@agent-scope/cli 1.18.1 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +907 -292
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +716 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +716 -110
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
// src/program.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
5
5
|
import { generateTest, loadTrace } from "@agent-scope/playwright";
|
|
6
|
-
import { Command as
|
|
6
|
+
import { Command as Command12 } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/browser.ts
|
|
9
9
|
import { writeFileSync } from "fs";
|
|
@@ -214,13 +214,15 @@ function buildTable(headers, rows) {
|
|
|
214
214
|
}
|
|
215
215
|
function formatListTable(rows) {
|
|
216
216
|
if (rows.length === 0) return "No components found.";
|
|
217
|
-
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
|
|
217
|
+
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
|
|
218
218
|
const tableRows = rows.map((r) => [
|
|
219
219
|
r.name,
|
|
220
220
|
r.file,
|
|
221
221
|
r.complexityClass,
|
|
222
222
|
String(r.hookCount),
|
|
223
|
-
String(r.contextCount)
|
|
223
|
+
String(r.contextCount),
|
|
224
|
+
r.collection ?? "\u2014",
|
|
225
|
+
r.internal ? "yes" : "no"
|
|
224
226
|
]);
|
|
225
227
|
return buildTable(headers, tableRows);
|
|
226
228
|
}
|
|
@@ -251,6 +253,8 @@ function formatGetTable(name, descriptor) {
|
|
|
251
253
|
` Composes: ${descriptor.composes.join(", ") || "none"}`,
|
|
252
254
|
` Composed By: ${descriptor.composedBy.join(", ") || "none"}`,
|
|
253
255
|
` Side Effects: ${formatSideEffects(descriptor.sideEffects)}`,
|
|
256
|
+
` Collection: ${descriptor.collection ?? "\u2014"}`,
|
|
257
|
+
` Internal: ${descriptor.internal}`,
|
|
254
258
|
"",
|
|
255
259
|
` Props (${propNames.length}):`
|
|
256
260
|
];
|
|
@@ -273,8 +277,16 @@ function formatGetJson(name, descriptor) {
|
|
|
273
277
|
}
|
|
274
278
|
function formatQueryTable(rows, queryDesc) {
|
|
275
279
|
if (rows.length === 0) return `No components match: ${queryDesc}`;
|
|
276
|
-
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
|
|
277
|
-
const tableRows = rows.map((r) => [
|
|
280
|
+
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
|
|
281
|
+
const tableRows = rows.map((r) => [
|
|
282
|
+
r.name,
|
|
283
|
+
r.file,
|
|
284
|
+
r.complexityClass,
|
|
285
|
+
r.hooks,
|
|
286
|
+
r.contexts,
|
|
287
|
+
r.collection ?? "\u2014",
|
|
288
|
+
r.internal ? "yes" : "no"
|
|
289
|
+
]);
|
|
278
290
|
return `Query: ${queryDesc}
|
|
279
291
|
|
|
280
292
|
${buildTable(headers, tableRows)}`;
|
|
@@ -569,10 +581,10 @@ async function getCompiledCssForClasses(cwd, classes) {
|
|
|
569
581
|
return build3(deduped);
|
|
570
582
|
}
|
|
571
583
|
async function compileGlobalCssFile(cssFilePath, cwd) {
|
|
572
|
-
const { existsSync:
|
|
584
|
+
const { existsSync: existsSync16, readFileSync: readFileSync14 } = await import("fs");
|
|
573
585
|
const { createRequire: createRequire3 } = await import("module");
|
|
574
|
-
if (!
|
|
575
|
-
const raw =
|
|
586
|
+
if (!existsSync16(cssFilePath)) return null;
|
|
587
|
+
const raw = readFileSync14(cssFilePath, "utf-8");
|
|
576
588
|
const needsCompile = /@tailwind|@import\s+['"]tailwindcss/.test(raw);
|
|
577
589
|
if (!needsCompile) {
|
|
578
590
|
return raw;
|
|
@@ -998,7 +1010,7 @@ function parseChecks(raw) {
|
|
|
998
1010
|
}
|
|
999
1011
|
function createCiCommand() {
|
|
1000
1012
|
return new Command("ci").description(
|
|
1001
|
-
"Run
|
|
1013
|
+
"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
1014
|
).option(
|
|
1003
1015
|
"-b, --baseline <dir>",
|
|
1004
1016
|
"Baseline directory for visual regression comparison (omit to skip)"
|
|
@@ -1042,18 +1054,212 @@ function createCiCommand() {
|
|
|
1042
1054
|
);
|
|
1043
1055
|
}
|
|
1044
1056
|
|
|
1057
|
+
// src/doctor-commands.ts
|
|
1058
|
+
import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
|
|
1059
|
+
import { join, resolve as resolve3 } from "path";
|
|
1060
|
+
import { Command as Command2 } from "commander";
|
|
1061
|
+
function collectSourceFiles(dir) {
|
|
1062
|
+
if (!existsSync3(dir)) return [];
|
|
1063
|
+
const results = [];
|
|
1064
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1065
|
+
const full = join(dir, entry.name);
|
|
1066
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".reactscope") {
|
|
1067
|
+
results.push(...collectSourceFiles(full));
|
|
1068
|
+
} else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
1069
|
+
results.push(full);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return results;
|
|
1073
|
+
}
|
|
1074
|
+
function checkConfig(cwd) {
|
|
1075
|
+
const configPath = resolve3(cwd, "reactscope.config.json");
|
|
1076
|
+
if (!existsSync3(configPath)) {
|
|
1077
|
+
return {
|
|
1078
|
+
name: "config",
|
|
1079
|
+
status: "error",
|
|
1080
|
+
message: "reactscope.config.json not found \u2014 run `scope init`"
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
JSON.parse(readFileSync3(configPath, "utf-8"));
|
|
1085
|
+
return { name: "config", status: "ok", message: "reactscope.config.json valid" };
|
|
1086
|
+
} catch {
|
|
1087
|
+
return { name: "config", status: "error", message: "reactscope.config.json is not valid JSON" };
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
function checkTokens(cwd) {
|
|
1091
|
+
const configPath = resolve3(cwd, "reactscope.config.json");
|
|
1092
|
+
let tokensPath = resolve3(cwd, "reactscope.tokens.json");
|
|
1093
|
+
if (existsSync3(configPath)) {
|
|
1094
|
+
try {
|
|
1095
|
+
const cfg = JSON.parse(readFileSync3(configPath, "utf-8"));
|
|
1096
|
+
if (cfg.tokens?.file) tokensPath = resolve3(cwd, cfg.tokens.file);
|
|
1097
|
+
} catch {
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (!existsSync3(tokensPath)) {
|
|
1101
|
+
return {
|
|
1102
|
+
name: "tokens",
|
|
1103
|
+
status: "warn",
|
|
1104
|
+
message: `Token file not found at ${tokensPath} \u2014 run \`scope init\``
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const raw = JSON.parse(readFileSync3(tokensPath, "utf-8"));
|
|
1109
|
+
if (!raw.version) {
|
|
1110
|
+
return { name: "tokens", status: "warn", message: "Token file is missing a `version` field" };
|
|
1111
|
+
}
|
|
1112
|
+
return { name: "tokens", status: "ok", message: "Token file valid" };
|
|
1113
|
+
} catch {
|
|
1114
|
+
return { name: "tokens", status: "error", message: "Token file is not valid JSON" };
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
function checkGlobalCss(cwd) {
|
|
1118
|
+
const configPath = resolve3(cwd, "reactscope.config.json");
|
|
1119
|
+
let globalCss = [];
|
|
1120
|
+
if (existsSync3(configPath)) {
|
|
1121
|
+
try {
|
|
1122
|
+
const cfg = JSON.parse(readFileSync3(configPath, "utf-8"));
|
|
1123
|
+
globalCss = cfg.components?.wrappers?.globalCSS ?? [];
|
|
1124
|
+
} catch {
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (globalCss.length === 0) {
|
|
1128
|
+
return {
|
|
1129
|
+
name: "globalCSS",
|
|
1130
|
+
status: "warn",
|
|
1131
|
+
message: "No globalCSS configured \u2014 Tailwind styles won't apply to renders. Add `components.wrappers.globalCSS` to reactscope.config.json"
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
const missing = globalCss.filter((f) => !existsSync3(resolve3(cwd, f)));
|
|
1135
|
+
if (missing.length > 0) {
|
|
1136
|
+
return {
|
|
1137
|
+
name: "globalCSS",
|
|
1138
|
+
status: "error",
|
|
1139
|
+
message: `globalCSS file(s) not found: ${missing.join(", ")}`
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
return {
|
|
1143
|
+
name: "globalCSS",
|
|
1144
|
+
status: "ok",
|
|
1145
|
+
message: `${globalCss.length} globalCSS file(s) present`
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
function checkManifest(cwd) {
|
|
1149
|
+
const manifestPath = resolve3(cwd, ".reactscope", "manifest.json");
|
|
1150
|
+
if (!existsSync3(manifestPath)) {
|
|
1151
|
+
return {
|
|
1152
|
+
name: "manifest",
|
|
1153
|
+
status: "warn",
|
|
1154
|
+
message: "Manifest not found \u2014 run `scope manifest generate`"
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
const manifestMtime = statSync(manifestPath).mtimeMs;
|
|
1158
|
+
const sourceDir = resolve3(cwd, "src");
|
|
1159
|
+
const sourceFiles = collectSourceFiles(sourceDir);
|
|
1160
|
+
const stale = sourceFiles.filter((f) => statSync(f).mtimeMs > manifestMtime);
|
|
1161
|
+
if (stale.length > 0) {
|
|
1162
|
+
return {
|
|
1163
|
+
name: "manifest",
|
|
1164
|
+
status: "warn",
|
|
1165
|
+
message: `Manifest may be stale \u2014 ${stale.length} source file(s) modified since last generate. Run \`scope manifest generate\``
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
return { name: "manifest", status: "ok", message: "Manifest present and up to date" };
|
|
1169
|
+
}
|
|
1170
|
+
var ICONS = { ok: "\u2713", warn: "!", error: "\u2717" };
|
|
1171
|
+
function formatCheck(check) {
|
|
1172
|
+
return ` [${ICONS[check.status]}] ${check.name.padEnd(12)} ${check.message}`;
|
|
1173
|
+
}
|
|
1174
|
+
function createDoctorCommand() {
|
|
1175
|
+
return new Command2("doctor").description(
|
|
1176
|
+
`Verify your Scope project setup before running other commands.
|
|
1177
|
+
|
|
1178
|
+
CHECKS PERFORMED:
|
|
1179
|
+
config reactscope.config.json exists and is valid JSON
|
|
1180
|
+
tokens reactscope.tokens.json exists and passes validation
|
|
1181
|
+
css globalCSS files referenced in config actually exist
|
|
1182
|
+
manifest .reactscope/manifest.json exists and is not stale
|
|
1183
|
+
(stale = source files modified after last generate)
|
|
1184
|
+
|
|
1185
|
+
STATUS LEVELS: ok | warn | error
|
|
1186
|
+
|
|
1187
|
+
Run this first whenever renders fail or produce unexpected output.
|
|
1188
|
+
|
|
1189
|
+
Examples:
|
|
1190
|
+
scope doctor
|
|
1191
|
+
scope doctor --json
|
|
1192
|
+
scope doctor --json | jq '.checks[] | select(.status == "error")'`
|
|
1193
|
+
).option("--json", "Emit structured JSON output", false).action((opts) => {
|
|
1194
|
+
const cwd = process.cwd();
|
|
1195
|
+
const checks = [
|
|
1196
|
+
checkConfig(cwd),
|
|
1197
|
+
checkTokens(cwd),
|
|
1198
|
+
checkGlobalCss(cwd),
|
|
1199
|
+
checkManifest(cwd)
|
|
1200
|
+
];
|
|
1201
|
+
const errors = checks.filter((c) => c.status === "error").length;
|
|
1202
|
+
const warnings = checks.filter((c) => c.status === "warn").length;
|
|
1203
|
+
if (opts.json) {
|
|
1204
|
+
process.stdout.write(
|
|
1205
|
+
`${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, checks }, null, 2)}
|
|
1206
|
+
`
|
|
1207
|
+
);
|
|
1208
|
+
if (errors > 0) process.exit(1);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
process.stdout.write("\nScope Doctor\n");
|
|
1212
|
+
process.stdout.write("\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");
|
|
1213
|
+
for (const check of checks) process.stdout.write(`${formatCheck(check)}
|
|
1214
|
+
`);
|
|
1215
|
+
process.stdout.write("\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");
|
|
1216
|
+
if (errors > 0) {
|
|
1217
|
+
process.stdout.write(` ${errors} error(s), ${warnings} warning(s)
|
|
1218
|
+
|
|
1219
|
+
`);
|
|
1220
|
+
process.exit(1);
|
|
1221
|
+
} else if (warnings > 0) {
|
|
1222
|
+
process.stdout.write(` ${warnings} warning(s) \u2014 everything works but could be better
|
|
1223
|
+
|
|
1224
|
+
`);
|
|
1225
|
+
} else {
|
|
1226
|
+
process.stdout.write(" All checks passed!\n\n");
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// src/get-skill-command.ts
|
|
1232
|
+
import { Command as Command3 } from "commander";
|
|
1233
|
+
|
|
1234
|
+
// src/skill-content.ts
|
|
1235
|
+
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**3-command workflow:**\n```\nscope init # scaffold config + auto-generate manifest\nscope manifest query --context ThemeContext # ask questions about the codebase\nscope render Button # produce a PNG of a component\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`)\nScope files let you define **named rendering scenarios** for a component alongside it in the source tree. They are the primary way to ensure `render all` produces meaningful screenshots.\n\n```tsx\n// Button.scope.tsx\nimport type { ScopeFile } from \'@agent-scope/cli\';\nimport { Button } from \'./Button\';\n\nexport default {\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 ScopeFile<typeof Button>;\n```\n\nKey rules:\n- The file must be named `<ComponentName>.scope.tsx` in the same directory\n- Export a default object where keys are scenario names and values are props\n- `render all` uses the `default` scenario (or first defined) as the primary screenshot\n- If 2+ scenarios exist, `render all` automatically runs a matrix and merges cells into the component JSON\n- Scenarios also feed the interactive Playground in the docs site\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\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\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 X`\n\n**"I want to render all variants of a component"**\n\u2192 Define all variants in `.scope.tsx`, then `scope render all` (auto-matrix)\n\u2192 Or: `scope render matrix X --axes \'variant:primary,secondary,danger\'`\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 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\n```bash\nnpm install -g @agent-scope/cli # global\nnpm install --save-dev @agent-scope/cli # per-project\n```\n\nBinary: `scope`\n\n---\n\n## Core Workflow\n\n```\ninit \u2192 manifest generate \u2192 manifest query/get/list \u2192 render \u2192 (token audit) \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, and manifest staleness.\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\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\n```\n$ scope doctor\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 Button\nscope render Button --props \'{"variant":"primary","children":"Click me"}\'\nscope render Button --viewport 375x812\nscope render Button --output button.png\nscope render 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 http://localhost:3000\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 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 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| `"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';
|
|
1236
|
+
|
|
1237
|
+
// src/get-skill-command.ts
|
|
1238
|
+
function createGetSkillCommand() {
|
|
1239
|
+
return new Command3("get-skill").description(
|
|
1240
|
+
'Print the embedded Scope SKILL.md to stdout.\n\nAgents: pipe this command into your context loader to bootstrap Scope knowledge.\nThe skill covers: when to use each command, config requirements, output format,\nrender engine selection, and common failure modes.\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'
|
|
1241
|
+
).option("--json", "Wrap output in JSON { skill: string } instead of raw markdown").action((opts) => {
|
|
1242
|
+
if (opts.json) {
|
|
1243
|
+
process.stdout.write(`${JSON.stringify({ skill: SKILL_CONTENT }, null, 2)}
|
|
1244
|
+
`);
|
|
1245
|
+
} else {
|
|
1246
|
+
process.stdout.write(SKILL_CONTENT);
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1045
1251
|
// src/init/index.ts
|
|
1046
|
-
import { appendFileSync, existsSync as
|
|
1047
|
-
import { join as
|
|
1252
|
+
import { appendFileSync, existsSync as existsSync5, mkdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1253
|
+
import { join as join3 } from "path";
|
|
1048
1254
|
import * as readline from "readline";
|
|
1049
1255
|
|
|
1050
1256
|
// src/init/detect.ts
|
|
1051
|
-
import { existsSync as
|
|
1052
|
-
import { join } from "path";
|
|
1257
|
+
import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
|
|
1258
|
+
import { join as join2 } from "path";
|
|
1053
1259
|
function hasConfigFile(dir, stem) {
|
|
1054
|
-
if (!
|
|
1260
|
+
if (!existsSync4(dir)) return false;
|
|
1055
1261
|
try {
|
|
1056
|
-
const entries =
|
|
1262
|
+
const entries = readdirSync2(dir);
|
|
1057
1263
|
return entries.some((f) => f === stem || f.startsWith(`${stem}.`));
|
|
1058
1264
|
} catch {
|
|
1059
1265
|
return false;
|
|
@@ -1061,7 +1267,7 @@ function hasConfigFile(dir, stem) {
|
|
|
1061
1267
|
}
|
|
1062
1268
|
function readSafe(path) {
|
|
1063
1269
|
try {
|
|
1064
|
-
return
|
|
1270
|
+
return readFileSync4(path, "utf-8");
|
|
1065
1271
|
} catch {
|
|
1066
1272
|
return null;
|
|
1067
1273
|
}
|
|
@@ -1074,15 +1280,15 @@ function detectFramework(rootDir, packageDeps) {
|
|
|
1074
1280
|
return "unknown";
|
|
1075
1281
|
}
|
|
1076
1282
|
function detectPackageManager(rootDir) {
|
|
1077
|
-
if (
|
|
1078
|
-
if (
|
|
1079
|
-
if (
|
|
1080
|
-
if (
|
|
1283
|
+
if (existsSync4(join2(rootDir, "bun.lock"))) return "bun";
|
|
1284
|
+
if (existsSync4(join2(rootDir, "yarn.lock"))) return "yarn";
|
|
1285
|
+
if (existsSync4(join2(rootDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
1286
|
+
if (existsSync4(join2(rootDir, "package-lock.json"))) return "npm";
|
|
1081
1287
|
return "npm";
|
|
1082
1288
|
}
|
|
1083
1289
|
function detectTypeScript(rootDir) {
|
|
1084
|
-
const candidate =
|
|
1085
|
-
if (
|
|
1290
|
+
const candidate = join2(rootDir, "tsconfig.json");
|
|
1291
|
+
if (existsSync4(candidate)) {
|
|
1086
1292
|
return { typescript: true, tsconfigPath: candidate };
|
|
1087
1293
|
}
|
|
1088
1294
|
return { typescript: false, tsconfigPath: null };
|
|
@@ -1094,11 +1300,11 @@ function detectComponentPatterns(rootDir, typescript) {
|
|
|
1094
1300
|
const ext = typescript ? "tsx" : "jsx";
|
|
1095
1301
|
const altExt = typescript ? "jsx" : "jsx";
|
|
1096
1302
|
for (const dir of COMPONENT_DIRS) {
|
|
1097
|
-
const absDir =
|
|
1098
|
-
if (!
|
|
1303
|
+
const absDir = join2(rootDir, dir);
|
|
1304
|
+
if (!existsSync4(absDir)) continue;
|
|
1099
1305
|
let hasComponents = false;
|
|
1100
1306
|
try {
|
|
1101
|
-
const entries =
|
|
1307
|
+
const entries = readdirSync2(absDir, { withFileTypes: true });
|
|
1102
1308
|
hasComponents = entries.some(
|
|
1103
1309
|
(e) => e.isFile() && COMPONENT_EXTS.some((x) => e.name.endsWith(x))
|
|
1104
1310
|
);
|
|
@@ -1106,7 +1312,7 @@ function detectComponentPatterns(rootDir, typescript) {
|
|
|
1106
1312
|
hasComponents = entries.some(
|
|
1107
1313
|
(e) => e.isDirectory() && (() => {
|
|
1108
1314
|
try {
|
|
1109
|
-
return
|
|
1315
|
+
return readdirSync2(join2(absDir, e.name)).some(
|
|
1110
1316
|
(f) => COMPONENT_EXTS.some((x) => f.endsWith(x))
|
|
1111
1317
|
);
|
|
1112
1318
|
} catch {
|
|
@@ -1143,7 +1349,7 @@ var GLOBAL_CSS_CANDIDATES = [
|
|
|
1143
1349
|
"styles/index.css"
|
|
1144
1350
|
];
|
|
1145
1351
|
function detectGlobalCSSFiles(rootDir) {
|
|
1146
|
-
return GLOBAL_CSS_CANDIDATES.filter((rel) =>
|
|
1352
|
+
return GLOBAL_CSS_CANDIDATES.filter((rel) => existsSync4(join2(rootDir, rel)));
|
|
1147
1353
|
}
|
|
1148
1354
|
var TAILWIND_STEMS = ["tailwind.config"];
|
|
1149
1355
|
var CSS_EXTS = [".css", ".scss", ".sass", ".less"];
|
|
@@ -1154,23 +1360,23 @@ function detectTokenSources(rootDir) {
|
|
|
1154
1360
|
for (const stem of TAILWIND_STEMS) {
|
|
1155
1361
|
if (hasConfigFile(rootDir, stem)) {
|
|
1156
1362
|
try {
|
|
1157
|
-
const entries =
|
|
1363
|
+
const entries = readdirSync2(rootDir);
|
|
1158
1364
|
const match = entries.find((f) => f === stem || f.startsWith(`${stem}.`));
|
|
1159
1365
|
if (match) {
|
|
1160
|
-
sources.push({ kind: "tailwind-config", path:
|
|
1366
|
+
sources.push({ kind: "tailwind-config", path: join2(rootDir, match) });
|
|
1161
1367
|
}
|
|
1162
1368
|
} catch {
|
|
1163
1369
|
}
|
|
1164
1370
|
}
|
|
1165
1371
|
}
|
|
1166
|
-
const srcDir =
|
|
1167
|
-
const dirsToScan =
|
|
1372
|
+
const srcDir = join2(rootDir, "src");
|
|
1373
|
+
const dirsToScan = existsSync4(srcDir) ? [srcDir] : [];
|
|
1168
1374
|
for (const scanDir of dirsToScan) {
|
|
1169
1375
|
try {
|
|
1170
|
-
const entries =
|
|
1376
|
+
const entries = readdirSync2(scanDir, { withFileTypes: true });
|
|
1171
1377
|
for (const entry of entries) {
|
|
1172
1378
|
if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
|
|
1173
|
-
const filePath =
|
|
1379
|
+
const filePath = join2(scanDir, entry.name);
|
|
1174
1380
|
const content = readSafe(filePath);
|
|
1175
1381
|
if (content !== null && CSS_CUSTOM_PROPS_RE.test(content)) {
|
|
1176
1382
|
sources.push({ kind: "css-custom-properties", path: filePath });
|
|
@@ -1180,12 +1386,12 @@ function detectTokenSources(rootDir) {
|
|
|
1180
1386
|
} catch {
|
|
1181
1387
|
}
|
|
1182
1388
|
}
|
|
1183
|
-
if (
|
|
1389
|
+
if (existsSync4(srcDir)) {
|
|
1184
1390
|
try {
|
|
1185
|
-
const entries =
|
|
1391
|
+
const entries = readdirSync2(srcDir);
|
|
1186
1392
|
for (const entry of entries) {
|
|
1187
1393
|
if (THEME_SUFFIXES.some((s) => entry.endsWith(s))) {
|
|
1188
|
-
sources.push({ kind: "theme-file", path:
|
|
1394
|
+
sources.push({ kind: "theme-file", path: join2(srcDir, entry) });
|
|
1189
1395
|
}
|
|
1190
1396
|
}
|
|
1191
1397
|
} catch {
|
|
@@ -1194,7 +1400,7 @@ function detectTokenSources(rootDir) {
|
|
|
1194
1400
|
return sources;
|
|
1195
1401
|
}
|
|
1196
1402
|
function detectProject(rootDir) {
|
|
1197
|
-
const pkgPath =
|
|
1403
|
+
const pkgPath = join2(rootDir, "package.json");
|
|
1198
1404
|
let packageDeps = {};
|
|
1199
1405
|
const pkgContent = readSafe(pkgPath);
|
|
1200
1406
|
if (pkgContent !== null) {
|
|
@@ -1225,7 +1431,8 @@ function detectProject(rootDir) {
|
|
|
1225
1431
|
}
|
|
1226
1432
|
|
|
1227
1433
|
// src/init/index.ts
|
|
1228
|
-
import {
|
|
1434
|
+
import { generateManifest as generateManifest2 } from "@agent-scope/manifest";
|
|
1435
|
+
import { Command as Command4 } from "commander";
|
|
1229
1436
|
function buildDefaultConfig(detected, tokenFile, outputDir) {
|
|
1230
1437
|
const include = detected.componentPatterns.length > 0 ? detected.componentPatterns : ["src/**/*.tsx"];
|
|
1231
1438
|
return {
|
|
@@ -1263,9 +1470,9 @@ function createRL() {
|
|
|
1263
1470
|
});
|
|
1264
1471
|
}
|
|
1265
1472
|
async function ask(rl, question) {
|
|
1266
|
-
return new Promise((
|
|
1473
|
+
return new Promise((resolve19) => {
|
|
1267
1474
|
rl.question(question, (answer) => {
|
|
1268
|
-
|
|
1475
|
+
resolve19(answer.trim());
|
|
1269
1476
|
});
|
|
1270
1477
|
});
|
|
1271
1478
|
}
|
|
@@ -1274,9 +1481,9 @@ async function askWithDefault(rl, label, defaultValue) {
|
|
|
1274
1481
|
return answer.length > 0 ? answer : defaultValue;
|
|
1275
1482
|
}
|
|
1276
1483
|
function ensureGitignoreEntry(rootDir, entry) {
|
|
1277
|
-
const gitignorePath =
|
|
1278
|
-
if (
|
|
1279
|
-
const content =
|
|
1484
|
+
const gitignorePath = join3(rootDir, ".gitignore");
|
|
1485
|
+
if (existsSync5(gitignorePath)) {
|
|
1486
|
+
const content = readFileSync5(gitignorePath, "utf-8");
|
|
1280
1487
|
const normalised = entry.replace(/\/$/, "");
|
|
1281
1488
|
const lines = content.split("\n").map((l) => l.trim());
|
|
1282
1489
|
if (lines.includes(entry) || lines.includes(normalised)) {
|
|
@@ -1305,7 +1512,7 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1305
1512
|
return result;
|
|
1306
1513
|
};
|
|
1307
1514
|
var parseBlock = parseBlock2;
|
|
1308
|
-
const raw =
|
|
1515
|
+
const raw = readFileSync5(tailwindSource.path, "utf-8");
|
|
1309
1516
|
const tokens = {};
|
|
1310
1517
|
const colorsKeyIdx = raw.indexOf("colors:");
|
|
1311
1518
|
if (colorsKeyIdx !== -1) {
|
|
@@ -1340,7 +1547,7 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1340
1547
|
}
|
|
1341
1548
|
}
|
|
1342
1549
|
if (Object.keys(colorTokens).length > 0) {
|
|
1343
|
-
tokens
|
|
1550
|
+
tokens.color = colorTokens;
|
|
1344
1551
|
}
|
|
1345
1552
|
}
|
|
1346
1553
|
}
|
|
@@ -1353,7 +1560,7 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1353
1560
|
for (const [key, val] of Object.entries(spacingValues)) {
|
|
1354
1561
|
spacingTokens[key] = { value: val, type: "dimension" };
|
|
1355
1562
|
}
|
|
1356
|
-
tokens
|
|
1563
|
+
tokens.spacing = spacingTokens;
|
|
1357
1564
|
}
|
|
1358
1565
|
}
|
|
1359
1566
|
const fontFamilyMatch = raw.match(/fontFamily\s*:\s*\{([\s\S]*?)\n\s*\}/);
|
|
@@ -1366,7 +1573,7 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1366
1573
|
}
|
|
1367
1574
|
}
|
|
1368
1575
|
if (Object.keys(fontTokens).length > 0) {
|
|
1369
|
-
tokens
|
|
1576
|
+
tokens.font = fontTokens;
|
|
1370
1577
|
}
|
|
1371
1578
|
}
|
|
1372
1579
|
const borderRadiusMatch = raw.match(/borderRadius\s*:\s*\{([\s\S]*?)\n\s*\}/);
|
|
@@ -1377,7 +1584,7 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1377
1584
|
for (const [key, val] of Object.entries(radiusValues)) {
|
|
1378
1585
|
radiusTokens[key] = { value: val, type: "dimension" };
|
|
1379
1586
|
}
|
|
1380
|
-
tokens
|
|
1587
|
+
tokens.radius = radiusTokens;
|
|
1381
1588
|
}
|
|
1382
1589
|
}
|
|
1383
1590
|
return Object.keys(tokens).length > 0 ? tokens : null;
|
|
@@ -1386,14 +1593,14 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1386
1593
|
}
|
|
1387
1594
|
}
|
|
1388
1595
|
function scaffoldConfig(rootDir, config) {
|
|
1389
|
-
const path =
|
|
1596
|
+
const path = join3(rootDir, "reactscope.config.json");
|
|
1390
1597
|
writeFileSync3(path, `${JSON.stringify(config, null, 2)}
|
|
1391
1598
|
`);
|
|
1392
1599
|
return path;
|
|
1393
1600
|
}
|
|
1394
1601
|
function scaffoldTokenFile(rootDir, tokenFile, extractedTokens) {
|
|
1395
|
-
const path =
|
|
1396
|
-
if (!
|
|
1602
|
+
const path = join3(rootDir, tokenFile);
|
|
1603
|
+
if (!existsSync5(path)) {
|
|
1397
1604
|
const stub = {
|
|
1398
1605
|
$schema: "https://raw.githubusercontent.com/FlatFilers/Scope/main/packages/tokens/schema.json",
|
|
1399
1606
|
version: "1.0.0",
|
|
@@ -1409,19 +1616,19 @@ function scaffoldTokenFile(rootDir, tokenFile, extractedTokens) {
|
|
|
1409
1616
|
return path;
|
|
1410
1617
|
}
|
|
1411
1618
|
function scaffoldOutputDir(rootDir, outputDir) {
|
|
1412
|
-
const dirPath =
|
|
1619
|
+
const dirPath = join3(rootDir, outputDir);
|
|
1413
1620
|
mkdirSync(dirPath, { recursive: true });
|
|
1414
|
-
const keepPath =
|
|
1415
|
-
if (!
|
|
1621
|
+
const keepPath = join3(dirPath, ".gitkeep");
|
|
1622
|
+
if (!existsSync5(keepPath)) {
|
|
1416
1623
|
writeFileSync3(keepPath, "");
|
|
1417
1624
|
}
|
|
1418
1625
|
return dirPath;
|
|
1419
1626
|
}
|
|
1420
1627
|
async function runInit(options) {
|
|
1421
1628
|
const rootDir = options.cwd ?? process.cwd();
|
|
1422
|
-
const configPath =
|
|
1629
|
+
const configPath = join3(rootDir, "reactscope.config.json");
|
|
1423
1630
|
const created = [];
|
|
1424
|
-
if (
|
|
1631
|
+
if (existsSync5(configPath) && !options.force) {
|
|
1425
1632
|
const msg = "reactscope.config.json already exists. Run with --force to overwrite.";
|
|
1426
1633
|
process.stderr.write(`\u26A0\uFE0F ${msg}
|
|
1427
1634
|
`);
|
|
@@ -1496,7 +1703,28 @@ async function runInit(options) {
|
|
|
1496
1703
|
process.stdout.write(` ${p}
|
|
1497
1704
|
`);
|
|
1498
1705
|
}
|
|
1499
|
-
process.stdout.write("\n
|
|
1706
|
+
process.stdout.write("\n Scanning components...\n");
|
|
1707
|
+
try {
|
|
1708
|
+
const manifestConfig = {
|
|
1709
|
+
include: config.components.include,
|
|
1710
|
+
rootDir
|
|
1711
|
+
};
|
|
1712
|
+
const manifest = await generateManifest2(manifestConfig);
|
|
1713
|
+
const manifestCount = Object.keys(manifest.components).length;
|
|
1714
|
+
const manifestOutPath = join3(rootDir, config.output.dir, "manifest.json");
|
|
1715
|
+
mkdirSync(join3(rootDir, config.output.dir), { recursive: true });
|
|
1716
|
+
writeFileSync3(manifestOutPath, `${JSON.stringify(manifest, null, 2)}
|
|
1717
|
+
`);
|
|
1718
|
+
process.stdout.write(
|
|
1719
|
+
` Found ${manifestCount} component(s) \u2014 manifest written to ${manifestOutPath}
|
|
1720
|
+
`
|
|
1721
|
+
);
|
|
1722
|
+
} catch {
|
|
1723
|
+
process.stdout.write(
|
|
1724
|
+
" (manifest generate skipped \u2014 run `scope manifest generate` manually)\n"
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
process.stdout.write("\n");
|
|
1500
1728
|
return {
|
|
1501
1729
|
success: true,
|
|
1502
1730
|
message: "Project initialised successfully.",
|
|
@@ -1505,7 +1733,9 @@ async function runInit(options) {
|
|
|
1505
1733
|
};
|
|
1506
1734
|
}
|
|
1507
1735
|
function createInitCommand() {
|
|
1508
|
-
return new
|
|
1736
|
+
return new Command4("init").description(
|
|
1737
|
+
"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"
|
|
1738
|
+
).option("-y, --yes", "Accept all detected defaults without prompting", false).option("--force", "Overwrite existing reactscope.config.json if present", false).action(async (opts) => {
|
|
1509
1739
|
try {
|
|
1510
1740
|
const result = await runInit({ yes: opts.yes, force: opts.force });
|
|
1511
1741
|
if (!result.success && !result.skipped) {
|
|
@@ -1520,24 +1750,24 @@ function createInitCommand() {
|
|
|
1520
1750
|
}
|
|
1521
1751
|
|
|
1522
1752
|
// src/instrument/renders.ts
|
|
1523
|
-
import { resolve as
|
|
1753
|
+
import { resolve as resolve8 } from "path";
|
|
1524
1754
|
import { getBrowserEntryScript as getBrowserEntryScript5 } from "@agent-scope/playwright";
|
|
1525
1755
|
import { BrowserPool as BrowserPool2 } from "@agent-scope/render";
|
|
1526
|
-
import { Command as
|
|
1756
|
+
import { Command as Command7 } from "commander";
|
|
1527
1757
|
|
|
1528
1758
|
// src/manifest-commands.ts
|
|
1529
|
-
import { existsSync as
|
|
1530
|
-
import { resolve as
|
|
1531
|
-
import { generateManifest as
|
|
1532
|
-
import { Command as
|
|
1759
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
1760
|
+
import { resolve as resolve4 } from "path";
|
|
1761
|
+
import { generateManifest as generateManifest3 } from "@agent-scope/manifest";
|
|
1762
|
+
import { Command as Command5 } from "commander";
|
|
1533
1763
|
var MANIFEST_PATH = ".reactscope/manifest.json";
|
|
1534
1764
|
function loadManifest(manifestPath = MANIFEST_PATH) {
|
|
1535
|
-
const absPath =
|
|
1536
|
-
if (!
|
|
1765
|
+
const absPath = resolve4(process.cwd(), manifestPath);
|
|
1766
|
+
if (!existsSync6(absPath)) {
|
|
1537
1767
|
throw new Error(`Manifest not found at ${absPath}.
|
|
1538
1768
|
Run \`scope manifest generate\` first.`);
|
|
1539
1769
|
}
|
|
1540
|
-
const raw =
|
|
1770
|
+
const raw = readFileSync6(absPath, "utf-8");
|
|
1541
1771
|
return JSON.parse(raw);
|
|
1542
1772
|
}
|
|
1543
1773
|
function resolveFormat(formatFlag) {
|
|
@@ -1546,34 +1776,56 @@ function resolveFormat(formatFlag) {
|
|
|
1546
1776
|
return isTTY() ? "table" : "json";
|
|
1547
1777
|
}
|
|
1548
1778
|
function registerList(manifestCmd) {
|
|
1549
|
-
manifestCmd.command("list").description(
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1779
|
+
manifestCmd.command("list").description(
|
|
1780
|
+
`List all components in the manifest as a table (TTY) or JSON (piped).
|
|
1781
|
+
|
|
1782
|
+
Examples:
|
|
1783
|
+
scope manifest list
|
|
1784
|
+
scope manifest list --format json | jq '.[].name'
|
|
1785
|
+
scope manifest list --filter "Button*"`
|
|
1786
|
+
).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(
|
|
1787
|
+
(opts) => {
|
|
1788
|
+
try {
|
|
1789
|
+
const manifest = loadManifest(opts.manifest);
|
|
1790
|
+
const format = resolveFormat(opts.format);
|
|
1791
|
+
let entries = Object.entries(manifest.components);
|
|
1792
|
+
if (opts.filter !== void 0) {
|
|
1793
|
+
const filterPattern = opts.filter ?? "";
|
|
1794
|
+
entries = entries.filter(([name]) => matchGlob(filterPattern, name));
|
|
1795
|
+
}
|
|
1796
|
+
if (opts.collection !== void 0) {
|
|
1797
|
+
const col = opts.collection;
|
|
1798
|
+
entries = entries.filter(([, d]) => d.collection === col);
|
|
1799
|
+
}
|
|
1800
|
+
if (opts.internal === true) {
|
|
1801
|
+
entries = entries.filter(([, d]) => d.internal);
|
|
1802
|
+
} else if (opts.internal === false) {
|
|
1803
|
+
entries = entries.filter(([, d]) => !d.internal);
|
|
1804
|
+
}
|
|
1805
|
+
const rows = entries.map(([name, descriptor]) => ({
|
|
1806
|
+
name,
|
|
1807
|
+
file: descriptor.filePath,
|
|
1808
|
+
complexityClass: descriptor.complexityClass,
|
|
1809
|
+
hookCount: descriptor.detectedHooks.length,
|
|
1810
|
+
contextCount: descriptor.requiredContexts.length,
|
|
1811
|
+
collection: descriptor.collection,
|
|
1812
|
+
internal: descriptor.internal
|
|
1813
|
+
}));
|
|
1814
|
+
const output = format === "json" ? formatListJson(rows) : formatListTable(rows);
|
|
1815
|
+
process.stdout.write(`${output}
|
|
1567
1816
|
`);
|
|
1568
|
-
|
|
1569
|
-
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1570
1819
|
`);
|
|
1571
|
-
|
|
1820
|
+
process.exit(1);
|
|
1821
|
+
}
|
|
1572
1822
|
}
|
|
1573
|
-
|
|
1823
|
+
);
|
|
1574
1824
|
}
|
|
1575
1825
|
function registerGet(manifestCmd) {
|
|
1576
|
-
manifestCmd.command("get <name>").description(
|
|
1826
|
+
manifestCmd.command("get <name>").description(
|
|
1827
|
+
"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'"
|
|
1828
|
+
).option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action((name, opts) => {
|
|
1577
1829
|
try {
|
|
1578
1830
|
const manifest = loadManifest(opts.manifest);
|
|
1579
1831
|
const format = resolveFormat(opts.format);
|
|
@@ -1597,10 +1849,12 @@ Available: ${available}${hint}`
|
|
|
1597
1849
|
});
|
|
1598
1850
|
}
|
|
1599
1851
|
function registerQuery(manifestCmd) {
|
|
1600
|
-
manifestCmd.command("query").description(
|
|
1852
|
+
manifestCmd.command("query").description(
|
|
1853
|
+
'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'
|
|
1854
|
+
).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(
|
|
1601
1855
|
"--has-prop <spec>",
|
|
1602
1856
|
"Find components with a prop matching name or name:type (e.g. 'loading' or 'variant:union')"
|
|
1603
|
-
).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(
|
|
1857
|
+
).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(
|
|
1604
1858
|
(opts) => {
|
|
1605
1859
|
try {
|
|
1606
1860
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -1613,9 +1867,11 @@ function registerQuery(manifestCmd) {
|
|
|
1613
1867
|
if (opts.hasFetch) queryParts.push("has-fetch");
|
|
1614
1868
|
if (opts.hasProp !== void 0) queryParts.push(`has-prop=${opts.hasProp}`);
|
|
1615
1869
|
if (opts.composedBy !== void 0) queryParts.push(`composed-by=${opts.composedBy}`);
|
|
1870
|
+
if (opts.internal) queryParts.push("internal");
|
|
1871
|
+
if (opts.collection !== void 0) queryParts.push(`collection=${opts.collection}`);
|
|
1616
1872
|
if (queryParts.length === 0) {
|
|
1617
1873
|
process.stderr.write(
|
|
1618
|
-
"No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop,
|
|
1874
|
+
"No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop, --composed-by, --internal, or --collection.\n"
|
|
1619
1875
|
);
|
|
1620
1876
|
process.exit(1);
|
|
1621
1877
|
}
|
|
@@ -1660,15 +1916,24 @@ function registerQuery(manifestCmd) {
|
|
|
1660
1916
|
const targetName = opts.composedBy;
|
|
1661
1917
|
entries = entries.filter(([, d]) => {
|
|
1662
1918
|
const composedBy = d.composedBy;
|
|
1663
|
-
return composedBy
|
|
1919
|
+
return composedBy?.includes(targetName);
|
|
1664
1920
|
});
|
|
1665
1921
|
}
|
|
1922
|
+
if (opts.internal) {
|
|
1923
|
+
entries = entries.filter(([, d]) => d.internal);
|
|
1924
|
+
}
|
|
1925
|
+
if (opts.collection !== void 0) {
|
|
1926
|
+
const col = opts.collection;
|
|
1927
|
+
entries = entries.filter(([, d]) => d.collection === col);
|
|
1928
|
+
}
|
|
1666
1929
|
const rows = entries.map(([name, d]) => ({
|
|
1667
1930
|
name,
|
|
1668
1931
|
file: d.filePath,
|
|
1669
1932
|
complexityClass: d.complexityClass,
|
|
1670
1933
|
hooks: d.detectedHooks.join(", ") || "\u2014",
|
|
1671
|
-
contexts: d.requiredContexts.join(", ") || "\u2014"
|
|
1934
|
+
contexts: d.requiredContexts.join(", ") || "\u2014",
|
|
1935
|
+
collection: d.collection,
|
|
1936
|
+
internal: d.internal
|
|
1672
1937
|
}));
|
|
1673
1938
|
const output = format === "json" ? formatQueryJson(rows) : formatQueryTable(rows, queryDesc);
|
|
1674
1939
|
process.stdout.write(`${output}
|
|
@@ -1683,16 +1948,16 @@ function registerQuery(manifestCmd) {
|
|
|
1683
1948
|
}
|
|
1684
1949
|
function registerGenerate(manifestCmd) {
|
|
1685
1950
|
manifestCmd.command("generate").description(
|
|
1686
|
-
|
|
1951
|
+
'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\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'
|
|
1687
1952
|
).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) => {
|
|
1688
1953
|
try {
|
|
1689
|
-
const rootDir =
|
|
1690
|
-
const outputPath =
|
|
1954
|
+
const rootDir = resolve4(process.cwd(), opts.root ?? ".");
|
|
1955
|
+
const outputPath = resolve4(process.cwd(), opts.output);
|
|
1691
1956
|
const include = opts.include?.split(",").map((s) => s.trim());
|
|
1692
1957
|
const exclude = opts.exclude?.split(",").map((s) => s.trim());
|
|
1693
1958
|
process.stderr.write(`Scanning ${rootDir} for React components...
|
|
1694
1959
|
`);
|
|
1695
|
-
const manifest = await
|
|
1960
|
+
const manifest = await generateManifest3({
|
|
1696
1961
|
rootDir,
|
|
1697
1962
|
...include !== void 0 && { include },
|
|
1698
1963
|
...exclude !== void 0 && { exclude }
|
|
@@ -1701,7 +1966,7 @@ function registerGenerate(manifestCmd) {
|
|
|
1701
1966
|
process.stderr.write(`Found ${componentCount} components.
|
|
1702
1967
|
`);
|
|
1703
1968
|
const outputDir = outputPath.replace(/\/[^/]+$/, "");
|
|
1704
|
-
if (!
|
|
1969
|
+
if (!existsSync6(outputDir)) {
|
|
1705
1970
|
mkdirSync2(outputDir, { recursive: true });
|
|
1706
1971
|
}
|
|
1707
1972
|
writeFileSync4(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
@@ -1717,8 +1982,8 @@ function registerGenerate(manifestCmd) {
|
|
|
1717
1982
|
});
|
|
1718
1983
|
}
|
|
1719
1984
|
function createManifestCommand() {
|
|
1720
|
-
const manifestCmd = new
|
|
1721
|
-
"Query and explore the component manifest"
|
|
1985
|
+
const manifestCmd = new Command5("manifest").description(
|
|
1986
|
+
"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"
|
|
1722
1987
|
);
|
|
1723
1988
|
registerList(manifestCmd);
|
|
1724
1989
|
registerGet(manifestCmd);
|
|
@@ -1728,7 +1993,7 @@ function createManifestCommand() {
|
|
|
1728
1993
|
}
|
|
1729
1994
|
|
|
1730
1995
|
// src/instrument/hooks.ts
|
|
1731
|
-
import { resolve as
|
|
1996
|
+
import { resolve as resolve5 } from "path";
|
|
1732
1997
|
import { getBrowserEntryScript as getBrowserEntryScript2 } from "@agent-scope/playwright";
|
|
1733
1998
|
import { Command as Cmd } from "commander";
|
|
1734
1999
|
import { chromium as chromium2 } from "playwright";
|
|
@@ -2040,7 +2305,19 @@ async function runHooksProfiling(componentName, filePath, props) {
|
|
|
2040
2305
|
}
|
|
2041
2306
|
function createInstrumentHooksCommand() {
|
|
2042
2307
|
const cmd = new Cmd("hooks").description(
|
|
2043
|
-
|
|
2308
|
+
`Profile per-hook-instance data for a component.
|
|
2309
|
+
|
|
2310
|
+
METRICS CAPTURED per hook instance:
|
|
2311
|
+
useState update count, current value
|
|
2312
|
+
useCallback cache hit rate (stable reference %)
|
|
2313
|
+
useMemo cache hit rate (recompute %)
|
|
2314
|
+
useEffect execution count
|
|
2315
|
+
useRef current value snapshot
|
|
2316
|
+
|
|
2317
|
+
Examples:
|
|
2318
|
+
scope instrument hooks SearchInput
|
|
2319
|
+
scope instrument hooks SearchInput --props '{"value":"hello"}' --json
|
|
2320
|
+
scope instrument hooks Dropdown --json | jq '.hooks[] | select(.type == "useMemo")' `
|
|
2044
2321
|
).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(
|
|
2045
2322
|
async (componentName, opts) => {
|
|
2046
2323
|
try {
|
|
@@ -2060,7 +2337,7 @@ Available: ${available}`
|
|
|
2060
2337
|
throw new Error(`Invalid props JSON: ${opts.props}`);
|
|
2061
2338
|
}
|
|
2062
2339
|
const rootDir = process.cwd();
|
|
2063
|
-
const filePath =
|
|
2340
|
+
const filePath = resolve5(rootDir, descriptor.filePath);
|
|
2064
2341
|
process.stderr.write(`Instrumenting hooks for ${componentName}\u2026
|
|
2065
2342
|
`);
|
|
2066
2343
|
const result = await runHooksProfiling(componentName, filePath, props);
|
|
@@ -2088,7 +2365,7 @@ Available: ${available}`
|
|
|
2088
2365
|
}
|
|
2089
2366
|
|
|
2090
2367
|
// src/instrument/profile.ts
|
|
2091
|
-
import { resolve as
|
|
2368
|
+
import { resolve as resolve6 } from "path";
|
|
2092
2369
|
import { getBrowserEntryScript as getBrowserEntryScript3 } from "@agent-scope/playwright";
|
|
2093
2370
|
import { Command as Cmd2 } from "commander";
|
|
2094
2371
|
import { chromium as chromium3 } from "playwright";
|
|
@@ -2330,7 +2607,19 @@ async function runInteractionProfile(componentName, filePath, props, interaction
|
|
|
2330
2607
|
}
|
|
2331
2608
|
function createInstrumentProfileCommand() {
|
|
2332
2609
|
const cmd = new Cmd2("profile").description(
|
|
2333
|
-
|
|
2610
|
+
`Capture a full performance profile for an interaction sequence.
|
|
2611
|
+
|
|
2612
|
+
PROFILE INCLUDES:
|
|
2613
|
+
renders total re-renders triggered by the interaction
|
|
2614
|
+
timing interaction start \u2192 paint time (ms)
|
|
2615
|
+
layoutShifts cumulative layout shift (CLS) score
|
|
2616
|
+
scriptTime JS execution time (ms)
|
|
2617
|
+
longTasks count of tasks >50ms
|
|
2618
|
+
|
|
2619
|
+
Examples:
|
|
2620
|
+
scope instrument profile Button --interaction '[{"action":"click","target":"button"}]'
|
|
2621
|
+
scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]' --json
|
|
2622
|
+
scope instrument profile Form --json | jq '.summary.renderCount'`
|
|
2334
2623
|
).argument("<component>", "Component name (must exist in the manifest)").option(
|
|
2335
2624
|
"--interaction <json>",
|
|
2336
2625
|
`Interaction steps JSON, e.g. '[{"action":"click","target":"button.primary"}]'`,
|
|
@@ -2363,7 +2652,7 @@ Available: ${available}`
|
|
|
2363
2652
|
throw new Error(`Invalid interaction JSON: ${opts.interaction}`);
|
|
2364
2653
|
}
|
|
2365
2654
|
const rootDir = process.cwd();
|
|
2366
|
-
const filePath =
|
|
2655
|
+
const filePath = resolve6(rootDir, descriptor.filePath);
|
|
2367
2656
|
process.stderr.write(`Profiling interaction for ${componentName}\u2026
|
|
2368
2657
|
`);
|
|
2369
2658
|
const result = await runInteractionProfile(componentName, filePath, props, interaction);
|
|
@@ -2391,9 +2680,9 @@ Available: ${available}`
|
|
|
2391
2680
|
}
|
|
2392
2681
|
|
|
2393
2682
|
// src/instrument/tree.ts
|
|
2394
|
-
import { resolve as
|
|
2683
|
+
import { resolve as resolve7 } from "path";
|
|
2395
2684
|
import { getBrowserEntryScript as getBrowserEntryScript4 } from "@agent-scope/playwright";
|
|
2396
|
-
import { Command as
|
|
2685
|
+
import { Command as Command6 } from "commander";
|
|
2397
2686
|
import { chromium as chromium4 } from "playwright";
|
|
2398
2687
|
var MANIFEST_PATH4 = ".reactscope/manifest.json";
|
|
2399
2688
|
var DEFAULT_VIEWPORT_WIDTH = 375;
|
|
@@ -2674,7 +2963,21 @@ async function runInstrumentTree(options) {
|
|
|
2674
2963
|
}
|
|
2675
2964
|
}
|
|
2676
2965
|
function createInstrumentTreeCommand() {
|
|
2677
|
-
return new
|
|
2966
|
+
return new Command6("tree").description(
|
|
2967
|
+
`Render a component and output the full instrumentation tree:
|
|
2968
|
+
DOM structure, computed styles per node, a11y roles, and React fibers.
|
|
2969
|
+
|
|
2970
|
+
OUTPUT STRUCTURE per node:
|
|
2971
|
+
tag / id / className DOM identity
|
|
2972
|
+
computedStyles resolved CSS properties
|
|
2973
|
+
a11y role, name, focusable
|
|
2974
|
+
children nested child nodes
|
|
2975
|
+
|
|
2976
|
+
Examples:
|
|
2977
|
+
scope instrument tree Card
|
|
2978
|
+
scope instrument tree Button --props '{"variant":"primary"}' --json
|
|
2979
|
+
scope instrument tree Input --json | jq '.tree.computedStyles'`
|
|
2980
|
+
).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(
|
|
2678
2981
|
"--wasted-renders",
|
|
2679
2982
|
"Filter to components with wasted renders (no prop/state/context changes, not memoized)",
|
|
2680
2983
|
false
|
|
@@ -2698,7 +3001,7 @@ Available: ${available}`
|
|
|
2698
3001
|
}
|
|
2699
3002
|
}
|
|
2700
3003
|
const rootDir = process.cwd();
|
|
2701
|
-
const filePath =
|
|
3004
|
+
const filePath = resolve7(rootDir, descriptor.filePath);
|
|
2702
3005
|
process.stderr.write(`Instrumenting ${componentName}\u2026
|
|
2703
3006
|
`);
|
|
2704
3007
|
const instrumentRoot = await runInstrumentTree({
|
|
@@ -3069,8 +3372,9 @@ Available: ${available}`
|
|
|
3069
3372
|
);
|
|
3070
3373
|
}
|
|
3071
3374
|
const rootDir = process.cwd();
|
|
3072
|
-
const filePath =
|
|
3073
|
-
const preScript = getBrowserEntryScript5()
|
|
3375
|
+
const filePath = resolve8(rootDir, descriptor.filePath);
|
|
3376
|
+
const preScript = `${getBrowserEntryScript5()}
|
|
3377
|
+
${buildInstrumentationScript()}`;
|
|
3074
3378
|
const htmlHarness = await buildComponentHarness(
|
|
3075
3379
|
filePath,
|
|
3076
3380
|
options.componentName,
|
|
@@ -3159,7 +3463,24 @@ function formatRendersTable(result) {
|
|
|
3159
3463
|
return lines.join("\n");
|
|
3160
3464
|
}
|
|
3161
3465
|
function createInstrumentRendersCommand() {
|
|
3162
|
-
return new
|
|
3466
|
+
return new Command7("renders").description(
|
|
3467
|
+
`Trace every re-render triggered by an interaction and identify root causes.
|
|
3468
|
+
|
|
3469
|
+
OUTPUT INCLUDES per render event:
|
|
3470
|
+
component which component re-rendered
|
|
3471
|
+
trigger why it re-rendered: state_change | props_change | context_change |
|
|
3472
|
+
parent_rerender | force_update | hook_dependency
|
|
3473
|
+
wasted true if re-rendered with no changed inputs and not memoized
|
|
3474
|
+
chain full causality chain from root cause to this render
|
|
3475
|
+
|
|
3476
|
+
WASTED RENDERS: propsChanged=false AND stateChanged=false AND contextChanged=false
|
|
3477
|
+
AND memoized=false \u2014 these are optimisation opportunities.
|
|
3478
|
+
|
|
3479
|
+
Examples:
|
|
3480
|
+
scope instrument renders SearchPage --interaction '[{"action":"type","target":"input","text":"hello"}]'
|
|
3481
|
+
scope instrument renders Button --interaction '[{"action":"click","target":"button"}]' --json
|
|
3482
|
+
scope instrument renders Form --json | jq '.events[] | select(.wasted == true)'`
|
|
3483
|
+
).argument("<component>", "Component name to instrument (must be in manifest)").option(
|
|
3163
3484
|
"--interaction <json>",
|
|
3164
3485
|
`Interaction sequence JSON, e.g. '[{"action":"click","target":"button"}]'`,
|
|
3165
3486
|
"[]"
|
|
@@ -3204,8 +3525,29 @@ function createInstrumentRendersCommand() {
|
|
|
3204
3525
|
);
|
|
3205
3526
|
}
|
|
3206
3527
|
function createInstrumentCommand() {
|
|
3207
|
-
const instrumentCmd = new
|
|
3208
|
-
|
|
3528
|
+
const instrumentCmd = new Command7("instrument").description(
|
|
3529
|
+
`Runtime instrumentation for React component behaviour analysis.
|
|
3530
|
+
|
|
3531
|
+
All instrument commands:
|
|
3532
|
+
1. Build an esbuild harness for the component
|
|
3533
|
+
2. Load it in a Playwright browser
|
|
3534
|
+
3. Inject instrumentation hooks into React DevTools fiber
|
|
3535
|
+
4. Execute interactions and collect events
|
|
3536
|
+
|
|
3537
|
+
PREREQUISITES:
|
|
3538
|
+
scope manifest generate (component must be in manifest)
|
|
3539
|
+
reactscope.config.json (for wrappers/globalCSS)
|
|
3540
|
+
|
|
3541
|
+
INTERACTION FORMAT:
|
|
3542
|
+
JSON array of step objects: [{action, target, text?}]
|
|
3543
|
+
Actions: click | type | focus | blur | hover | key
|
|
3544
|
+
Target: CSS selector for the element to interact with
|
|
3545
|
+
|
|
3546
|
+
Examples:
|
|
3547
|
+
scope instrument renders Button --interaction '[{"action":"click","target":"button"}]'
|
|
3548
|
+
scope instrument hooks SearchInput --props '{"value":"hello"}'
|
|
3549
|
+
scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]'
|
|
3550
|
+
scope instrument tree Card`
|
|
3209
3551
|
);
|
|
3210
3552
|
instrumentCmd.addCommand(createInstrumentRendersCommand());
|
|
3211
3553
|
instrumentCmd.addCommand(createInstrumentHooksCommand());
|
|
@@ -3215,8 +3557,8 @@ function createInstrumentCommand() {
|
|
|
3215
3557
|
}
|
|
3216
3558
|
|
|
3217
3559
|
// src/render-commands.ts
|
|
3218
|
-
import { existsSync as
|
|
3219
|
-
import { resolve as
|
|
3560
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
3561
|
+
import { resolve as resolve10 } from "path";
|
|
3220
3562
|
import {
|
|
3221
3563
|
ALL_CONTEXT_IDS,
|
|
3222
3564
|
ALL_STRESS_IDS,
|
|
@@ -3227,13 +3569,13 @@ import {
|
|
|
3227
3569
|
safeRender as safeRender2,
|
|
3228
3570
|
stressAxis
|
|
3229
3571
|
} from "@agent-scope/render";
|
|
3230
|
-
import { Command as
|
|
3572
|
+
import { Command as Command8 } from "commander";
|
|
3231
3573
|
|
|
3232
3574
|
// src/scope-file.ts
|
|
3233
|
-
import { existsSync as
|
|
3575
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, rmSync } from "fs";
|
|
3234
3576
|
import { createRequire as createRequire2 } from "module";
|
|
3235
3577
|
import { tmpdir } from "os";
|
|
3236
|
-
import { dirname as dirname2, join as
|
|
3578
|
+
import { dirname as dirname2, join as join4, resolve as resolve9 } from "path";
|
|
3237
3579
|
import * as esbuild2 from "esbuild";
|
|
3238
3580
|
var SCOPE_EXTENSIONS = [".scope.tsx", ".scope.ts", ".scope.jsx", ".scope.js"];
|
|
3239
3581
|
function findScopeFile(componentFilePath) {
|
|
@@ -3241,15 +3583,15 @@ function findScopeFile(componentFilePath) {
|
|
|
3241
3583
|
const stem = componentFilePath.replace(/\.(tsx?|jsx?)$/, "");
|
|
3242
3584
|
const baseName = stem.slice(dir.length + 1);
|
|
3243
3585
|
for (const ext of SCOPE_EXTENSIONS) {
|
|
3244
|
-
const candidate =
|
|
3245
|
-
if (
|
|
3586
|
+
const candidate = join4(dir, `${baseName}${ext}`);
|
|
3587
|
+
if (existsSync7(candidate)) return candidate;
|
|
3246
3588
|
}
|
|
3247
3589
|
return null;
|
|
3248
3590
|
}
|
|
3249
3591
|
async function loadScopeFile(scopeFilePath) {
|
|
3250
|
-
const tmpDir =
|
|
3592
|
+
const tmpDir = join4(tmpdir(), `scope-file-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
3251
3593
|
mkdirSync3(tmpDir, { recursive: true });
|
|
3252
|
-
const outFile =
|
|
3594
|
+
const outFile = join4(tmpDir, "scope-file.cjs");
|
|
3253
3595
|
try {
|
|
3254
3596
|
const result = await esbuild2.build({
|
|
3255
3597
|
entryPoints: [scopeFilePath],
|
|
@@ -3274,7 +3616,7 @@ async function loadScopeFile(scopeFilePath) {
|
|
|
3274
3616
|
${msg}`);
|
|
3275
3617
|
}
|
|
3276
3618
|
const req = createRequire2(import.meta.url);
|
|
3277
|
-
delete req.cache[
|
|
3619
|
+
delete req.cache[resolve9(outFile)];
|
|
3278
3620
|
const mod = req(outFile);
|
|
3279
3621
|
const scenarios = extractScenarios(mod, scopeFilePath);
|
|
3280
3622
|
const hasWrapper = typeof mod.wrapper === "function" || typeof mod.default?.wrapper === "function";
|
|
@@ -3352,10 +3694,10 @@ ${msg}`);
|
|
|
3352
3694
|
|
|
3353
3695
|
// src/render-commands.ts
|
|
3354
3696
|
function loadGlobalCssFilesFromConfig(cwd) {
|
|
3355
|
-
const configPath =
|
|
3356
|
-
if (!
|
|
3697
|
+
const configPath = resolve10(cwd, "reactscope.config.json");
|
|
3698
|
+
if (!existsSync8(configPath)) return [];
|
|
3357
3699
|
try {
|
|
3358
|
-
const raw =
|
|
3700
|
+
const raw = readFileSync7(configPath, "utf-8");
|
|
3359
3701
|
const cfg = JSON.parse(raw);
|
|
3360
3702
|
return cfg.components?.wrappers?.globalCSS ?? [];
|
|
3361
3703
|
} catch {
|
|
@@ -3426,7 +3768,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3426
3768
|
}
|
|
3427
3769
|
});
|
|
3428
3770
|
return [...set];
|
|
3429
|
-
});
|
|
3771
|
+
}) ?? [];
|
|
3430
3772
|
const projectCss2 = await getCompiledCssForClasses(rootDir, classes);
|
|
3431
3773
|
if (projectCss2 != null && projectCss2.length > 0) {
|
|
3432
3774
|
await page.addStyleTag({ content: projectCss2 });
|
|
@@ -3439,49 +3781,147 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3439
3781
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
3440
3782
|
);
|
|
3441
3783
|
}
|
|
3442
|
-
const PAD =
|
|
3443
|
-
const MIN_W = 320;
|
|
3444
|
-
const MIN_H = 200;
|
|
3784
|
+
const PAD = 8;
|
|
3445
3785
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
3446
3786
|
const clipY = Math.max(0, boundingBox.y - PAD);
|
|
3447
3787
|
const rawW = boundingBox.width + PAD * 2;
|
|
3448
3788
|
const rawH = boundingBox.height + PAD * 2;
|
|
3449
|
-
const
|
|
3450
|
-
const
|
|
3451
|
-
const safeW = Math.min(clipW, viewportWidth - clipX);
|
|
3452
|
-
const safeH = Math.min(clipH, viewportHeight - clipY);
|
|
3789
|
+
const safeW = Math.min(rawW, viewportWidth - clipX);
|
|
3790
|
+
const safeH = Math.min(rawH, viewportHeight - clipY);
|
|
3453
3791
|
const screenshot = await page.screenshot({
|
|
3454
3792
|
clip: { x: clipX, y: clipY, width: safeW, height: safeH },
|
|
3455
3793
|
type: "png"
|
|
3456
3794
|
});
|
|
3795
|
+
const STYLE_PROPS = [
|
|
3796
|
+
"display",
|
|
3797
|
+
"width",
|
|
3798
|
+
"height",
|
|
3799
|
+
"color",
|
|
3800
|
+
"backgroundColor",
|
|
3801
|
+
"fontSize",
|
|
3802
|
+
"fontFamily",
|
|
3803
|
+
"fontWeight",
|
|
3804
|
+
"lineHeight",
|
|
3805
|
+
"padding",
|
|
3806
|
+
"paddingTop",
|
|
3807
|
+
"paddingRight",
|
|
3808
|
+
"paddingBottom",
|
|
3809
|
+
"paddingLeft",
|
|
3810
|
+
"margin",
|
|
3811
|
+
"marginTop",
|
|
3812
|
+
"marginRight",
|
|
3813
|
+
"marginBottom",
|
|
3814
|
+
"marginLeft",
|
|
3815
|
+
"gap",
|
|
3816
|
+
"borderRadius",
|
|
3817
|
+
"borderWidth",
|
|
3818
|
+
"borderColor",
|
|
3819
|
+
"borderStyle",
|
|
3820
|
+
"boxShadow",
|
|
3821
|
+
"opacity",
|
|
3822
|
+
"position",
|
|
3823
|
+
"flexDirection",
|
|
3824
|
+
"alignItems",
|
|
3825
|
+
"justifyContent",
|
|
3826
|
+
"overflow"
|
|
3827
|
+
];
|
|
3828
|
+
const _domResult = await page.evaluate(
|
|
3829
|
+
(args) => {
|
|
3830
|
+
let count = 0;
|
|
3831
|
+
const styles = {};
|
|
3832
|
+
function captureStyles(el, id, propList) {
|
|
3833
|
+
const computed = window.getComputedStyle(el);
|
|
3834
|
+
const out = {};
|
|
3835
|
+
for (const prop of propList) {
|
|
3836
|
+
const val = computed[prop] ?? "";
|
|
3837
|
+
if (val && val !== "none" && val !== "normal" && val !== "auto") out[prop] = val;
|
|
3838
|
+
}
|
|
3839
|
+
styles[id] = out;
|
|
3840
|
+
}
|
|
3841
|
+
function walk(node) {
|
|
3842
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
3843
|
+
return {
|
|
3844
|
+
tag: "#text",
|
|
3845
|
+
attrs: {},
|
|
3846
|
+
text: node.textContent?.trim() ?? "",
|
|
3847
|
+
children: []
|
|
3848
|
+
};
|
|
3849
|
+
}
|
|
3850
|
+
const el = node;
|
|
3851
|
+
const id = count++;
|
|
3852
|
+
captureStyles(el, id, args.props);
|
|
3853
|
+
const attrs = {};
|
|
3854
|
+
for (const attr of Array.from(el.attributes)) {
|
|
3855
|
+
attrs[attr.name] = attr.value;
|
|
3856
|
+
}
|
|
3857
|
+
const children = Array.from(el.childNodes).filter(
|
|
3858
|
+
(n) => n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE && (n.textContent?.trim() ?? "").length > 0
|
|
3859
|
+
).map(walk);
|
|
3860
|
+
return { tag: el.tagName.toLowerCase(), attrs, nodeId: id, children };
|
|
3861
|
+
}
|
|
3862
|
+
const root = document.querySelector(args.sel);
|
|
3863
|
+
if (!root)
|
|
3864
|
+
return {
|
|
3865
|
+
tree: { tag: "div", attrs: {}, children: [] },
|
|
3866
|
+
elementCount: 0,
|
|
3867
|
+
nodeStyles: {}
|
|
3868
|
+
};
|
|
3869
|
+
return { tree: walk(root), elementCount: count, nodeStyles: styles };
|
|
3870
|
+
},
|
|
3871
|
+
{ sel: "[data-reactscope-root] > *", props: STYLE_PROPS }
|
|
3872
|
+
);
|
|
3873
|
+
const domTree = _domResult?.tree ?? { tag: "div", attrs: {}, children: [] };
|
|
3874
|
+
const elementCount = _domResult?.elementCount ?? 0;
|
|
3875
|
+
const nodeStyles = _domResult?.nodeStyles ?? {};
|
|
3457
3876
|
const computedStyles = {};
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
"fontFamily",
|
|
3471
|
-
"padding",
|
|
3472
|
-
"margin"
|
|
3473
|
-
]) {
|
|
3474
|
-
out[prop] = computed.getPropertyValue(prop);
|
|
3877
|
+
if (nodeStyles[0]) computedStyles["[data-reactscope-root] > *"] = nodeStyles[0];
|
|
3878
|
+
for (const [nodeId, styles] of Object.entries(nodeStyles)) {
|
|
3879
|
+
computedStyles[`#node-${nodeId}`] = styles;
|
|
3880
|
+
}
|
|
3881
|
+
const dom = {
|
|
3882
|
+
tree: domTree,
|
|
3883
|
+
elementCount,
|
|
3884
|
+
boundingBox: {
|
|
3885
|
+
x: boundingBox.x,
|
|
3886
|
+
y: boundingBox.y,
|
|
3887
|
+
width: boundingBox.width,
|
|
3888
|
+
height: boundingBox.height
|
|
3475
3889
|
}
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3890
|
+
};
|
|
3891
|
+
const a11yInfo = await page.evaluate((sel) => {
|
|
3892
|
+
const wrapper = document.querySelector(sel);
|
|
3893
|
+
const el = wrapper?.firstElementChild ?? wrapper;
|
|
3894
|
+
if (!el) return { role: "generic", name: "" };
|
|
3895
|
+
return {
|
|
3896
|
+
role: el.getAttribute("role") ?? el.tagName.toLowerCase() ?? "generic",
|
|
3897
|
+
name: el.getAttribute("aria-label") ?? el.getAttribute("aria-labelledby") ?? el.textContent?.trim().slice(0, 100) ?? ""
|
|
3898
|
+
};
|
|
3899
|
+
}, "[data-reactscope-root]") ?? {
|
|
3900
|
+
role: "generic",
|
|
3901
|
+
name: ""
|
|
3902
|
+
};
|
|
3903
|
+
const imgViolations = await page.evaluate((sel) => {
|
|
3904
|
+
const container = document.querySelector(sel);
|
|
3905
|
+
if (!container) return [];
|
|
3906
|
+
const issues = [];
|
|
3907
|
+
container.querySelectorAll("img").forEach((img) => {
|
|
3908
|
+
if (!img.alt) issues.push("Image missing accessible name");
|
|
3909
|
+
});
|
|
3910
|
+
return issues;
|
|
3911
|
+
}, "[data-reactscope-root]") ?? [];
|
|
3912
|
+
const accessibility = {
|
|
3913
|
+
role: a11yInfo.role,
|
|
3914
|
+
name: a11yInfo.name,
|
|
3915
|
+
violations: imgViolations
|
|
3916
|
+
};
|
|
3479
3917
|
return {
|
|
3480
3918
|
screenshot,
|
|
3481
3919
|
width: Math.round(safeW),
|
|
3482
3920
|
height: Math.round(safeH),
|
|
3483
3921
|
renderTimeMs,
|
|
3484
|
-
computedStyles
|
|
3922
|
+
computedStyles,
|
|
3923
|
+
dom,
|
|
3924
|
+
accessibility
|
|
3485
3925
|
};
|
|
3486
3926
|
} finally {
|
|
3487
3927
|
pool.release(slot);
|
|
@@ -3519,7 +3959,27 @@ Available: ${available}`
|
|
|
3519
3959
|
return { __default__: {} };
|
|
3520
3960
|
}
|
|
3521
3961
|
function registerRenderSingle(renderCmd) {
|
|
3522
|
-
renderCmd.command("component <component>", { isDefault: true }).description(
|
|
3962
|
+
renderCmd.command("component <component>", { isDefault: true }).description(
|
|
3963
|
+
`Render one component to a PNG screenshot or JSON data object.
|
|
3964
|
+
|
|
3965
|
+
PROP SOURCES (in priority order):
|
|
3966
|
+
--scenario <name> named scenario from <ComponentName>.scope file
|
|
3967
|
+
--props <json> inline props JSON string
|
|
3968
|
+
(no flag) component rendered with all-default props
|
|
3969
|
+
|
|
3970
|
+
FORMAT DETECTION:
|
|
3971
|
+
--format png always write PNG
|
|
3972
|
+
--format json always write JSON render data
|
|
3973
|
+
auto (default) PNG when -o has .png extension or stdout is file;
|
|
3974
|
+
JSON when stdout is a pipe
|
|
3975
|
+
|
|
3976
|
+
Examples:
|
|
3977
|
+
scope render component Button
|
|
3978
|
+
scope render component Button --props '{"variant":"primary","size":"lg"}'
|
|
3979
|
+
scope render component Button --scenario hover-state -o button-hover.png
|
|
3980
|
+
scope render component Card --viewport 375x812 --theme dark
|
|
3981
|
+
scope render component Badge --format json | jq '.a11y'`
|
|
3982
|
+
).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(
|
|
3523
3983
|
async (componentName, opts) => {
|
|
3524
3984
|
try {
|
|
3525
3985
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -3557,11 +4017,16 @@ Available: ${available}`
|
|
|
3557
4017
|
}
|
|
3558
4018
|
const { width, height } = parseViewport(opts.viewport);
|
|
3559
4019
|
const rootDir = process.cwd();
|
|
3560
|
-
const filePath =
|
|
4020
|
+
const filePath = resolve10(rootDir, descriptor.filePath);
|
|
3561
4021
|
const scopeData = await loadScopeFileForComponent(filePath);
|
|
3562
4022
|
const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
3563
4023
|
const scenarios = buildScenarioMap(opts, scopeData);
|
|
3564
4024
|
const globalCssFiles = loadGlobalCssFilesFromConfig(rootDir);
|
|
4025
|
+
if (globalCssFiles.length === 0) {
|
|
4026
|
+
process.stderr.write(
|
|
4027
|
+
"warning: No globalCSS files configured. Tailwind/CSS styles will not be applied to renders.\n Add `components.wrappers.globalCSS` to reactscope.config.json\n"
|
|
4028
|
+
);
|
|
4029
|
+
}
|
|
3565
4030
|
const renderer = buildRenderer(
|
|
3566
4031
|
filePath,
|
|
3567
4032
|
componentName,
|
|
@@ -3605,7 +4070,7 @@ Available: ${available}`
|
|
|
3605
4070
|
const result = outcome.result;
|
|
3606
4071
|
const outFileName = isNamed ? `${componentName}-${scenarioName}.png` : `${componentName}.png`;
|
|
3607
4072
|
if (opts.output !== void 0 && !isNamed) {
|
|
3608
|
-
const outPath =
|
|
4073
|
+
const outPath = resolve10(process.cwd(), opts.output);
|
|
3609
4074
|
writeFileSync5(outPath, result.screenshot);
|
|
3610
4075
|
process.stdout.write(
|
|
3611
4076
|
`\u2713 ${label} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
@@ -3616,9 +4081,9 @@ Available: ${available}`
|
|
|
3616
4081
|
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
3617
4082
|
`);
|
|
3618
4083
|
} else {
|
|
3619
|
-
const dir =
|
|
4084
|
+
const dir = resolve10(process.cwd(), DEFAULT_OUTPUT_DIR);
|
|
3620
4085
|
mkdirSync4(dir, { recursive: true });
|
|
3621
|
-
const outPath =
|
|
4086
|
+
const outPath = resolve10(dir, outFileName);
|
|
3622
4087
|
writeFileSync5(outPath, result.screenshot);
|
|
3623
4088
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${outFileName}`;
|
|
3624
4089
|
process.stdout.write(
|
|
@@ -3639,7 +4104,9 @@ Available: ${available}`
|
|
|
3639
4104
|
);
|
|
3640
4105
|
}
|
|
3641
4106
|
function registerRenderMatrix(renderCmd) {
|
|
3642
|
-
renderCmd.command("matrix <component>").description(
|
|
4107
|
+
renderCmd.command("matrix <component>").description(
|
|
4108
|
+
'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'
|
|
4109
|
+
).option(
|
|
3643
4110
|
"--axes <spec>",
|
|
3644
4111
|
`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"]}'`
|
|
3645
4112
|
).option(
|
|
@@ -3660,7 +4127,7 @@ Available: ${available}`
|
|
|
3660
4127
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 8);
|
|
3661
4128
|
const { width, height } = { width: 375, height: 812 };
|
|
3662
4129
|
const rootDir = process.cwd();
|
|
3663
|
-
const filePath =
|
|
4130
|
+
const filePath = resolve10(rootDir, descriptor.filePath);
|
|
3664
4131
|
const matrixCssFiles = loadGlobalCssFilesFromConfig(rootDir);
|
|
3665
4132
|
const renderer = buildRenderer(
|
|
3666
4133
|
filePath,
|
|
@@ -3753,7 +4220,7 @@ Available: ${available}`
|
|
|
3753
4220
|
const { SpriteSheetGenerator: SpriteSheetGenerator2 } = await import("@agent-scope/render");
|
|
3754
4221
|
const gen = new SpriteSheetGenerator2();
|
|
3755
4222
|
const sheet = await gen.generate(result);
|
|
3756
|
-
const spritePath =
|
|
4223
|
+
const spritePath = resolve10(process.cwd(), opts.sprite);
|
|
3757
4224
|
writeFileSync5(spritePath, sheet.png);
|
|
3758
4225
|
process.stderr.write(`Sprite sheet saved to ${spritePath}
|
|
3759
4226
|
`);
|
|
@@ -3763,9 +4230,9 @@ Available: ${available}`
|
|
|
3763
4230
|
const { SpriteSheetGenerator: SpriteSheetGenerator2 } = await import("@agent-scope/render");
|
|
3764
4231
|
const gen = new SpriteSheetGenerator2();
|
|
3765
4232
|
const sheet = await gen.generate(result);
|
|
3766
|
-
const dir =
|
|
4233
|
+
const dir = resolve10(process.cwd(), DEFAULT_OUTPUT_DIR);
|
|
3767
4234
|
mkdirSync4(dir, { recursive: true });
|
|
3768
|
-
const outPath =
|
|
4235
|
+
const outPath = resolve10(dir, `${componentName}-matrix.png`);
|
|
3769
4236
|
writeFileSync5(outPath, sheet.png);
|
|
3770
4237
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${componentName}-matrix.png`;
|
|
3771
4238
|
process.stdout.write(
|
|
@@ -3798,7 +4265,9 @@ Available: ${available}`
|
|
|
3798
4265
|
);
|
|
3799
4266
|
}
|
|
3800
4267
|
function registerRenderAll(renderCmd) {
|
|
3801
|
-
renderCmd.command("all").description(
|
|
4268
|
+
renderCmd.command("all").description(
|
|
4269
|
+
"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"
|
|
4270
|
+
).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(
|
|
3802
4271
|
async (opts) => {
|
|
3803
4272
|
try {
|
|
3804
4273
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -3809,23 +4278,37 @@ function registerRenderAll(renderCmd) {
|
|
|
3809
4278
|
return;
|
|
3810
4279
|
}
|
|
3811
4280
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 4);
|
|
3812
|
-
const outputDir =
|
|
4281
|
+
const outputDir = resolve10(process.cwd(), opts.outputDir);
|
|
3813
4282
|
mkdirSync4(outputDir, { recursive: true });
|
|
3814
4283
|
const rootDir = process.cwd();
|
|
3815
4284
|
process.stderr.write(`Rendering ${total} components (concurrency: ${concurrency})\u2026
|
|
3816
4285
|
`);
|
|
3817
4286
|
const results = [];
|
|
4287
|
+
const complianceStylesMap = {};
|
|
3818
4288
|
let completed = 0;
|
|
3819
4289
|
const renderOne = async (name) => {
|
|
3820
4290
|
const descriptor = manifest.components[name];
|
|
3821
4291
|
if (descriptor === void 0) return;
|
|
3822
|
-
const filePath =
|
|
4292
|
+
const filePath = resolve10(rootDir, descriptor.filePath);
|
|
3823
4293
|
const allCssFiles = loadGlobalCssFilesFromConfig(process.cwd());
|
|
3824
|
-
const
|
|
4294
|
+
const scopeData = await loadScopeFileForComponent(filePath);
|
|
4295
|
+
const scenarioEntries = scopeData !== null ? Object.entries(scopeData.scenarios) : [];
|
|
4296
|
+
const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
|
|
4297
|
+
const renderProps = defaultEntry !== void 0 ? defaultEntry[1] : {};
|
|
4298
|
+
const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
4299
|
+
const renderer = buildRenderer(
|
|
4300
|
+
filePath,
|
|
4301
|
+
name,
|
|
4302
|
+
375,
|
|
4303
|
+
812,
|
|
4304
|
+
allCssFiles,
|
|
4305
|
+
process.cwd(),
|
|
4306
|
+
wrapperScript
|
|
4307
|
+
);
|
|
3825
4308
|
const outcome = await safeRender2(
|
|
3826
|
-
() => renderer.renderCell(
|
|
4309
|
+
() => renderer.renderCell(renderProps, descriptor.complexityClass),
|
|
3827
4310
|
{
|
|
3828
|
-
props:
|
|
4311
|
+
props: renderProps,
|
|
3829
4312
|
sourceLocation: {
|
|
3830
4313
|
file: descriptor.filePath,
|
|
3831
4314
|
line: descriptor.loc.start,
|
|
@@ -3843,7 +4326,7 @@ function registerRenderAll(renderCmd) {
|
|
|
3843
4326
|
success: false,
|
|
3844
4327
|
errorMessage: outcome.error.message
|
|
3845
4328
|
});
|
|
3846
|
-
const errPath =
|
|
4329
|
+
const errPath = resolve10(outputDir, `${name}.error.json`);
|
|
3847
4330
|
writeFileSync5(
|
|
3848
4331
|
errPath,
|
|
3849
4332
|
JSON.stringify(
|
|
@@ -3861,10 +4344,81 @@ function registerRenderAll(renderCmd) {
|
|
|
3861
4344
|
}
|
|
3862
4345
|
const result = outcome.result;
|
|
3863
4346
|
results.push({ name, renderTimeMs: result.renderTimeMs, success: true });
|
|
3864
|
-
const pngPath =
|
|
4347
|
+
const pngPath = resolve10(outputDir, `${name}.png`);
|
|
3865
4348
|
writeFileSync5(pngPath, result.screenshot);
|
|
3866
|
-
const jsonPath =
|
|
4349
|
+
const jsonPath = resolve10(outputDir, `${name}.json`);
|
|
3867
4350
|
writeFileSync5(jsonPath, JSON.stringify(formatRenderJson(name, {}, result), null, 2));
|
|
4351
|
+
const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
|
|
4352
|
+
const compStyles = {
|
|
4353
|
+
colors: {},
|
|
4354
|
+
spacing: {},
|
|
4355
|
+
typography: {},
|
|
4356
|
+
borders: {},
|
|
4357
|
+
shadows: {}
|
|
4358
|
+
};
|
|
4359
|
+
for (const [prop, val] of Object.entries(rawStyles)) {
|
|
4360
|
+
if (!val || val === "none" || val === "") continue;
|
|
4361
|
+
const lower = prop.toLowerCase();
|
|
4362
|
+
if (lower.includes("color") || lower.includes("background")) {
|
|
4363
|
+
compStyles.colors[prop] = val;
|
|
4364
|
+
} else if (lower.includes("padding") || lower.includes("margin") || lower.includes("gap") || lower.includes("width") || lower.includes("height")) {
|
|
4365
|
+
compStyles.spacing[prop] = val;
|
|
4366
|
+
} else if (lower.includes("font") || lower.includes("lineheight") || lower.includes("letterspacing") || lower.includes("texttransform")) {
|
|
4367
|
+
compStyles.typography[prop] = val;
|
|
4368
|
+
} else if (lower.includes("border") || lower.includes("radius") || lower.includes("outline")) {
|
|
4369
|
+
compStyles.borders[prop] = val;
|
|
4370
|
+
} else if (lower.includes("shadow")) {
|
|
4371
|
+
compStyles.shadows[prop] = val;
|
|
4372
|
+
}
|
|
4373
|
+
}
|
|
4374
|
+
complianceStylesMap[name] = compStyles;
|
|
4375
|
+
if (scopeData !== null && Object.keys(scopeData.scenarios).length >= 2) {
|
|
4376
|
+
try {
|
|
4377
|
+
const scenarioEntries2 = Object.entries(scopeData.scenarios);
|
|
4378
|
+
const scenarioAxis = {
|
|
4379
|
+
name: "scenario",
|
|
4380
|
+
values: scenarioEntries2.map(([k]) => k)
|
|
4381
|
+
};
|
|
4382
|
+
const scenarioPropsMap = Object.fromEntries(scenarioEntries2);
|
|
4383
|
+
const matrixRenderer = buildRenderer(
|
|
4384
|
+
filePath,
|
|
4385
|
+
name,
|
|
4386
|
+
375,
|
|
4387
|
+
812,
|
|
4388
|
+
allCssFiles,
|
|
4389
|
+
process.cwd(),
|
|
4390
|
+
wrapperScript
|
|
4391
|
+
);
|
|
4392
|
+
const wrappedRenderer = {
|
|
4393
|
+
_satori: matrixRenderer._satori,
|
|
4394
|
+
async renderCell(props, cc) {
|
|
4395
|
+
const scenarioName = props.scenario;
|
|
4396
|
+
const realProps = scenarioName !== void 0 ? scenarioPropsMap[scenarioName] ?? props : props;
|
|
4397
|
+
return matrixRenderer.renderCell(realProps, cc ?? "simple");
|
|
4398
|
+
}
|
|
4399
|
+
};
|
|
4400
|
+
const matrix = new RenderMatrix(wrappedRenderer, [scenarioAxis], {
|
|
4401
|
+
concurrency: 2
|
|
4402
|
+
});
|
|
4403
|
+
const matrixResult = await matrix.render();
|
|
4404
|
+
const matrixCells = matrixResult.cells.map((cell) => ({
|
|
4405
|
+
axisValues: [scenarioEntries2[cell.axisIndices[0] ?? 0]?.[0] ?? ""],
|
|
4406
|
+
screenshot: cell.result.screenshot.toString("base64"),
|
|
4407
|
+
width: cell.result.width,
|
|
4408
|
+
height: cell.result.height,
|
|
4409
|
+
renderTimeMs: cell.result.renderTimeMs
|
|
4410
|
+
}));
|
|
4411
|
+
const existingJson = JSON.parse(readFileSync7(jsonPath, "utf-8"));
|
|
4412
|
+
existingJson.cells = matrixCells;
|
|
4413
|
+
existingJson.axisLabels = [scenarioAxis.values];
|
|
4414
|
+
writeFileSync5(jsonPath, JSON.stringify(existingJson, null, 2));
|
|
4415
|
+
} catch (matrixErr) {
|
|
4416
|
+
process.stderr.write(
|
|
4417
|
+
` [warn] Matrix render for ${name} failed: ${matrixErr instanceof Error ? matrixErr.message : String(matrixErr)}
|
|
4418
|
+
`
|
|
4419
|
+
);
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
3868
4422
|
if (isTTY()) {
|
|
3869
4423
|
process.stdout.write(
|
|
3870
4424
|
`\u2713 ${name} \u2192 ${opts.outputDir}/${name}.png (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
@@ -3888,6 +4442,14 @@ function registerRenderAll(renderCmd) {
|
|
|
3888
4442
|
}
|
|
3889
4443
|
await Promise.all(workers);
|
|
3890
4444
|
await shutdownPool3();
|
|
4445
|
+
const compStylesPath = resolve10(
|
|
4446
|
+
resolve10(process.cwd(), opts.outputDir),
|
|
4447
|
+
"..",
|
|
4448
|
+
"compliance-styles.json"
|
|
4449
|
+
);
|
|
4450
|
+
writeFileSync5(compStylesPath, JSON.stringify(complianceStylesMap, null, 2));
|
|
4451
|
+
process.stderr.write(`[scope/render] \u2713 Wrote compliance-styles.json
|
|
4452
|
+
`);
|
|
3891
4453
|
process.stderr.write("\n");
|
|
3892
4454
|
const summary = formatSummaryText(results, outputDir);
|
|
3893
4455
|
process.stderr.write(`${summary}
|
|
@@ -3926,8 +4488,8 @@ function resolveMatrixFormat(formatFlag, spriteAlreadyWritten) {
|
|
|
3926
4488
|
return "json";
|
|
3927
4489
|
}
|
|
3928
4490
|
function createRenderCommand() {
|
|
3929
|
-
const renderCmd = new
|
|
3930
|
-
|
|
4491
|
+
const renderCmd = new Command8("render").description(
|
|
4492
|
+
'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'
|
|
3931
4493
|
);
|
|
3932
4494
|
registerRenderSingle(renderCmd);
|
|
3933
4495
|
registerRenderMatrix(renderCmd);
|
|
@@ -3936,9 +4498,9 @@ function createRenderCommand() {
|
|
|
3936
4498
|
}
|
|
3937
4499
|
|
|
3938
4500
|
// src/report/baseline.ts
|
|
3939
|
-
import { existsSync as
|
|
3940
|
-
import { resolve as
|
|
3941
|
-
import { generateManifest as
|
|
4501
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync5, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
4502
|
+
import { resolve as resolve11 } from "path";
|
|
4503
|
+
import { generateManifest as generateManifest4 } from "@agent-scope/manifest";
|
|
3942
4504
|
import { BrowserPool as BrowserPool4, safeRender as safeRender3 } from "@agent-scope/render";
|
|
3943
4505
|
import { ComplianceEngine as ComplianceEngine2, TokenResolver as TokenResolver2 } from "@agent-scope/tokens";
|
|
3944
4506
|
var DEFAULT_BASELINE_DIR = ".reactscope/baseline";
|
|
@@ -4086,30 +4648,30 @@ async function runBaseline(options = {}) {
|
|
|
4086
4648
|
} = options;
|
|
4087
4649
|
const startTime = performance.now();
|
|
4088
4650
|
const rootDir = process.cwd();
|
|
4089
|
-
const baselineDir =
|
|
4090
|
-
const rendersDir =
|
|
4091
|
-
if (
|
|
4651
|
+
const baselineDir = resolve11(rootDir, outputDir);
|
|
4652
|
+
const rendersDir = resolve11(baselineDir, "renders");
|
|
4653
|
+
if (existsSync9(baselineDir)) {
|
|
4092
4654
|
rmSync2(baselineDir, { recursive: true, force: true });
|
|
4093
4655
|
}
|
|
4094
4656
|
mkdirSync5(rendersDir, { recursive: true });
|
|
4095
4657
|
let manifest;
|
|
4096
4658
|
if (manifestPath !== void 0) {
|
|
4097
|
-
const { readFileSync:
|
|
4098
|
-
const absPath =
|
|
4099
|
-
if (!
|
|
4659
|
+
const { readFileSync: readFileSync14 } = await import("fs");
|
|
4660
|
+
const absPath = resolve11(rootDir, manifestPath);
|
|
4661
|
+
if (!existsSync9(absPath)) {
|
|
4100
4662
|
throw new Error(`Manifest not found at ${absPath}.`);
|
|
4101
4663
|
}
|
|
4102
|
-
manifest = JSON.parse(
|
|
4664
|
+
manifest = JSON.parse(readFileSync14(absPath, "utf-8"));
|
|
4103
4665
|
process.stderr.write(`Loaded manifest from ${manifestPath}
|
|
4104
4666
|
`);
|
|
4105
4667
|
} else {
|
|
4106
4668
|
process.stderr.write("Scanning for React components\u2026\n");
|
|
4107
|
-
manifest = await
|
|
4669
|
+
manifest = await generateManifest4({ rootDir });
|
|
4108
4670
|
const count = Object.keys(manifest.components).length;
|
|
4109
4671
|
process.stderr.write(`Found ${count} components.
|
|
4110
4672
|
`);
|
|
4111
4673
|
}
|
|
4112
|
-
writeFileSync6(
|
|
4674
|
+
writeFileSync6(resolve11(baselineDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
|
4113
4675
|
let componentNames = Object.keys(manifest.components);
|
|
4114
4676
|
if (componentsGlob !== void 0) {
|
|
4115
4677
|
componentNames = componentNames.filter((name) => matchGlob(componentsGlob, name));
|
|
@@ -4130,7 +4692,7 @@ async function runBaseline(options = {}) {
|
|
|
4130
4692
|
auditedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4131
4693
|
};
|
|
4132
4694
|
writeFileSync6(
|
|
4133
|
-
|
|
4695
|
+
resolve11(baselineDir, "compliance.json"),
|
|
4134
4696
|
JSON.stringify(emptyReport, null, 2),
|
|
4135
4697
|
"utf-8"
|
|
4136
4698
|
);
|
|
@@ -4151,7 +4713,7 @@ async function runBaseline(options = {}) {
|
|
|
4151
4713
|
const renderOne = async (name) => {
|
|
4152
4714
|
const descriptor = manifest.components[name];
|
|
4153
4715
|
if (descriptor === void 0) return;
|
|
4154
|
-
const filePath =
|
|
4716
|
+
const filePath = resolve11(rootDir, descriptor.filePath);
|
|
4155
4717
|
const outcome = await safeRender3(
|
|
4156
4718
|
() => renderComponent2(filePath, name, {}, viewportWidth, viewportHeight),
|
|
4157
4719
|
{
|
|
@@ -4170,7 +4732,7 @@ async function runBaseline(options = {}) {
|
|
|
4170
4732
|
}
|
|
4171
4733
|
if (outcome.crashed) {
|
|
4172
4734
|
failureCount++;
|
|
4173
|
-
const errPath =
|
|
4735
|
+
const errPath = resolve11(rendersDir, `${name}.error.json`);
|
|
4174
4736
|
writeFileSync6(
|
|
4175
4737
|
errPath,
|
|
4176
4738
|
JSON.stringify(
|
|
@@ -4188,10 +4750,10 @@ async function runBaseline(options = {}) {
|
|
|
4188
4750
|
return;
|
|
4189
4751
|
}
|
|
4190
4752
|
const result = outcome.result;
|
|
4191
|
-
writeFileSync6(
|
|
4753
|
+
writeFileSync6(resolve11(rendersDir, `${name}.png`), result.screenshot);
|
|
4192
4754
|
const jsonOutput = formatRenderJson(name, {}, result);
|
|
4193
4755
|
writeFileSync6(
|
|
4194
|
-
|
|
4756
|
+
resolve11(rendersDir, `${name}.json`),
|
|
4195
4757
|
JSON.stringify(jsonOutput, null, 2),
|
|
4196
4758
|
"utf-8"
|
|
4197
4759
|
);
|
|
@@ -4219,7 +4781,7 @@ async function runBaseline(options = {}) {
|
|
|
4219
4781
|
const engine = new ComplianceEngine2(resolver);
|
|
4220
4782
|
const batchReport = engine.auditBatch(computedStylesMap);
|
|
4221
4783
|
writeFileSync6(
|
|
4222
|
-
|
|
4784
|
+
resolve11(baselineDir, "compliance.json"),
|
|
4223
4785
|
JSON.stringify(batchReport, null, 2),
|
|
4224
4786
|
"utf-8"
|
|
4225
4787
|
);
|
|
@@ -4262,22 +4824,22 @@ function registerBaselineSubCommand(reportCmd) {
|
|
|
4262
4824
|
}
|
|
4263
4825
|
|
|
4264
4826
|
// src/report/diff.ts
|
|
4265
|
-
import { existsSync as
|
|
4266
|
-
import { resolve as
|
|
4267
|
-
import { generateManifest as
|
|
4827
|
+
import { existsSync as existsSync10, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
4828
|
+
import { resolve as resolve12 } from "path";
|
|
4829
|
+
import { generateManifest as generateManifest5 } from "@agent-scope/manifest";
|
|
4268
4830
|
import { BrowserPool as BrowserPool5, safeRender as safeRender4 } from "@agent-scope/render";
|
|
4269
4831
|
import { ComplianceEngine as ComplianceEngine3, TokenResolver as TokenResolver3 } from "@agent-scope/tokens";
|
|
4270
4832
|
var DEFAULT_BASELINE_DIR2 = ".reactscope/baseline";
|
|
4271
4833
|
function loadBaselineCompliance(baselineDir) {
|
|
4272
|
-
const compliancePath =
|
|
4273
|
-
if (!
|
|
4274
|
-
const raw = JSON.parse(
|
|
4834
|
+
const compliancePath = resolve12(baselineDir, "compliance.json");
|
|
4835
|
+
if (!existsSync10(compliancePath)) return null;
|
|
4836
|
+
const raw = JSON.parse(readFileSync8(compliancePath, "utf-8"));
|
|
4275
4837
|
return raw;
|
|
4276
4838
|
}
|
|
4277
4839
|
function loadBaselineRenderJson2(baselineDir, componentName) {
|
|
4278
|
-
const jsonPath =
|
|
4279
|
-
if (!
|
|
4280
|
-
return JSON.parse(
|
|
4840
|
+
const jsonPath = resolve12(baselineDir, "renders", `${componentName}.json`);
|
|
4841
|
+
if (!existsSync10(jsonPath)) return null;
|
|
4842
|
+
return JSON.parse(readFileSync8(jsonPath, "utf-8"));
|
|
4281
4843
|
}
|
|
4282
4844
|
var _pool5 = null;
|
|
4283
4845
|
async function getPool5(viewportWidth, viewportHeight) {
|
|
@@ -4444,19 +5006,19 @@ async function runDiff(options = {}) {
|
|
|
4444
5006
|
} = options;
|
|
4445
5007
|
const startTime = performance.now();
|
|
4446
5008
|
const rootDir = process.cwd();
|
|
4447
|
-
const baselineDir =
|
|
4448
|
-
if (!
|
|
5009
|
+
const baselineDir = resolve12(rootDir, baselineDirRaw);
|
|
5010
|
+
if (!existsSync10(baselineDir)) {
|
|
4449
5011
|
throw new Error(
|
|
4450
5012
|
`Baseline directory not found at "${baselineDir}". Run \`scope report baseline\` first to create a baseline snapshot.`
|
|
4451
5013
|
);
|
|
4452
5014
|
}
|
|
4453
|
-
const baselineManifestPath =
|
|
4454
|
-
if (!
|
|
5015
|
+
const baselineManifestPath = resolve12(baselineDir, "manifest.json");
|
|
5016
|
+
if (!existsSync10(baselineManifestPath)) {
|
|
4455
5017
|
throw new Error(
|
|
4456
5018
|
`Baseline manifest.json not found at "${baselineManifestPath}". The baseline directory may be incomplete \u2014 re-run \`scope report baseline\`.`
|
|
4457
5019
|
);
|
|
4458
5020
|
}
|
|
4459
|
-
const baselineManifest = JSON.parse(
|
|
5021
|
+
const baselineManifest = JSON.parse(readFileSync8(baselineManifestPath, "utf-8"));
|
|
4460
5022
|
const baselineCompliance = loadBaselineCompliance(baselineDir);
|
|
4461
5023
|
const baselineComponentNames = new Set(Object.keys(baselineManifest.components));
|
|
4462
5024
|
process.stderr.write(
|
|
@@ -4465,16 +5027,16 @@ async function runDiff(options = {}) {
|
|
|
4465
5027
|
);
|
|
4466
5028
|
let currentManifest;
|
|
4467
5029
|
if (manifestPath !== void 0) {
|
|
4468
|
-
const absPath =
|
|
4469
|
-
if (!
|
|
5030
|
+
const absPath = resolve12(rootDir, manifestPath);
|
|
5031
|
+
if (!existsSync10(absPath)) {
|
|
4470
5032
|
throw new Error(`Manifest not found at "${absPath}".`);
|
|
4471
5033
|
}
|
|
4472
|
-
currentManifest = JSON.parse(
|
|
5034
|
+
currentManifest = JSON.parse(readFileSync8(absPath, "utf-8"));
|
|
4473
5035
|
process.stderr.write(`Loaded manifest from ${manifestPath}
|
|
4474
5036
|
`);
|
|
4475
5037
|
} else {
|
|
4476
5038
|
process.stderr.write("Scanning for React components\u2026\n");
|
|
4477
|
-
currentManifest = await
|
|
5039
|
+
currentManifest = await generateManifest5({ rootDir });
|
|
4478
5040
|
const count = Object.keys(currentManifest.components).length;
|
|
4479
5041
|
process.stderr.write(`Found ${count} components.
|
|
4480
5042
|
`);
|
|
@@ -4502,7 +5064,7 @@ async function runDiff(options = {}) {
|
|
|
4502
5064
|
const renderOne = async (name) => {
|
|
4503
5065
|
const descriptor = currentManifest.components[name];
|
|
4504
5066
|
if (descriptor === void 0) return;
|
|
4505
|
-
const filePath =
|
|
5067
|
+
const filePath = resolve12(rootDir, descriptor.filePath);
|
|
4506
5068
|
const outcome = await safeRender4(
|
|
4507
5069
|
() => renderComponent3(filePath, name, {}, viewportWidth, viewportHeight),
|
|
4508
5070
|
{
|
|
@@ -4742,8 +5304,8 @@ function registerDiffSubCommand(reportCmd) {
|
|
|
4742
5304
|
}
|
|
4743
5305
|
|
|
4744
5306
|
// src/report/pr-comment.ts
|
|
4745
|
-
import { existsSync as
|
|
4746
|
-
import { resolve as
|
|
5307
|
+
import { existsSync as existsSync11, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
|
|
5308
|
+
import { resolve as resolve13 } from "path";
|
|
4747
5309
|
var STATUS_BADGE = {
|
|
4748
5310
|
added: "\u2705 added",
|
|
4749
5311
|
removed: "\u{1F5D1}\uFE0F removed",
|
|
@@ -4826,13 +5388,13 @@ function formatPrComment(diff) {
|
|
|
4826
5388
|
return lines.join("\n");
|
|
4827
5389
|
}
|
|
4828
5390
|
function loadDiffResult(filePath) {
|
|
4829
|
-
const abs =
|
|
4830
|
-
if (!
|
|
5391
|
+
const abs = resolve13(filePath);
|
|
5392
|
+
if (!existsSync11(abs)) {
|
|
4831
5393
|
throw new Error(`DiffResult file not found: ${abs}`);
|
|
4832
5394
|
}
|
|
4833
5395
|
let raw;
|
|
4834
5396
|
try {
|
|
4835
|
-
raw =
|
|
5397
|
+
raw = readFileSync9(abs, "utf-8");
|
|
4836
5398
|
} catch (err) {
|
|
4837
5399
|
throw new Error(
|
|
4838
5400
|
`Failed to read DiffResult file: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -4859,7 +5421,7 @@ function registerPrCommentSubCommand(reportCmd) {
|
|
|
4859
5421
|
const diff = loadDiffResult(opts.input);
|
|
4860
5422
|
const comment = formatPrComment(diff);
|
|
4861
5423
|
if (opts.output !== void 0) {
|
|
4862
|
-
writeFileSync8(
|
|
5424
|
+
writeFileSync8(resolve13(opts.output), comment, "utf-8");
|
|
4863
5425
|
process.stderr.write(`PR comment written to ${opts.output}
|
|
4864
5426
|
`);
|
|
4865
5427
|
} else {
|
|
@@ -5154,11 +5716,11 @@ function buildStructuredReport(report) {
|
|
|
5154
5716
|
}
|
|
5155
5717
|
|
|
5156
5718
|
// src/site-commands.ts
|
|
5157
|
-
import { createReadStream, existsSync as
|
|
5719
|
+
import { createReadStream, existsSync as existsSync12, statSync as statSync2 } from "fs";
|
|
5158
5720
|
import { createServer } from "http";
|
|
5159
|
-
import { extname, join as
|
|
5721
|
+
import { extname, join as join5, resolve as resolve14 } from "path";
|
|
5160
5722
|
import { buildSite } from "@agent-scope/site";
|
|
5161
|
-
import { Command as
|
|
5723
|
+
import { Command as Command9 } from "commander";
|
|
5162
5724
|
var MIME_TYPES = {
|
|
5163
5725
|
".html": "text/html; charset=utf-8",
|
|
5164
5726
|
".css": "text/css; charset=utf-8",
|
|
@@ -5171,19 +5733,21 @@ var MIME_TYPES = {
|
|
|
5171
5733
|
".ico": "image/x-icon"
|
|
5172
5734
|
};
|
|
5173
5735
|
function registerBuild(siteCmd) {
|
|
5174
|
-
siteCmd.command("build").description(
|
|
5736
|
+
siteCmd.command("build").description(
|
|
5737
|
+
'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-styles.json\n scope site build --base-path /ui'
|
|
5738
|
+
).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("--title <text>", "Site title", "Scope \u2014 Component Gallery").action(
|
|
5175
5739
|
async (opts) => {
|
|
5176
5740
|
try {
|
|
5177
|
-
const inputDir =
|
|
5178
|
-
const outputDir =
|
|
5179
|
-
if (!
|
|
5741
|
+
const inputDir = resolve14(process.cwd(), opts.input);
|
|
5742
|
+
const outputDir = resolve14(process.cwd(), opts.output);
|
|
5743
|
+
if (!existsSync12(inputDir)) {
|
|
5180
5744
|
throw new Error(
|
|
5181
5745
|
`Input directory not found: ${inputDir}
|
|
5182
5746
|
Run \`scope manifest generate\` and \`scope render\` first.`
|
|
5183
5747
|
);
|
|
5184
5748
|
}
|
|
5185
|
-
const manifestPath =
|
|
5186
|
-
if (!
|
|
5749
|
+
const manifestPath = join5(inputDir, "manifest.json");
|
|
5750
|
+
if (!existsSync12(manifestPath)) {
|
|
5187
5751
|
throw new Error(
|
|
5188
5752
|
`Manifest not found at ${manifestPath}
|
|
5189
5753
|
Run \`scope manifest generate\` first.`
|
|
@@ -5196,7 +5760,7 @@ Run \`scope manifest generate\` first.`
|
|
|
5196
5760
|
outputDir,
|
|
5197
5761
|
basePath: opts.basePath,
|
|
5198
5762
|
...opts.compliance !== void 0 && {
|
|
5199
|
-
compliancePath:
|
|
5763
|
+
compliancePath: resolve14(process.cwd(), opts.compliance)
|
|
5200
5764
|
},
|
|
5201
5765
|
title: opts.title
|
|
5202
5766
|
});
|
|
@@ -5213,14 +5777,16 @@ Run \`scope manifest generate\` first.`
|
|
|
5213
5777
|
);
|
|
5214
5778
|
}
|
|
5215
5779
|
function registerServe(siteCmd) {
|
|
5216
|
-
siteCmd.command("serve").description(
|
|
5780
|
+
siteCmd.command("serve").description(
|
|
5781
|
+
"Start a local HTTP server for the built site directory.\n\nRun `scope site build` first.\nCtrl+C to stop.\n\nExamples:\n scope site serve\n scope site serve --port 8080\n scope site serve --dir ./my-site-output"
|
|
5782
|
+
).option("-p, --port <number>", "Port to listen on", "3000").option("-d, --dir <path>", "Directory to serve", ".reactscope/site").action((opts) => {
|
|
5217
5783
|
try {
|
|
5218
5784
|
const port = Number.parseInt(opts.port, 10);
|
|
5219
5785
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
5220
5786
|
throw new Error(`Invalid port: ${opts.port}`);
|
|
5221
5787
|
}
|
|
5222
|
-
const serveDir =
|
|
5223
|
-
if (!
|
|
5788
|
+
const serveDir = resolve14(process.cwd(), opts.dir);
|
|
5789
|
+
if (!existsSync12(serveDir)) {
|
|
5224
5790
|
throw new Error(
|
|
5225
5791
|
`Serve directory not found: ${serveDir}
|
|
5226
5792
|
Run \`scope site build\` first.`
|
|
@@ -5229,13 +5795,13 @@ Run \`scope site build\` first.`
|
|
|
5229
5795
|
const server = createServer((req, res) => {
|
|
5230
5796
|
const rawUrl = req.url ?? "/";
|
|
5231
5797
|
const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
|
|
5232
|
-
const filePath =
|
|
5798
|
+
const filePath = join5(serveDir, urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath);
|
|
5233
5799
|
if (!filePath.startsWith(serveDir)) {
|
|
5234
5800
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
5235
5801
|
res.end("Forbidden");
|
|
5236
5802
|
return;
|
|
5237
5803
|
}
|
|
5238
|
-
if (
|
|
5804
|
+
if (existsSync12(filePath) && statSync2(filePath).isFile()) {
|
|
5239
5805
|
const ext = extname(filePath).toLowerCase();
|
|
5240
5806
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
5241
5807
|
res.writeHead(200, { "Content-Type": contentType });
|
|
@@ -5243,7 +5809,7 @@ Run \`scope site build\` first.`
|
|
|
5243
5809
|
return;
|
|
5244
5810
|
}
|
|
5245
5811
|
const htmlPath = `${filePath}.html`;
|
|
5246
|
-
if (
|
|
5812
|
+
if (existsSync12(htmlPath) && statSync2(htmlPath).isFile()) {
|
|
5247
5813
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
5248
5814
|
createReadStream(htmlPath).pipe(res);
|
|
5249
5815
|
return;
|
|
@@ -5276,8 +5842,8 @@ Run \`scope site build\` first.`
|
|
|
5276
5842
|
});
|
|
5277
5843
|
}
|
|
5278
5844
|
function createSiteCommand() {
|
|
5279
|
-
const siteCmd = new
|
|
5280
|
-
|
|
5845
|
+
const siteCmd = new Command9("site").description(
|
|
5846
|
+
'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-styles.json\n scope site serve --port 8080'
|
|
5281
5847
|
);
|
|
5282
5848
|
registerBuild(siteCmd);
|
|
5283
5849
|
registerServe(siteCmd);
|
|
@@ -5285,8 +5851,8 @@ function createSiteCommand() {
|
|
|
5285
5851
|
}
|
|
5286
5852
|
|
|
5287
5853
|
// src/tokens/commands.ts
|
|
5288
|
-
import { existsSync as
|
|
5289
|
-
import { resolve as
|
|
5854
|
+
import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
|
|
5855
|
+
import { resolve as resolve18 } from "path";
|
|
5290
5856
|
import {
|
|
5291
5857
|
parseTokenFileSync as parseTokenFileSync2,
|
|
5292
5858
|
TokenParseError,
|
|
@@ -5294,26 +5860,26 @@ import {
|
|
|
5294
5860
|
TokenValidationError,
|
|
5295
5861
|
validateTokenFile
|
|
5296
5862
|
} from "@agent-scope/tokens";
|
|
5297
|
-
import { Command as
|
|
5863
|
+
import { Command as Command11 } from "commander";
|
|
5298
5864
|
|
|
5299
5865
|
// src/tokens/compliance.ts
|
|
5300
|
-
import { existsSync as
|
|
5301
|
-
import { resolve as
|
|
5866
|
+
import { existsSync as existsSync13, readFileSync as readFileSync10 } from "fs";
|
|
5867
|
+
import { resolve as resolve15 } from "path";
|
|
5302
5868
|
import {
|
|
5303
5869
|
ComplianceEngine as ComplianceEngine4,
|
|
5304
5870
|
TokenResolver as TokenResolver4
|
|
5305
5871
|
} from "@agent-scope/tokens";
|
|
5306
5872
|
var DEFAULT_STYLES_PATH = ".reactscope/compliance-styles.json";
|
|
5307
5873
|
function loadStylesFile(stylesPath) {
|
|
5308
|
-
const absPath =
|
|
5309
|
-
if (!
|
|
5874
|
+
const absPath = resolve15(process.cwd(), stylesPath);
|
|
5875
|
+
if (!existsSync13(absPath)) {
|
|
5310
5876
|
throw new Error(
|
|
5311
5877
|
`Compliance styles file not found at ${absPath}.
|
|
5312
5878
|
Run \`scope render all\` first to generate component styles, or use --styles to specify a path.
|
|
5313
5879
|
Expected format: { "ComponentName": { colors: {}, spacing: {}, typography: {}, borders: {}, shadows: {} } }`
|
|
5314
5880
|
);
|
|
5315
5881
|
}
|
|
5316
|
-
const raw =
|
|
5882
|
+
const raw = readFileSync10(absPath, "utf-8");
|
|
5317
5883
|
let parsed;
|
|
5318
5884
|
try {
|
|
5319
5885
|
parsed = JSON.parse(raw);
|
|
@@ -5435,7 +6001,9 @@ function formatComplianceReport(batch, threshold) {
|
|
|
5435
6001
|
return lines.join("\n");
|
|
5436
6002
|
}
|
|
5437
6003
|
function registerCompliance(tokensCmd) {
|
|
5438
|
-
tokensCmd.command("compliance").description(
|
|
6004
|
+
tokensCmd.command("compliance").description(
|
|
6005
|
+
"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 --styles ./custom/compliance-styles.json"
|
|
6006
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).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((opts) => {
|
|
5439
6007
|
try {
|
|
5440
6008
|
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
5441
6009
|
const { tokens } = loadTokens(tokenFilePath);
|
|
@@ -5473,38 +6041,40 @@ function registerCompliance(tokensCmd) {
|
|
|
5473
6041
|
}
|
|
5474
6042
|
|
|
5475
6043
|
// src/tokens/export.ts
|
|
5476
|
-
import { existsSync as
|
|
5477
|
-
import { resolve as
|
|
6044
|
+
import { existsSync as existsSync14, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
|
|
6045
|
+
import { resolve as resolve16 } from "path";
|
|
5478
6046
|
import {
|
|
5479
6047
|
exportTokens,
|
|
5480
6048
|
parseTokenFileSync,
|
|
5481
6049
|
ThemeResolver,
|
|
5482
6050
|
TokenResolver as TokenResolver5
|
|
5483
6051
|
} from "@agent-scope/tokens";
|
|
5484
|
-
import { Command as
|
|
6052
|
+
import { Command as Command10 } from "commander";
|
|
5485
6053
|
var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
|
|
5486
6054
|
var CONFIG_FILE = "reactscope.config.json";
|
|
5487
6055
|
var SUPPORTED_FORMATS = ["css", "ts", "scss", "tailwind", "flat-json", "figma"];
|
|
5488
6056
|
function resolveTokenFilePath2(fileFlag) {
|
|
5489
6057
|
if (fileFlag !== void 0) {
|
|
5490
|
-
return
|
|
6058
|
+
return resolve16(process.cwd(), fileFlag);
|
|
5491
6059
|
}
|
|
5492
|
-
const configPath =
|
|
5493
|
-
if (
|
|
6060
|
+
const configPath = resolve16(process.cwd(), CONFIG_FILE);
|
|
6061
|
+
if (existsSync14(configPath)) {
|
|
5494
6062
|
try {
|
|
5495
|
-
const raw =
|
|
6063
|
+
const raw = readFileSync11(configPath, "utf-8");
|
|
5496
6064
|
const config = JSON.parse(raw);
|
|
5497
6065
|
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
5498
6066
|
const file = config.tokens.file;
|
|
5499
|
-
return
|
|
6067
|
+
return resolve16(process.cwd(), file);
|
|
5500
6068
|
}
|
|
5501
6069
|
} catch {
|
|
5502
6070
|
}
|
|
5503
6071
|
}
|
|
5504
|
-
return
|
|
6072
|
+
return resolve16(process.cwd(), DEFAULT_TOKEN_FILE);
|
|
5505
6073
|
}
|
|
5506
6074
|
function createTokensExportCommand() {
|
|
5507
|
-
return new
|
|
6075
|
+
return new Command10("export").description(
|
|
6076
|
+
'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'
|
|
6077
|
+
).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(
|
|
5508
6078
|
"--theme <name>",
|
|
5509
6079
|
"Include theme overrides for the named theme (applies to css, ts, scss, tailwind, figma)"
|
|
5510
6080
|
).action(
|
|
@@ -5530,13 +6100,13 @@ Supported formats: ${SUPPORTED_FORMATS.join(", ")}
|
|
|
5530
6100
|
const format = opts.format;
|
|
5531
6101
|
try {
|
|
5532
6102
|
const filePath = resolveTokenFilePath2(opts.file);
|
|
5533
|
-
if (!
|
|
6103
|
+
if (!existsSync14(filePath)) {
|
|
5534
6104
|
throw new Error(
|
|
5535
6105
|
`Token file not found at ${filePath}.
|
|
5536
6106
|
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
5537
6107
|
);
|
|
5538
6108
|
}
|
|
5539
|
-
const raw =
|
|
6109
|
+
const raw = readFileSync11(filePath, "utf-8");
|
|
5540
6110
|
const { tokens, rawFile } = parseTokenFileSync(raw);
|
|
5541
6111
|
let themesMap;
|
|
5542
6112
|
if (opts.theme !== void 0) {
|
|
@@ -5575,7 +6145,7 @@ Available themes: ${themeNames.join(", ")}`
|
|
|
5575
6145
|
themes: themesMap
|
|
5576
6146
|
});
|
|
5577
6147
|
if (opts.out !== void 0) {
|
|
5578
|
-
const outPath =
|
|
6148
|
+
const outPath = resolve16(process.cwd(), opts.out);
|
|
5579
6149
|
writeFileSync9(outPath, output, "utf-8");
|
|
5580
6150
|
process.stderr.write(`Exported ${tokens.length} tokens to ${outPath}
|
|
5581
6151
|
`);
|
|
@@ -5651,7 +6221,9 @@ function formatImpactSummary(report) {
|
|
|
5651
6221
|
return `\u2192 ${parts.join(", ")}`;
|
|
5652
6222
|
}
|
|
5653
6223
|
function registerImpact(tokensCmd) {
|
|
5654
|
-
tokensCmd.command("impact <path>").description(
|
|
6224
|
+
tokensCmd.command("impact <path>").description(
|
|
6225
|
+
"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"
|
|
6226
|
+
).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(
|
|
5655
6227
|
(tokenPath, opts) => {
|
|
5656
6228
|
try {
|
|
5657
6229
|
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
@@ -5691,7 +6263,7 @@ ${formatImpactSummary(report)}
|
|
|
5691
6263
|
|
|
5692
6264
|
// src/tokens/preview.ts
|
|
5693
6265
|
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync10 } from "fs";
|
|
5694
|
-
import { resolve as
|
|
6266
|
+
import { resolve as resolve17 } from "path";
|
|
5695
6267
|
import { BrowserPool as BrowserPool6, SpriteSheetGenerator } from "@agent-scope/render";
|
|
5696
6268
|
import { ComplianceEngine as ComplianceEngine6, ImpactAnalyzer as ImpactAnalyzer2, TokenResolver as TokenResolver7 } from "@agent-scope/tokens";
|
|
5697
6269
|
var DEFAULT_STYLES_PATH3 = ".reactscope/compliance-styles.json";
|
|
@@ -5746,7 +6318,9 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
|
|
|
5746
6318
|
}
|
|
5747
6319
|
}
|
|
5748
6320
|
function registerPreview(tokensCmd) {
|
|
5749
|
-
tokensCmd.command("preview <path>").description(
|
|
6321
|
+
tokensCmd.command("preview <path>").description(
|
|
6322
|
+
'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'
|
|
6323
|
+
).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(
|
|
5750
6324
|
async (tokenPath, opts) => {
|
|
5751
6325
|
try {
|
|
5752
6326
|
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
@@ -5872,8 +6446,8 @@ function registerPreview(tokensCmd) {
|
|
|
5872
6446
|
});
|
|
5873
6447
|
const spriteResult = await generator.generate(matrixResult);
|
|
5874
6448
|
const tokenLabel = tokenPath.replace(/\./g, "-");
|
|
5875
|
-
const outputPath = opts.output ??
|
|
5876
|
-
const outputDir =
|
|
6449
|
+
const outputPath = opts.output ?? resolve17(process.cwd(), DEFAULT_OUTPUT_DIR2, `preview-${tokenLabel}.png`);
|
|
6450
|
+
const outputDir = resolve17(outputPath, "..");
|
|
5877
6451
|
mkdirSync6(outputDir, { recursive: true });
|
|
5878
6452
|
writeFileSync10(outputPath, spriteResult.png);
|
|
5879
6453
|
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
|
|
@@ -5934,30 +6508,30 @@ function buildTable2(headers, rows) {
|
|
|
5934
6508
|
}
|
|
5935
6509
|
function resolveTokenFilePath(fileFlag) {
|
|
5936
6510
|
if (fileFlag !== void 0) {
|
|
5937
|
-
return
|
|
6511
|
+
return resolve18(process.cwd(), fileFlag);
|
|
5938
6512
|
}
|
|
5939
|
-
const configPath =
|
|
5940
|
-
if (
|
|
6513
|
+
const configPath = resolve18(process.cwd(), CONFIG_FILE2);
|
|
6514
|
+
if (existsSync15(configPath)) {
|
|
5941
6515
|
try {
|
|
5942
|
-
const raw =
|
|
6516
|
+
const raw = readFileSync12(configPath, "utf-8");
|
|
5943
6517
|
const config = JSON.parse(raw);
|
|
5944
6518
|
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
5945
6519
|
const file = config.tokens.file;
|
|
5946
|
-
return
|
|
6520
|
+
return resolve18(process.cwd(), file);
|
|
5947
6521
|
}
|
|
5948
6522
|
} catch {
|
|
5949
6523
|
}
|
|
5950
6524
|
}
|
|
5951
|
-
return
|
|
6525
|
+
return resolve18(process.cwd(), DEFAULT_TOKEN_FILE2);
|
|
5952
6526
|
}
|
|
5953
6527
|
function loadTokens(absPath) {
|
|
5954
|
-
if (!
|
|
6528
|
+
if (!existsSync15(absPath)) {
|
|
5955
6529
|
throw new Error(
|
|
5956
6530
|
`Token file not found at ${absPath}.
|
|
5957
6531
|
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
5958
6532
|
);
|
|
5959
6533
|
}
|
|
5960
|
-
const raw =
|
|
6534
|
+
const raw = readFileSync12(absPath, "utf-8");
|
|
5961
6535
|
return parseTokenFileSync2(raw);
|
|
5962
6536
|
}
|
|
5963
6537
|
function getRawValue(node, segments) {
|
|
@@ -5993,7 +6567,9 @@ function buildResolutionChain(startPath, rawTokens) {
|
|
|
5993
6567
|
return chain;
|
|
5994
6568
|
}
|
|
5995
6569
|
function registerGet2(tokensCmd) {
|
|
5996
|
-
tokensCmd.command("get <path>").description(
|
|
6570
|
+
tokensCmd.command("get <path>").description(
|
|
6571
|
+
"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"
|
|
6572
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
5997
6573
|
try {
|
|
5998
6574
|
const filePath = resolveTokenFilePath(opts.file);
|
|
5999
6575
|
const { tokens } = loadTokens(filePath);
|
|
@@ -6018,7 +6594,18 @@ function registerGet2(tokensCmd) {
|
|
|
6018
6594
|
});
|
|
6019
6595
|
}
|
|
6020
6596
|
function registerList2(tokensCmd) {
|
|
6021
|
-
tokensCmd.command("list [category]").description(
|
|
6597
|
+
tokensCmd.command("list [category]").description(
|
|
6598
|
+
`List all tokens, optionally filtered by category prefix or type.
|
|
6599
|
+
|
|
6600
|
+
CATEGORY: top-level token namespace (e.g. "color", "spacing", "typography")
|
|
6601
|
+
TYPE: token value type \u2014 color | spacing | typography | shadow | radius | opacity
|
|
6602
|
+
|
|
6603
|
+
Examples:
|
|
6604
|
+
scope tokens list
|
|
6605
|
+
scope tokens list color
|
|
6606
|
+
scope tokens list --type spacing
|
|
6607
|
+
scope tokens list color --format json | jq '.[].path'`
|
|
6608
|
+
).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(
|
|
6022
6609
|
(category, opts) => {
|
|
6023
6610
|
try {
|
|
6024
6611
|
const filePath = resolveTokenFilePath(opts.file);
|
|
@@ -6048,7 +6635,9 @@ function registerList2(tokensCmd) {
|
|
|
6048
6635
|
);
|
|
6049
6636
|
}
|
|
6050
6637
|
function registerSearch(tokensCmd) {
|
|
6051
|
-
tokensCmd.command("search <value>").description(
|
|
6638
|
+
tokensCmd.command("search <value>").description(
|
|
6639
|
+
'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'
|
|
6640
|
+
).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(
|
|
6052
6641
|
(value, opts) => {
|
|
6053
6642
|
try {
|
|
6054
6643
|
const filePath = resolveTokenFilePath(opts.file);
|
|
@@ -6131,7 +6720,9 @@ Tip: use --fuzzy for nearest-match search.
|
|
|
6131
6720
|
);
|
|
6132
6721
|
}
|
|
6133
6722
|
function registerResolve(tokensCmd) {
|
|
6134
|
-
tokensCmd.command("resolve <path>").description(
|
|
6723
|
+
tokensCmd.command("resolve <path>").description(
|
|
6724
|
+
"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"
|
|
6725
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
6135
6726
|
try {
|
|
6136
6727
|
const filePath = resolveTokenFilePath(opts.file);
|
|
6137
6728
|
const absFilePath = filePath;
|
|
@@ -6167,17 +6758,29 @@ function registerResolve(tokensCmd) {
|
|
|
6167
6758
|
}
|
|
6168
6759
|
function registerValidate(tokensCmd) {
|
|
6169
6760
|
tokensCmd.command("validate").description(
|
|
6170
|
-
|
|
6761
|
+
`Validate the token file and report errors.
|
|
6762
|
+
|
|
6763
|
+
CHECKS:
|
|
6764
|
+
- Circular reference chains (A \u2192 B \u2192 A)
|
|
6765
|
+
- Broken references ({path.that.does.not.exist})
|
|
6766
|
+
- Type mismatches (token declared as "color" but value is a number)
|
|
6767
|
+
- Duplicate paths
|
|
6768
|
+
|
|
6769
|
+
Exits 1 if any errors are found (suitable for CI).
|
|
6770
|
+
|
|
6771
|
+
Examples:
|
|
6772
|
+
scope tokens validate
|
|
6773
|
+
scope tokens validate --format json | jq '.errors'`
|
|
6171
6774
|
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((opts) => {
|
|
6172
6775
|
try {
|
|
6173
6776
|
const filePath = resolveTokenFilePath(opts.file);
|
|
6174
|
-
if (!
|
|
6777
|
+
if (!existsSync15(filePath)) {
|
|
6175
6778
|
throw new Error(
|
|
6176
6779
|
`Token file not found at ${filePath}.
|
|
6177
6780
|
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
6178
6781
|
);
|
|
6179
6782
|
}
|
|
6180
|
-
const raw =
|
|
6783
|
+
const raw = readFileSync12(filePath, "utf-8");
|
|
6181
6784
|
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY2();
|
|
6182
6785
|
const errors = [];
|
|
6183
6786
|
let parsed;
|
|
@@ -6245,8 +6848,8 @@ function outputValidationResult(filePath, errors, useJson) {
|
|
|
6245
6848
|
}
|
|
6246
6849
|
}
|
|
6247
6850
|
function createTokensCommand() {
|
|
6248
|
-
const tokensCmd = new
|
|
6249
|
-
|
|
6851
|
+
const tokensCmd = new Command11("tokens").description(
|
|
6852
|
+
'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'
|
|
6250
6853
|
);
|
|
6251
6854
|
registerGet2(tokensCmd);
|
|
6252
6855
|
registerList2(tokensCmd);
|
|
@@ -6262,8 +6865,12 @@ function createTokensCommand() {
|
|
|
6262
6865
|
|
|
6263
6866
|
// src/program.ts
|
|
6264
6867
|
function createProgram(options = {}) {
|
|
6265
|
-
const program2 = new
|
|
6266
|
-
|
|
6868
|
+
const program2 = new Command12("scope").version(options.version ?? "0.1.0").description(
|
|
6869
|
+
'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\nQUICKSTART (new project):\n scope init # detect config, scaffold reactscope.config.json\n scope doctor # verify setup before doing anything else\n scope manifest generate # scan source and build component manifest\n scope render all # screenshot every component\n scope site build # build HTML gallery\n scope site serve # open at http://localhost:3000\n\nQUICKSTART (existing project / CI):\n scope ci # manifest \u2192 render \u2192 compliance \u2192 regression in one step\n\nAGENT BOOTSTRAP:\n scope get-skill # print SKILL.md to stdout \u2014 pipe into agent context\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.'
|
|
6870
|
+
);
|
|
6871
|
+
program2.command("capture <url>").description(
|
|
6872
|
+
"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"
|
|
6873
|
+
).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(
|
|
6267
6874
|
async (url, opts) => {
|
|
6268
6875
|
try {
|
|
6269
6876
|
const { report } = await browserCapture({
|
|
@@ -6287,7 +6894,9 @@ function createProgram(options = {}) {
|
|
|
6287
6894
|
}
|
|
6288
6895
|
}
|
|
6289
6896
|
);
|
|
6290
|
-
program2.command("tree <url>").description(
|
|
6897
|
+
program2.command("tree <url>").description(
|
|
6898
|
+
"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"
|
|
6899
|
+
).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(
|
|
6291
6900
|
async (url, opts) => {
|
|
6292
6901
|
try {
|
|
6293
6902
|
const { report } = await browserCapture({
|
|
@@ -6310,7 +6919,9 @@ function createProgram(options = {}) {
|
|
|
6310
6919
|
}
|
|
6311
6920
|
}
|
|
6312
6921
|
);
|
|
6313
|
-
program2.command("report <url>").description(
|
|
6922
|
+
program2.command("report <url>").description(
|
|
6923
|
+
"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"
|
|
6924
|
+
).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(
|
|
6314
6925
|
async (url, opts) => {
|
|
6315
6926
|
try {
|
|
6316
6927
|
const { report } = await browserCapture({
|
|
@@ -6334,8 +6945,10 @@ function createProgram(options = {}) {
|
|
|
6334
6945
|
}
|
|
6335
6946
|
}
|
|
6336
6947
|
);
|
|
6337
|
-
program2.command("generate").description(
|
|
6338
|
-
|
|
6948
|
+
program2.command("generate").description(
|
|
6949
|
+
'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"'
|
|
6950
|
+
).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) => {
|
|
6951
|
+
const raw = readFileSync13(tracePath, "utf-8");
|
|
6339
6952
|
const trace = loadTrace(raw);
|
|
6340
6953
|
const source = generateTest(trace, {
|
|
6341
6954
|
description: opts.description,
|
|
@@ -6350,6 +6963,8 @@ function createProgram(options = {}) {
|
|
|
6350
6963
|
program2.addCommand(createInstrumentCommand());
|
|
6351
6964
|
program2.addCommand(createInitCommand());
|
|
6352
6965
|
program2.addCommand(createCiCommand());
|
|
6966
|
+
program2.addCommand(createDoctorCommand());
|
|
6967
|
+
program2.addCommand(createGetSkillCommand());
|
|
6353
6968
|
const existingReportCmd = program2.commands.find((c) => c.name() === "report");
|
|
6354
6969
|
if (existingReportCmd !== void 0) {
|
|
6355
6970
|
registerBaselineSubCommand(existingReportCmd);
|