onsystem 0.0.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1019 -0
- package/dist/design-system-CnEv5sIL.js +7841 -0
- package/dist/eslint-worker.d.ts +36 -0
- package/dist/eslint-worker.js +88 -0
- package/dist/eslint.d.ts +23 -0
- package/dist/eslint.js +172 -0
- package/dist/index.d.ts +775 -0
- package/dist/index.js +3 -0
- package/dist/stdio-DjfkiDMV.js +1399 -0
- package/dist/types-DJtccu3A.d.ts +386 -0
- package/dist/workspace-DzWQ4L8R.js +193 -0
- package/package.json +124 -8
- package/schema.json +301 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { $ as plural, C as updateBaseline, F as CONFIG_FILES, H as RULE_IDS, I as ConfigError, J as detectProject, K as rulesFor, L as DEFAULT_DOCS, R as DEFAULT_SEVERITY, S as readBaseline, V as LEGACY_CONFIG_FILES, _ as VERSION, at as toPosix, b as applyBaseline, c as silentLogger, g as NAME, h as formatDiagnostics, i as componentFiles, it as relativePath, l as stderrLogger, m as uncheckedNotice, nt as isInside, o as loadDesignSystem, rt as matchesGlob, s as tokenFiles, tt as unique, v as BASELINE_FILE, w as writeBaseline, x as countBaseline, y as LEGACY_BASELINE_FILE } from "./design-system-CnEv5sIL.js";
|
|
3
|
+
import { t as serveStdio } from "./stdio-DjfkiDMV.js";
|
|
4
|
+
import { r as loadTarget } from "./workspace-DzWQ4L8R.js";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { escapePath, glob } from "tinyglobby";
|
|
8
|
+
import fs$1 from "node:fs/promises";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import readline from "node:readline/promises";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
//#region src/explain.ts
|
|
13
|
+
/**
|
|
14
|
+
* What `inspect --explain` says about one project: every candidate zero config looked
|
|
15
|
+
* at and why each was taken or not, the config it resolved to, tokens whose value did
|
|
16
|
+
* not resolve, and components without props, with the likely reason.
|
|
17
|
+
*/
|
|
18
|
+
function explainProject(ds, cwd, indent = "") {
|
|
19
|
+
const { config, model } = ds;
|
|
20
|
+
const lines = [];
|
|
21
|
+
const out = (line = "") => lines.push(line ? `${indent}${line}` : "");
|
|
22
|
+
out("How the design system was found");
|
|
23
|
+
if (config.configured) out(` components come from ${config.configFile ? display(cwd, config.configFile) : "--components"}, so zero config did not look`);
|
|
24
|
+
else {
|
|
25
|
+
const steps = [];
|
|
26
|
+
detectProject(config.root, config.tsconfig, steps);
|
|
27
|
+
for (const step of steps) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
|
|
28
|
+
if (!config.detected) out(` So the defaults apply: ${config.components.join(", ")}${model.stats.files.components ? "" : ", which match nothing"}.`);
|
|
29
|
+
}
|
|
30
|
+
out();
|
|
31
|
+
out("Resolved config");
|
|
32
|
+
const field = (name, value) => out(` ${name.padEnd(12)} ${value}`);
|
|
33
|
+
field("root", config.root);
|
|
34
|
+
field("config", config.configFile ? display(cwd, config.configFile) : "(none)");
|
|
35
|
+
field("components", listOf$1(config.components) || "(none)");
|
|
36
|
+
field("exclude", listOf$1(config.exclude));
|
|
37
|
+
field("tokens", listOf$1(config.tokens.map((t) => `${t.path}${t.prefix ? ` (prefix ${t.prefix})` : ""}`)));
|
|
38
|
+
field("docs", listOf$1(config.docs));
|
|
39
|
+
field("tsconfig", config.tsconfig ?? (fs.existsSync(path.join(config.root, "tsconfig.json")) ? "tsconfig.json" : "(none)"));
|
|
40
|
+
field("importPath", importPaths(ds));
|
|
41
|
+
if (config.tailwindConfig) field("tailwind", display(config.root, config.tailwindConfig));
|
|
42
|
+
if (config.designSystems) field("primary", config.designSystems.map((d, i) => `${i ? "then " : ""}${d.source} (${d.imports} imports)`).join("; "));
|
|
43
|
+
const changed = RULE_IDS.filter((id) => config.rules[id].severity !== DEFAULT_SEVERITY[id]);
|
|
44
|
+
field("rules", `${RULE_IDS.map((id) => `${id} ${config.rules[id].severity}`).join(", ")}${changed.length ? ` (changed: ${changed.join(", ")})` : ""}`);
|
|
45
|
+
if (config.overrides?.length) for (const override of config.overrides) field("override", `${override.files.join(", ")}: ${Object.entries(override.rules).map(([id, rule]) => `${id} ${rule.severity}`).join(", ")}`);
|
|
46
|
+
if (config.base) field("relative to", `${config.base} (a config at the workspace root)`);
|
|
47
|
+
out();
|
|
48
|
+
out("Tokens");
|
|
49
|
+
const found = ds.tokens;
|
|
50
|
+
const files = model.stats.files.tokens;
|
|
51
|
+
out(` ${found.length} tokens from ${files} ${files === 1 ? "file" : "files"}${files ? "" : `: no file matches ${config.tokens.map((t) => t.path).join(", ")}`}`);
|
|
52
|
+
const defined = new Set(found.map((t) => t.cssVar).filter((v) => Boolean(v)));
|
|
53
|
+
const missing = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] ?? "").filter((v) => !defined.has(v));
|
|
54
|
+
const unresolved = found.filter((t) => missing(t.value).length);
|
|
55
|
+
if (unresolved.length) out(` ${unresolved.length} unresolved, reading a custom property no token file defines: ${unresolved.slice(0, 8).map((t) => `${t.cssVar ?? t.name}: ${t.value}`).join(", ")}${unresolved.length > 8 ? `, and ${unresolved.length - 8} more` : ""}`);
|
|
56
|
+
else if (found.length) out(" every value resolves");
|
|
57
|
+
for (const warning of model.warnings.filter((w) => /alias|token/i.test(w))) out(` ${warning}`);
|
|
58
|
+
out();
|
|
59
|
+
out("Props");
|
|
60
|
+
const empty = ds.roots().filter((c) => c.openProps && !c.props.length && !c.inherits.length);
|
|
61
|
+
const open = ds.components.filter((c) => c.openProps);
|
|
62
|
+
if (!open.length) out(` every component's props resolved (${ds.components.length} components)`);
|
|
63
|
+
else {
|
|
64
|
+
const installed = nodeModulesFor(config.root);
|
|
65
|
+
const names = (list) => `${list.slice(0, 6).map((c) => c.name).join(", ")}${list.length > 6 ? ` and ${list.length - 6} more` : ""}`;
|
|
66
|
+
if (empty.length) out(` ${empty.length} ${empty.length === 1 ? "component has" : "components have"} 0 props: ${names(empty)}.`);
|
|
67
|
+
out(` ${open.length} ${open.length === 1 ? "component has" : "components have"} props that did not fully resolve${empty.length === open.length ? "" : `: ${names(open)}`}, so no-unknown-prop skips ${open.length === 1 ? "it" : "them"}.`);
|
|
68
|
+
out(installed ? ` Dependencies are installed (${display(cwd, installed)}): a package without types, a type error, or a props type that depends on a type argument leaves them open.` : ` The dependencies are not installed: no node_modules in ${config.root} or a folder above it. Their props come from the packages they wrap (Radix, React Aria), so install them (npm install, pnpm install) and run again.`);
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
/** The first few, then how many more: a package's exports can name hundreds of files. */
|
|
73
|
+
function listOf$1(items, max = 8) {
|
|
74
|
+
const shown = items.slice(0, max).join(", ");
|
|
75
|
+
return items.length > max ? `${shown}, and ${items.length - max} more` : shown;
|
|
76
|
+
}
|
|
77
|
+
/** How components are suggested to be imported. */
|
|
78
|
+
function importPaths(ds) {
|
|
79
|
+
if (ds.config.importPath) return `${ds.config.importPath} (from the config)`;
|
|
80
|
+
const counts = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const c of ds.roots()) counts.set(c.importPath, (counts.get(c.importPath) ?? 0) + 1);
|
|
82
|
+
const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
83
|
+
const from = ds.config.imports?.length ? "package exports" : "tsconfig paths or relative paths";
|
|
84
|
+
return top.length ? `inferred from ${from}: ${top.map(([p, n]) => `${p} (${n})`).join(", ")}${counts.size > 3 ? `, and ${counts.size - 3} more` : ""}` : `inferred from ${from}`;
|
|
85
|
+
}
|
|
86
|
+
/** The nearest node_modules at or above `dir`, if any. */
|
|
87
|
+
function nodeModulesFor(dir) {
|
|
88
|
+
for (let current = path.resolve(dir);; current = path.dirname(current)) {
|
|
89
|
+
const candidate = path.join(current, "node_modules");
|
|
90
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
91
|
+
if (current === path.dirname(current)) return void 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function display(cwd, file) {
|
|
95
|
+
const rel = relativePath(cwd, file);
|
|
96
|
+
return rel && !rel.startsWith("..") ? rel : file;
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/init.ts
|
|
100
|
+
const SCHEMA = "https://unpkg.com/onsystem/schema.json";
|
|
101
|
+
/** More detected globs than this are files, not folders: zero config keeps them. */
|
|
102
|
+
const MAX_GLOBS = 6;
|
|
103
|
+
/**
|
|
104
|
+
* Writes `onsystem.config.json` in the root from what zero config finds there, or from
|
|
105
|
+
* the answers to up to three questions when it finds nothing, then checks the project
|
|
106
|
+
* once without failing and offers to record what it found as the baseline.
|
|
107
|
+
*/
|
|
108
|
+
async function init(configOptions, options, io, runCheck) {
|
|
109
|
+
const { config, workspace } = await loadTarget({
|
|
110
|
+
...configOptions,
|
|
111
|
+
config: void 0
|
|
112
|
+
});
|
|
113
|
+
const root = config.root;
|
|
114
|
+
const existing = [...CONFIG_FILES, ...LEGACY_CONFIG_FILES].map((name) => path.join(root, name)).find((file) => fs.existsSync(file));
|
|
115
|
+
if (existing && !options.force) {
|
|
116
|
+
io.stderr(`init: ${relativePath(io.cwd, existing) || existing} already exists. Run \`${NAME} inspect --explain\` to see what it resolves to, or pass --force to write a new onsystem.config.json.`);
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
const ask = options.yes ? void 0 : io.prompt;
|
|
120
|
+
const file = { $schema: SCHEMA };
|
|
121
|
+
const notes = [];
|
|
122
|
+
const relative = (abs) => relativePath(root, abs);
|
|
123
|
+
if (configOptions.components?.length) {
|
|
124
|
+
file.components = configOptions.components;
|
|
125
|
+
if (configOptions.tokens?.length) file.tokens = configOptions.tokens;
|
|
126
|
+
if (configOptions.docs?.length) file.docs = configOptions.docs;
|
|
127
|
+
} else if (workspace) notes.push(`This is a monorepo root: ${workspace.projects.length} of ${workspace.projects.length + workspace.others.length} workspace packages have a design system (${workspace.projects.map((p) => p.dir).join(", ") || "none"}). Each keeps zero config; rules and overrides you add here apply to every project without a config of its own.`);
|
|
128
|
+
else if (config.detected) {
|
|
129
|
+
notes.push(`Found the design system through ${config.detected}.`);
|
|
130
|
+
const specifiers = new Set((config.imports ?? []).map((m) => m.specifier));
|
|
131
|
+
const tokens = (await tokenFiles(config)).map(relative);
|
|
132
|
+
if (specifiers.size > 1) notes.push(`"components" is left to zero config: apps import them by ${specifiers.size} specifiers from the package's exports (${[...specifiers].slice(0, 2).join(", ")}, …), which a config cannot spell.`);
|
|
133
|
+
else if (config.components.length > MAX_GLOBS) notes.push(`"components" is left to zero config: it found ${config.components.length} files through the package's exports, which it keeps following as the package changes.`);
|
|
134
|
+
else {
|
|
135
|
+
file.components = config.components;
|
|
136
|
+
if ([...specifiers][0]) file.importPath = [...specifiers][0];
|
|
137
|
+
const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d));
|
|
138
|
+
if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs];
|
|
139
|
+
}
|
|
140
|
+
if (tokens.length) file.tokens = tokens;
|
|
141
|
+
} else {
|
|
142
|
+
if (!ask) {
|
|
143
|
+
io.stderr(`init: zero config found no design system in ${root}. Pass --components (and --tokens) to say where it is, or run init in a terminal to answer a few questions. \`${NAME} inspect --explain\` shows what it looked at.`);
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
io.stdout(`Zero config found no design system in ${root}.`);
|
|
147
|
+
let components = "";
|
|
148
|
+
for (let attempt = 0; attempt < 3 && !components; attempt++) {
|
|
149
|
+
const answer = (await ask("Where are the components? A glob from the root, e.g. src/components/ui/**/*.tsx: ")).trim();
|
|
150
|
+
if (!answer) continue;
|
|
151
|
+
if ((await componentFiles({
|
|
152
|
+
...config,
|
|
153
|
+
components: [answer]
|
|
154
|
+
})).length) components = answer;
|
|
155
|
+
else io.stdout(`No file matches ${answer}.`);
|
|
156
|
+
}
|
|
157
|
+
if (!components) {
|
|
158
|
+
io.stderr("init: no components given; nothing written.");
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
161
|
+
file.components = [components];
|
|
162
|
+
const tokens = (await ask("Where are the tokens? A CSS or DTCG .json file, or leave it empty: ")).trim();
|
|
163
|
+
if (tokens) {
|
|
164
|
+
if (!fs.existsSync(path.resolve(root, tokens)) && !/[*?{]/.test(tokens)) io.stdout(`${tokens} does not exist; it is left out.`);
|
|
165
|
+
else file.tokens = [tokens];
|
|
166
|
+
}
|
|
167
|
+
const importPath = (await ask("How do apps import them? A package name such as @acme/ui, or leave it empty for tsconfig paths: ")).trim();
|
|
168
|
+
if (importPath) file.importPath = importPath;
|
|
169
|
+
}
|
|
170
|
+
const out = path.join(root, "onsystem.config.json");
|
|
171
|
+
fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
|
|
172
|
+
for (const note of notes) io.stdout(note);
|
|
173
|
+
io.stdout(`Wrote ${relativePath(io.cwd, out) || out}.`);
|
|
174
|
+
const checkIo = {
|
|
175
|
+
...io,
|
|
176
|
+
cwd: root
|
|
177
|
+
};
|
|
178
|
+
const dry = await runCheck([
|
|
179
|
+
"check",
|
|
180
|
+
".",
|
|
181
|
+
"--format",
|
|
182
|
+
"json",
|
|
183
|
+
...options.cache ? [] : ["--no-cache"]
|
|
184
|
+
], checkIo);
|
|
185
|
+
let results = [];
|
|
186
|
+
try {
|
|
187
|
+
results = JSON.parse(dry.stdout || "[]");
|
|
188
|
+
} catch {
|
|
189
|
+
io.stderr(`init: the dry run did not finish: ${dry.stderr.split("\n")[0] ?? ""}`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
193
|
+
let errors = 0;
|
|
194
|
+
let warnings = 0;
|
|
195
|
+
for (const result of results) for (const d of result.diagnostics) {
|
|
196
|
+
byRule.set(d.ruleId, (byRule.get(d.ruleId) ?? 0) + 1);
|
|
197
|
+
if (d.severity === "error") errors++;
|
|
198
|
+
else warnings++;
|
|
199
|
+
}
|
|
200
|
+
const flagged = results.filter((r) => r.diagnostics.length).length;
|
|
201
|
+
const total = errors + warnings;
|
|
202
|
+
io.stdout(total ? `Dry run of \`${NAME} check .\`: ${count$1(errors, "error")} and ${count$1(warnings, "warning")} in ${flagged} of ${count$1(results.length, "file")}. By rule: ${[...byRule].sort((a, b) => b[1] - a[1]).map(([rule, n]) => `${rule} ${n.toLocaleString("en-US")}`).join(", ")}.` : `Dry run of \`${NAME} check .\`: no problems in ${count$1(results.length, "file")}.`);
|
|
203
|
+
for (const line of dry.stderr.split("\n").filter(Boolean)) io.stdout(line);
|
|
204
|
+
const hasBaseline = [BASELINE_FILE, LEGACY_BASELINE_FILE].some((name) => fs.existsSync(path.join(root, name)));
|
|
205
|
+
if (total && !hasBaseline) {
|
|
206
|
+
const answer = options.yes ? "y" : ask ? (await ask(`Record these ${total.toLocaleString("en-US")} findings in ${BASELINE_FILE}, so check fails only on new ones? [Y/n] `)).trim().toLowerCase() : "n";
|
|
207
|
+
if (answer === "" || answer.startsWith("y")) {
|
|
208
|
+
const recorded = await runCheck([
|
|
209
|
+
"check",
|
|
210
|
+
".",
|
|
211
|
+
"--update-baseline",
|
|
212
|
+
...options.cache ? [] : ["--no-cache"]
|
|
213
|
+
], checkIo);
|
|
214
|
+
if (recorded.stdout) io.stdout(recorded.stdout);
|
|
215
|
+
} else io.stdout(`No baseline written; \`${NAME} check . --update-baseline\` records one later.`);
|
|
216
|
+
}
|
|
217
|
+
io.stdout(`Next: commit onsystem.config.json${total && !hasBaseline ? ` and ${BASELINE_FILE}` : ""}, and run \`npx ${NAME} check . --format github --require-design-system\` in CI.`);
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
function count$1(n, word) {
|
|
221
|
+
return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/cli.ts
|
|
225
|
+
const DOCS = "https://onsystem.vercel.app/docs";
|
|
226
|
+
const HELP = `${NAME} ${VERSION}
|
|
227
|
+
|
|
228
|
+
Keeps coding agents on your React design system: checks UI code against your
|
|
229
|
+
real components, props, variants and tokens in a Claude Code hook, in CI with
|
|
230
|
+
check, and over MCP for any agent.
|
|
231
|
+
|
|
232
|
+
Usage
|
|
233
|
+
onsystem [serve] [options] Start the MCP server on stdio (default)
|
|
234
|
+
onsystem check <paths...> Lint files, folders or globs with the check_ui rules
|
|
235
|
+
(for CI)
|
|
236
|
+
onsystem inspect Print what was extracted from the project
|
|
237
|
+
onsystem init Write onsystem.config.json from what was found, run a
|
|
238
|
+
dry-run check and offer a baseline
|
|
239
|
+
onsystem help Show this help
|
|
240
|
+
|
|
241
|
+
At a monorepo root with no design system of its own, every command works per
|
|
242
|
+
project: each file is checked against the design system of the nearest folder
|
|
243
|
+
with a config, components.json or package.json, and inspect lists every project.
|
|
244
|
+
|
|
245
|
+
Options
|
|
246
|
+
--root <dir> Project root (default: the config file's directory, or cwd)
|
|
247
|
+
--config <file> Config file (default: onsystem.config.{json,ts,mjs,js})
|
|
248
|
+
--components <glob> Component sources, repeatable (default: found through
|
|
249
|
+
components.json or a workspace package, else
|
|
250
|
+
components/ui/**/*.{tsx,jsx} and src/components/ui/...)
|
|
251
|
+
--tokens <file> Token file (DTCG .json or CSS), repeatable
|
|
252
|
+
--docs <glob> Component docs (Markdown/MDX), repeatable
|
|
253
|
+
--no-cache Ignore the on-disk extraction cache
|
|
254
|
+
--no-watch serve: do not reload when files change
|
|
255
|
+
--format <format> check: pretty | json | github | sarif (default: pretty)
|
|
256
|
+
--max-warnings <n> check: exit 1 when there are more than n warnings
|
|
257
|
+
--update-baseline check: record the current findings as accepted, and exit 0
|
|
258
|
+
--baseline <file> check: baseline file (default: ${BASELINE_FILE} in the root,
|
|
259
|
+
used whenever it exists)
|
|
260
|
+
--ignore-baseline check: report every finding, baseline or not
|
|
261
|
+
--include-design-system
|
|
262
|
+
check: also lint the design system's own component
|
|
263
|
+
files, which are skipped by default
|
|
264
|
+
--include-tests check: also lint tests and stories (*.test.tsx,
|
|
265
|
+
*.spec.tsx, *.stories.tsx) in folders and globs
|
|
266
|
+
--quiet-without-design-system
|
|
267
|
+
check: print nothing and exit 0 when the project has no
|
|
268
|
+
design-system components and no config (tokens alone
|
|
269
|
+
are not a design system), for hooks installed across
|
|
270
|
+
many projects
|
|
271
|
+
--require-design-system
|
|
272
|
+
check: exit 2 when no components or no color tokens
|
|
273
|
+
are found, so CI cannot pass by checking nothing
|
|
274
|
+
--explain inspect: every candidate zero config looked at and why
|
|
275
|
+
it was taken or not, the resolved config, unresolved
|
|
276
|
+
tokens, and components whose props did not resolve
|
|
277
|
+
-y, --yes init: write without asking, and record the baseline
|
|
278
|
+
--force init: replace an existing config file
|
|
279
|
+
-h, --help Show this help
|
|
280
|
+
-v, --version Show the version
|
|
281
|
+
|
|
282
|
+
Examples
|
|
283
|
+
npx -y ${NAME} inspect --explain
|
|
284
|
+
npx -y ${NAME} init
|
|
285
|
+
npx -y ${NAME} check . --format github --require-design-system
|
|
286
|
+
npx -y ${NAME} check . --update-baseline # adopt in an existing codebase
|
|
287
|
+
|
|
288
|
+
Docs: ${DOCS}
|
|
289
|
+
`;
|
|
290
|
+
const defaultIo = {
|
|
291
|
+
cwd: process.cwd(),
|
|
292
|
+
stdout: (text) => process.stdout.write(`${text}\n`),
|
|
293
|
+
stderr: (text) => process.stderr.write(`${text}\n`),
|
|
294
|
+
color: process.stdout.isTTY && !process.env.NO_COLOR,
|
|
295
|
+
prompt: process.stdin.isTTY && process.stdout.isTTY ? askInTerminal : void 0
|
|
296
|
+
};
|
|
297
|
+
/** One question on the terminal, answered with a line. */
|
|
298
|
+
async function askInTerminal(question) {
|
|
299
|
+
const rl = readline.createInterface({
|
|
300
|
+
input: process.stdin,
|
|
301
|
+
output: process.stdout
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
return await rl.question(question);
|
|
305
|
+
} finally {
|
|
306
|
+
rl.close();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Runs the CLI. Resolves to an exit code; `serve` resolves once the server is listening. */
|
|
310
|
+
async function main(argv, io = defaultIo) {
|
|
311
|
+
let parsed;
|
|
312
|
+
try {
|
|
313
|
+
parsed = parseArgs({
|
|
314
|
+
args: argv,
|
|
315
|
+
allowPositionals: true,
|
|
316
|
+
allowNegative: true,
|
|
317
|
+
options: {
|
|
318
|
+
root: { type: "string" },
|
|
319
|
+
config: { type: "string" },
|
|
320
|
+
components: {
|
|
321
|
+
type: "string",
|
|
322
|
+
multiple: true
|
|
323
|
+
},
|
|
324
|
+
tokens: {
|
|
325
|
+
type: "string",
|
|
326
|
+
multiple: true
|
|
327
|
+
},
|
|
328
|
+
docs: {
|
|
329
|
+
type: "string",
|
|
330
|
+
multiple: true
|
|
331
|
+
},
|
|
332
|
+
cache: {
|
|
333
|
+
type: "boolean",
|
|
334
|
+
default: true
|
|
335
|
+
},
|
|
336
|
+
watch: {
|
|
337
|
+
type: "boolean",
|
|
338
|
+
default: true
|
|
339
|
+
},
|
|
340
|
+
format: {
|
|
341
|
+
type: "string",
|
|
342
|
+
default: "pretty"
|
|
343
|
+
},
|
|
344
|
+
"max-warnings": { type: "string" },
|
|
345
|
+
baseline: { type: "string" },
|
|
346
|
+
"update-baseline": {
|
|
347
|
+
type: "boolean",
|
|
348
|
+
default: false
|
|
349
|
+
},
|
|
350
|
+
"ignore-baseline": {
|
|
351
|
+
type: "boolean",
|
|
352
|
+
default: false
|
|
353
|
+
},
|
|
354
|
+
"quiet-without-design-system": {
|
|
355
|
+
type: "boolean",
|
|
356
|
+
default: false
|
|
357
|
+
},
|
|
358
|
+
"include-design-system": { type: "boolean" },
|
|
359
|
+
"include-tests": {
|
|
360
|
+
type: "boolean",
|
|
361
|
+
default: false
|
|
362
|
+
},
|
|
363
|
+
"require-design-system": {
|
|
364
|
+
type: "boolean",
|
|
365
|
+
default: false
|
|
366
|
+
},
|
|
367
|
+
explain: {
|
|
368
|
+
type: "boolean",
|
|
369
|
+
default: false
|
|
370
|
+
},
|
|
371
|
+
yes: {
|
|
372
|
+
type: "boolean",
|
|
373
|
+
short: "y",
|
|
374
|
+
default: false
|
|
375
|
+
},
|
|
376
|
+
force: {
|
|
377
|
+
type: "boolean",
|
|
378
|
+
default: false
|
|
379
|
+
},
|
|
380
|
+
help: {
|
|
381
|
+
type: "boolean",
|
|
382
|
+
short: "h"
|
|
383
|
+
},
|
|
384
|
+
version: {
|
|
385
|
+
type: "boolean",
|
|
386
|
+
short: "v"
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
io.stderr(`${error.message}\n\nRun ${NAME} --help for usage.`);
|
|
392
|
+
return 2;
|
|
393
|
+
}
|
|
394
|
+
const { values, positionals } = parsed;
|
|
395
|
+
if (values.help) {
|
|
396
|
+
io.stdout(HELP);
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
if (values.version) {
|
|
400
|
+
io.stdout(VERSION);
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
403
|
+
const [command = "serve", ...rest] = positionals;
|
|
404
|
+
if (command === "help") {
|
|
405
|
+
io.stdout(HELP);
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
let root = values.root;
|
|
409
|
+
if (root !== void 0 && /\$\{[^}]*\}/.test(root)) {
|
|
410
|
+
io.stderr(`--root "${root}" holds a variable the client did not expand; using the working directory instead${command === "serve" ? ", or the client's roots when it holds no project" : ""}.`);
|
|
411
|
+
root = void 0;
|
|
412
|
+
}
|
|
413
|
+
const configOptions = {
|
|
414
|
+
cwd: io.cwd,
|
|
415
|
+
root,
|
|
416
|
+
config: values.config,
|
|
417
|
+
components: values.components,
|
|
418
|
+
tokens: values.tokens,
|
|
419
|
+
docs: values.docs,
|
|
420
|
+
includeDesignSystem: values["include-design-system"]
|
|
421
|
+
};
|
|
422
|
+
try {
|
|
423
|
+
switch (command) {
|
|
424
|
+
case "serve":
|
|
425
|
+
await serveStdio({
|
|
426
|
+
...configOptions,
|
|
427
|
+
cache: values.cache,
|
|
428
|
+
watch: values.watch,
|
|
429
|
+
logger: stderrLogger
|
|
430
|
+
});
|
|
431
|
+
return 0;
|
|
432
|
+
case "check": return await check(rest, configOptions, values, io);
|
|
433
|
+
case "inspect": return await inspect(configOptions, values, io);
|
|
434
|
+
case "init": return await init(configOptions, {
|
|
435
|
+
yes: values.yes,
|
|
436
|
+
force: values.force,
|
|
437
|
+
cache: values.cache
|
|
438
|
+
}, io, async (args, checkIo) => {
|
|
439
|
+
const out = [];
|
|
440
|
+
const err = [];
|
|
441
|
+
return {
|
|
442
|
+
code: await main(args, {
|
|
443
|
+
cwd: checkIo.cwd,
|
|
444
|
+
color: false,
|
|
445
|
+
stdout: (t) => out.push(t),
|
|
446
|
+
stderr: (t) => err.push(t)
|
|
447
|
+
}),
|
|
448
|
+
stdout: out.join("\n"),
|
|
449
|
+
stderr: err.join("\n")
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
default:
|
|
453
|
+
io.stderr(`Unknown command "${command}". Run ${NAME} --help for usage.`);
|
|
454
|
+
return 2;
|
|
455
|
+
}
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof ConfigError) {
|
|
458
|
+
io.stderr(error.message);
|
|
459
|
+
return 2;
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function check(patterns, configOptions, values, io) {
|
|
465
|
+
if (values["quiet-without-design-system"] && values["require-design-system"]) {
|
|
466
|
+
io.stderr("check: pass --quiet-without-design-system or --require-design-system, not both");
|
|
467
|
+
return 2;
|
|
468
|
+
}
|
|
469
|
+
if (!patterns.length) {
|
|
470
|
+
io.stderr(`check: pass files, folders or globs, e.g. ${NAME} check .`);
|
|
471
|
+
return 2;
|
|
472
|
+
}
|
|
473
|
+
const format = values.format;
|
|
474
|
+
if (![
|
|
475
|
+
"pretty",
|
|
476
|
+
"json",
|
|
477
|
+
"github",
|
|
478
|
+
"sarif"
|
|
479
|
+
].includes(format)) {
|
|
480
|
+
io.stderr(`check: unknown --format "${values.format}" (pretty, json, github, sarif)`);
|
|
481
|
+
return 2;
|
|
482
|
+
}
|
|
483
|
+
const maxWarnings = values["max-warnings"] === void 0 ? void 0 : Number(values["max-warnings"]);
|
|
484
|
+
if (maxWarnings !== void 0 && !Number.isInteger(maxWarnings)) {
|
|
485
|
+
io.stderr("check: --max-warnings expects an integer");
|
|
486
|
+
return 2;
|
|
487
|
+
}
|
|
488
|
+
const target = await loadTarget(configOptions);
|
|
489
|
+
warnDeprecated(target.config.deprecations, format, io);
|
|
490
|
+
const selection = target.workspace ? await selectInWorkspace(target.workspace, patterns, values, format, io) : await selectInProject(target.config, patterns, values, format, io);
|
|
491
|
+
if (typeof selection === "number") return selection;
|
|
492
|
+
const { files, notes } = selection;
|
|
493
|
+
if (!files.length) {
|
|
494
|
+
if (format === "pretty") {
|
|
495
|
+
const hints = [selection.skipped ? `Pass --include-design-system to check ${selection.skipped === 1 ? "it" : "them"}.` : "", selection.testsLeftOut ? "Pass --include-tests to check tests and stories." : ""].filter(Boolean);
|
|
496
|
+
io.stdout(`Nothing to check: ${notes.join("; ")}. ${hints.join(" ")}`.trimEnd());
|
|
497
|
+
} else printNoResults(format, io);
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
500
|
+
const update = values["update-baseline"];
|
|
501
|
+
const baselineFile = values.baseline ? path.resolve(io.cwd, values.baseline) : defaultBaseline(selection.root, values["ignore-baseline"] ? void 0 : (text) => {
|
|
502
|
+
warnDeprecated([text], format, io);
|
|
503
|
+
});
|
|
504
|
+
const baseline = values["ignore-baseline"] ? void 0 : readBaseline(baselineFile);
|
|
505
|
+
if (values.baseline && !update && !values["ignore-baseline"] && !baseline) {
|
|
506
|
+
io.stderr(`check: baseline not found: ${displayPath(io.cwd, baselineFile)}`);
|
|
507
|
+
return 2;
|
|
508
|
+
}
|
|
509
|
+
const disabledFor = (file) => {
|
|
510
|
+
const ds = selection.designSystemOf(file);
|
|
511
|
+
return new Set(Object.entries(rulesFor(ds.config, relativePath(ds.root, file))).filter(([, rule]) => rule.severity === "off").map(([id]) => id));
|
|
512
|
+
};
|
|
513
|
+
const disabledByKey = /* @__PURE__ */ new Map();
|
|
514
|
+
const realRoot = fs.realpathSync.native(selection.root);
|
|
515
|
+
const baselineKey = (file) => relativePath(realRoot, fs.realpathSync.native(file)).normalize("NFC");
|
|
516
|
+
const results = [];
|
|
517
|
+
const checked = /* @__PURE__ */ new Map();
|
|
518
|
+
let baselined = 0;
|
|
519
|
+
let fixed = 0;
|
|
520
|
+
for (const file of files) {
|
|
521
|
+
const code = await fs$1.readFile(file, "utf8");
|
|
522
|
+
const ds = selection.designSystemOf(file);
|
|
523
|
+
let result = ds.check(code, relativePath(ds.root, file));
|
|
524
|
+
const key = baselineKey(file);
|
|
525
|
+
const disabled = disabledFor(file);
|
|
526
|
+
disabledByKey.set(key, disabled);
|
|
527
|
+
if (update) checked.set(key, result.diagnostics);
|
|
528
|
+
if (baseline && !update) {
|
|
529
|
+
const match = applyBaseline(baseline, key, result, disabled);
|
|
530
|
+
result = {
|
|
531
|
+
...match.result,
|
|
532
|
+
baselined: match.baselined
|
|
533
|
+
};
|
|
534
|
+
baselined += match.baselined;
|
|
535
|
+
fixed += match.fixed;
|
|
536
|
+
}
|
|
537
|
+
results.push({
|
|
538
|
+
...result,
|
|
539
|
+
file: relativePath(io.cwd, file)
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
const elsewhere = results.filter((r) => r.skipped).length;
|
|
543
|
+
if (elsewhere) notes.push(`${plural(elsewhere, "image or email file")} not checked`);
|
|
544
|
+
const unchecked = uncheckedNotice(results);
|
|
545
|
+
if (update) {
|
|
546
|
+
const next = updateBaseline(baseline, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
|
|
547
|
+
writeBaseline(baselineFile, next);
|
|
548
|
+
const counts = countBaseline(next);
|
|
549
|
+
io.stdout(`Baseline: ${count(counts.findings, "finding")} in ${count(counts.files, "file")} → ${displayPath(io.cwd, baselineFile)}`);
|
|
550
|
+
for (const text of selection.notices) io.stderr(text);
|
|
551
|
+
if (unchecked) io.stderr(unchecked);
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
|
|
555
|
+
color: io.color,
|
|
556
|
+
uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
|
|
557
|
+
baselined: baseline ? baselined : void 0,
|
|
558
|
+
notes,
|
|
559
|
+
fixedHint: fixed ? `${count(fixed, "baseline finding")} no longer ${fixed === 1 ? "occurs" : "occur"}: run \`check ${patterns.map(shellQuote).join(" ")} --update-baseline\` to drop ${fixed === 1 ? "it" : "them"}.` : void 0
|
|
560
|
+
});
|
|
561
|
+
if (output) io.stdout(output);
|
|
562
|
+
for (const text of [...selection.notices, unchecked]) {
|
|
563
|
+
if (!text) continue;
|
|
564
|
+
if (format === "pretty") io.stdout(text);
|
|
565
|
+
else if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
|
|
566
|
+
else io.stderr(text);
|
|
567
|
+
}
|
|
568
|
+
const errors = results.reduce((n, r) => n + r.errorCount, 0);
|
|
569
|
+
const warnings = results.reduce((n, r) => n + r.warningCount, 0);
|
|
570
|
+
if (errors > 0) return 1;
|
|
571
|
+
if (maxWarnings !== void 0 && warnings > maxWarnings) {
|
|
572
|
+
if (format === "pretty") io.stderr(`Too many warnings (${warnings}, max ${maxWarnings}).`);
|
|
573
|
+
return 1;
|
|
574
|
+
}
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
577
|
+
/** What `json` and `sarif` print when there is nothing to report on: `[]`, or a log without results. */
|
|
578
|
+
function printNoResults(format, io) {
|
|
579
|
+
if (format === "json") io.stdout("[]");
|
|
580
|
+
else if (format === "sarif") io.stdout(formatDiagnostics([], "sarif"));
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Maps a path relative to the working directory to one relative to the repository root, which
|
|
584
|
+
* GitHub resolves workflow command annotations (`--format github`) and code scanning locations
|
|
585
|
+
* (`--format sarif`) against, so `check` run from `apps/web` still reports
|
|
586
|
+
* `apps/web/app/page.tsx`. The root is GITHUB_WORKSPACE or the git top-level, whichever is the
|
|
587
|
+
* innermost folder holding the file: the git top-level outside Actions, GITHUB_WORKSPACE where
|
|
588
|
+
* git is missing or refuses the checkout, and the repository checked out in a folder of the
|
|
589
|
+
* workspace (`actions/checkout` with `path:`). A file under neither keeps its path relative to
|
|
590
|
+
* the working directory.
|
|
591
|
+
*/
|
|
592
|
+
function repositoryPath(cwd, env) {
|
|
593
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
594
|
+
cwd,
|
|
595
|
+
encoding: "utf8",
|
|
596
|
+
windowsHide: true
|
|
597
|
+
});
|
|
598
|
+
const roots = [result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim() : "", env.GITHUB_WORKSPACE ?? ""].filter(Boolean).map((dir) => realPath(path.resolve(cwd, dir))).sort((a, b) => b.length - a.length);
|
|
599
|
+
if (!roots.length) return toPosix;
|
|
600
|
+
return (file) => {
|
|
601
|
+
const absolute = realPath(path.resolve(cwd, file));
|
|
602
|
+
const root = roots.find((dir) => isInside(dir, absolute));
|
|
603
|
+
return root ? relativePath(root, absolute) : toPosix(file);
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/** Splits the patterns into files named outright and globs (a folder: every TSX/JSX file under it). */
|
|
607
|
+
function expandPatterns(patterns, cwd) {
|
|
608
|
+
const named = [];
|
|
609
|
+
const globs = [];
|
|
610
|
+
for (const pattern of patterns) {
|
|
611
|
+
const stat = fs.statSync(path.resolve(cwd, pattern), { throwIfNoEntry: false });
|
|
612
|
+
if (stat && !stat.isDirectory()) {
|
|
613
|
+
named.push(path.resolve(cwd, pattern));
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const literal = stat ? escapePath(toPosix(path.join(pattern, "."))) : toPosix(pattern);
|
|
617
|
+
globs.push(!stat ? literal : literal === "." ? "**/*.{tsx,jsx}" : `${literal}/**/*.{tsx,jsx}`);
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
named,
|
|
621
|
+
globs
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/** One project: the root's config and design system for every file. */
|
|
625
|
+
async function selectInProject(config, patterns, values, format, io) {
|
|
626
|
+
const ds = await loadDesignSystem(config, {
|
|
627
|
+
cache: values.cache,
|
|
628
|
+
logger: silentLogger
|
|
629
|
+
});
|
|
630
|
+
const { named, globs } = expandPatterns(patterns, io.cwd);
|
|
631
|
+
const options = {
|
|
632
|
+
cwd: io.cwd,
|
|
633
|
+
absolute: true,
|
|
634
|
+
expandDirectories: false
|
|
635
|
+
};
|
|
636
|
+
const all = globs.length ? await findFiles(globs, {
|
|
637
|
+
...options,
|
|
638
|
+
ignore: ["**/node_modules/**"]
|
|
639
|
+
}) : [];
|
|
640
|
+
const excludes = config.exclude.filter((e) => !(values["include-tests"] && isTestPattern(e))).map((e) => excludeFromCwd(e, config.root, io.cwd)).filter((e) => e !== void 0);
|
|
641
|
+
const kept = globs.length ? await findFiles(globs, {
|
|
642
|
+
...options,
|
|
643
|
+
ignore: unique(["**/node_modules/**", ...excludes])
|
|
644
|
+
}) : [];
|
|
645
|
+
const ignored = gitIgnored(io.cwd, kept);
|
|
646
|
+
const keptSet = new Set(kept);
|
|
647
|
+
const leftOut = all.filter((file) => !keptSet.has(file));
|
|
648
|
+
const left = {
|
|
649
|
+
tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
|
|
650
|
+
excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
|
|
651
|
+
gitignored: ignored.size
|
|
652
|
+
};
|
|
653
|
+
let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
654
|
+
if (!files.length && !all.length) {
|
|
655
|
+
io.stderr(`check: no files match ${patterns.join(" ")}`);
|
|
656
|
+
return 2;
|
|
657
|
+
}
|
|
658
|
+
if (values["quiet-without-design-system"] && !config.configFile && !ds.components.length) {
|
|
659
|
+
printNoResults(format, io);
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
const notice = ds.notice();
|
|
663
|
+
if (notice && values["require-design-system"]) {
|
|
664
|
+
io.stderr(`check --require-design-system: ${notice}`);
|
|
665
|
+
return 2;
|
|
666
|
+
}
|
|
667
|
+
let skipped = 0;
|
|
668
|
+
if (!config.includeDesignSystem) {
|
|
669
|
+
const own = new Set((await componentFiles(config)).map(realPath));
|
|
670
|
+
const rest = files.filter((file) => !own.has(realPath(file)));
|
|
671
|
+
skipped = files.length - rest.length;
|
|
672
|
+
files = rest;
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
files,
|
|
676
|
+
designSystemOf: () => ds,
|
|
677
|
+
root: ds.root,
|
|
678
|
+
notes: scopeNotes(skipped, left),
|
|
679
|
+
notices: notice ? [notice] : [],
|
|
680
|
+
skipped,
|
|
681
|
+
testsLeftOut: left.tests
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* A workspace root: each file is checked by the design system of its own project, the
|
|
686
|
+
* nearest folder with a config, a components.json or a package.json, with that project's
|
|
687
|
+
* config, exactly as `check` from that folder (and the Claude Code hook) would check it.
|
|
688
|
+
*/
|
|
689
|
+
async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
690
|
+
const { named, globs } = expandPatterns(patterns, io.cwd);
|
|
691
|
+
const all = globs.length ? await findFiles(globs, {
|
|
692
|
+
cwd: io.cwd,
|
|
693
|
+
absolute: true,
|
|
694
|
+
expandDirectories: false,
|
|
695
|
+
ignore: ["**/node_modules/**"]
|
|
696
|
+
}) : [];
|
|
697
|
+
const projectOf = /* @__PURE__ */ new Map();
|
|
698
|
+
for (const file of unique([...named, ...all])) projectOf.set(file, await workspace.projectFor(file));
|
|
699
|
+
const project = (file) => {
|
|
700
|
+
const found = projectOf.get(file);
|
|
701
|
+
if (!found) throw new Error(`No project for ${file}`);
|
|
702
|
+
return found;
|
|
703
|
+
};
|
|
704
|
+
const designSystems = /* @__PURE__ */ new Map();
|
|
705
|
+
const load = (p) => {
|
|
706
|
+
let ds = designSystems.get(p.root);
|
|
707
|
+
if (!ds) {
|
|
708
|
+
ds = loadDesignSystem(p.config, {
|
|
709
|
+
cache: values.cache,
|
|
710
|
+
logger: silentLogger
|
|
711
|
+
});
|
|
712
|
+
designSystems.set(p.root, ds);
|
|
713
|
+
}
|
|
714
|
+
return ds;
|
|
715
|
+
};
|
|
716
|
+
const leftOut = all.filter((file) => {
|
|
717
|
+
const { root, config } = project(file);
|
|
718
|
+
const relative = relativePath(root, file);
|
|
719
|
+
return config.exclude.some((e) => !(values["include-tests"] && isTestPattern(e)) && matchesGlob(relative, e));
|
|
720
|
+
});
|
|
721
|
+
const leftOutSet = new Set(leftOut);
|
|
722
|
+
const kept = all.filter((file) => !leftOutSet.has(file));
|
|
723
|
+
const ignored = gitIgnored(io.cwd, kept);
|
|
724
|
+
const left = {
|
|
725
|
+
tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
|
|
726
|
+
excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
|
|
727
|
+
gitignored: ignored.size
|
|
728
|
+
};
|
|
729
|
+
let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
730
|
+
if (!files.length && !all.length) {
|
|
731
|
+
io.stderr(`check: no files match ${patterns.join(" ")}`);
|
|
732
|
+
return 2;
|
|
733
|
+
}
|
|
734
|
+
const loaded = /* @__PURE__ */ new Map();
|
|
735
|
+
for (const p of unique(files.map(project))) loaded.set(p.root, await load(p));
|
|
736
|
+
const designSystemOf = (file) => {
|
|
737
|
+
const ds = loaded.get(project(file).root);
|
|
738
|
+
if (!ds) throw new Error(`No design system loaded for ${file}`);
|
|
739
|
+
return ds;
|
|
740
|
+
};
|
|
741
|
+
if (values["quiet-without-design-system"]) {
|
|
742
|
+
files = files.filter((file) => Boolean(project(file).config.configFile) || designSystemOf(file).components.length > 0);
|
|
743
|
+
if (!files.length) {
|
|
744
|
+
printNoResults(format, io);
|
|
745
|
+
return 0;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (values["require-design-system"]) {
|
|
749
|
+
let complete = false;
|
|
750
|
+
for (const p of workspace.projects) if (!(await load(p)).notice()) {
|
|
751
|
+
complete = true;
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
if (!complete) {
|
|
755
|
+
const packages = workspace.projects.length + workspace.others.length;
|
|
756
|
+
io.stderr(`check --require-design-system: no workspace package has a design system with components and color tokens (${plural(packages, "package")} looked at). See ${DOCS}/troubleshooting`);
|
|
757
|
+
return 2;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const own = /* @__PURE__ */ new Map();
|
|
761
|
+
for (const p of unique(files.map(project))) {
|
|
762
|
+
if (p.config.includeDesignSystem) continue;
|
|
763
|
+
own.set(p.root, new Set((await componentFiles(p.config)).map(realPath)));
|
|
764
|
+
}
|
|
765
|
+
const rest = files.filter((file) => !own.get(project(file).root)?.has(realPath(file)));
|
|
766
|
+
const skipped = files.length - rest.length;
|
|
767
|
+
files = rest;
|
|
768
|
+
const gaps = /* @__PURE__ */ new Map();
|
|
769
|
+
for (const p of unique(files.map(project))) {
|
|
770
|
+
const ds = loaded.get(p.root);
|
|
771
|
+
const notice = ds?.notice();
|
|
772
|
+
if (!ds || !notice) continue;
|
|
773
|
+
const gap = gaps.get(notice) ?? {
|
|
774
|
+
ds,
|
|
775
|
+
dirs: []
|
|
776
|
+
};
|
|
777
|
+
gap.dirs.push(p.dir);
|
|
778
|
+
gaps.set(notice, gap);
|
|
779
|
+
}
|
|
780
|
+
const notices = [...gaps.values()].map(({ ds, dirs }) => ds.notice(listOf(dirs)) ?? "");
|
|
781
|
+
const used = unique(files.map((file) => project(file).root)).length;
|
|
782
|
+
return {
|
|
783
|
+
files,
|
|
784
|
+
designSystemOf,
|
|
785
|
+
root: workspace.root,
|
|
786
|
+
notes: [...used > 1 ? [plural(used, "project")] : [], ...scopeNotes(skipped, left)],
|
|
787
|
+
notices: notices.filter(Boolean),
|
|
788
|
+
skipped,
|
|
789
|
+
testsLeftOut: left.tests
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/** `apps/web`, `apps/web and packages/ui`, `a, b, c and 4 more`. */
|
|
793
|
+
function listOf(items, max = 3) {
|
|
794
|
+
if (items.length <= 1) return items[0] ?? "";
|
|
795
|
+
if (items.length <= max) return `${items.slice(0, -1).join(", ")} and ${items.at(-1) ?? ""}`;
|
|
796
|
+
return `${items.slice(0, max).join(", ")} and ${items.length - max} more`;
|
|
797
|
+
}
|
|
798
|
+
async function inspect(configOptions, values, io) {
|
|
799
|
+
const target = await loadTarget(configOptions);
|
|
800
|
+
const { config } = target;
|
|
801
|
+
warnDeprecated(config.deprecations, "pretty", io);
|
|
802
|
+
if (target.workspace) return inspectWorkspace(target.workspace, values, io);
|
|
803
|
+
const ds = await loadDesignSystem(config, {
|
|
804
|
+
cache: values.cache,
|
|
805
|
+
logger: silentLogger
|
|
806
|
+
});
|
|
807
|
+
const { model } = ds;
|
|
808
|
+
if (values.format === "json") {
|
|
809
|
+
io.stdout(JSON.stringify(model, null, 2));
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
812
|
+
const lines = [
|
|
813
|
+
`root ${model.root}`,
|
|
814
|
+
`config ${config.configFile ? displayPath(io.cwd, config.configFile) : "(defaults)"}`,
|
|
815
|
+
...config.detected ? [`detected ${config.detected}`] : [],
|
|
816
|
+
`files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`,
|
|
817
|
+
`time ${model.stats.durationMs}ms${model.stats.fromCache ? " (cache)" : ""}`,
|
|
818
|
+
"",
|
|
819
|
+
`Components (${ds.roots().length} + ${model.components.length - ds.roots().length} parts)`
|
|
820
|
+
];
|
|
821
|
+
for (const c of ds.roots()) {
|
|
822
|
+
const variants = c.variants.map((v) => `${v.name}[${v.values.length}]`).join(" ");
|
|
823
|
+
const meta = [
|
|
824
|
+
c.element ? `<${c.element}>` : "",
|
|
825
|
+
plural(c.props.length, "prop"),
|
|
826
|
+
variants,
|
|
827
|
+
c.subcomponents.length ? plural(c.subcomponents.length, "part") : "",
|
|
828
|
+
c.docs ? "docs" : "",
|
|
829
|
+
plural(c.examples.length, "example")
|
|
830
|
+
].filter(Boolean);
|
|
831
|
+
lines.push(` ${c.name.padEnd(18)} ${meta.join(" · ")}`);
|
|
832
|
+
}
|
|
833
|
+
lines.push("", `Tokens (${model.tokens.length})`);
|
|
834
|
+
for (const [category, n] of tokenCounts(model)) lines.push(` ${category.padEnd(18)} ${n}`);
|
|
835
|
+
if (model.warnings.length) {
|
|
836
|
+
lines.push("", "Warnings");
|
|
837
|
+
for (const warning of model.warnings) lines.push(` ${warning}`);
|
|
838
|
+
}
|
|
839
|
+
if (values.explain) lines.push("", ...explainProject(ds, io.cwd));
|
|
840
|
+
io.stdout(lines.join("\n"));
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
function tokenCounts(model) {
|
|
844
|
+
const counts = /* @__PURE__ */ new Map();
|
|
845
|
+
for (const t of model.tokens) counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
|
|
846
|
+
return counts;
|
|
847
|
+
}
|
|
848
|
+
/** Every project of a workspace root, with what was found in each, and the packages without one. */
|
|
849
|
+
async function inspectWorkspace(workspace, values, io) {
|
|
850
|
+
const loaded = [];
|
|
851
|
+
for (const project of workspace.projects) loaded.push({
|
|
852
|
+
project,
|
|
853
|
+
ds: await loadDesignSystem(project.config, {
|
|
854
|
+
cache: values.cache,
|
|
855
|
+
logger: silentLogger
|
|
856
|
+
})
|
|
857
|
+
});
|
|
858
|
+
if (values.format === "json") {
|
|
859
|
+
io.stdout(JSON.stringify({
|
|
860
|
+
root: workspace.root,
|
|
861
|
+
workspace: workspace.source,
|
|
862
|
+
projects: loaded.map(({ project, ds }) => ({
|
|
863
|
+
dir: project.dir,
|
|
864
|
+
...project.name ? { name: project.name } : {},
|
|
865
|
+
...project.config.configFile ? { config: relativePath(workspace.root, project.config.configFile) } : {},
|
|
866
|
+
...project.config.detected ? { detected: project.config.detected } : {},
|
|
867
|
+
model: ds.model
|
|
868
|
+
})),
|
|
869
|
+
others: workspace.others
|
|
870
|
+
}, null, 2));
|
|
871
|
+
return 0;
|
|
872
|
+
}
|
|
873
|
+
const packages = workspace.projects.length + workspace.others.length;
|
|
874
|
+
const lines = [
|
|
875
|
+
`root ${workspace.root}`,
|
|
876
|
+
`workspace ${plural(packages, "package")} (${workspace.source}), ${plural(workspace.projects.length, "project")} with a design system`,
|
|
877
|
+
`config ${workspace.configFile ? displayPath(io.cwd, workspace.configFile) : "(defaults)"}`
|
|
878
|
+
];
|
|
879
|
+
for (const { project, ds } of loaded) {
|
|
880
|
+
const { model } = ds;
|
|
881
|
+
const roots = ds.roots();
|
|
882
|
+
const tokens = [...tokenCounts(model)].map(([category, n]) => `${category} ${n}`);
|
|
883
|
+
const own = project.config.configFile && project.config.configFile !== workspace.configFile ? displayPath(workspace.root, project.config.configFile) : void 0;
|
|
884
|
+
lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...own ? [` config ${own}`] : [], ...project.config.detected ? [` detected ${project.config.detected}`] : [], ` files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`, ` components ${roots.length} + ${model.components.length - roots.length} parts${roots.length ? `: ${listNames(roots.map((c) => c.name))}` : ""}`, ` tokens ${tokens.length ? tokens.join(", ") : "none"}`, ...model.warnings.length ? [` warnings ${model.warnings.length}: ${model.warnings[0] ?? ""}`] : []);
|
|
885
|
+
}
|
|
886
|
+
if (!loaded.length) lines.push("", `No workspace package has a design system that zero config finds. See ${DOCS}/troubleshooting`);
|
|
887
|
+
if (workspace.others.length) lines.push("", `Without a design system (${workspace.others.length}): ${listNames(workspace.others)}`);
|
|
888
|
+
lines.push("", `Each file is checked by its project's design system. For one project's components, tokens and warnings: ${NAME} inspect --root <folder>`);
|
|
889
|
+
if (values.explain) lines.push("", ...explainWorkspace(workspace, loaded, io.cwd));
|
|
890
|
+
io.stdout(lines.join("\n"));
|
|
891
|
+
return 0;
|
|
892
|
+
}
|
|
893
|
+
/** Why the root is read as a monorepo, then each project's explanation. */
|
|
894
|
+
function explainWorkspace(workspace, loaded, cwd) {
|
|
895
|
+
const { rootConfig } = workspace;
|
|
896
|
+
const steps = [];
|
|
897
|
+
detectProject(rootConfig.root, rootConfig.tsconfig, steps);
|
|
898
|
+
const lines = [
|
|
899
|
+
`Why this is read as a monorepo root: it declares workspace packages (${workspace.source}), no config there sets components, and zero config finds no design system in the root itself:`,
|
|
900
|
+
...steps.map((s) => ` ${(s.accepted ? "found" : "no").padEnd(6)} ${s.candidate}: ${s.reason}`),
|
|
901
|
+
` no ${rootConfig.components.join(", ")}: no file matches`
|
|
902
|
+
];
|
|
903
|
+
for (const { project, ds } of loaded) lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...explainProject(ds, cwd, " "));
|
|
904
|
+
return lines;
|
|
905
|
+
}
|
|
906
|
+
/** `A, B, C` up to a dozen names, then `and 40 more`. */
|
|
907
|
+
function listNames(names, max = 12) {
|
|
908
|
+
const shown = names.slice(0, max).join(", ");
|
|
909
|
+
return names.length > max ? `${shown} and ${names.length - max} more` : shown;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Says once what a file's old name means: on stderr, or as a workflow warning with
|
|
913
|
+
* `--format github` so it shows on the run. Never on stdout otherwise, which JSON output owns.
|
|
914
|
+
*/
|
|
915
|
+
function warnDeprecated(messages, format, io) {
|
|
916
|
+
for (const text of messages ?? []) if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
|
|
917
|
+
else io.stderr(text);
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* The baseline in the root: `onsystem.baseline.json`, or one still under its name from
|
|
921
|
+
* before the rename, which is read (and updated) where it is, with a notice to rename it.
|
|
922
|
+
*/
|
|
923
|
+
function defaultBaseline(root, warn) {
|
|
924
|
+
const current = path.join(root, BASELINE_FILE);
|
|
925
|
+
const legacy = path.join(root, LEGACY_BASELINE_FILE);
|
|
926
|
+
if (!fs.existsSync(legacy)) return current;
|
|
927
|
+
if (fs.existsSync(current)) {
|
|
928
|
+
warn?.(`${LEGACY_BASELINE_FILE} is ignored: ${BASELINE_FILE} is read instead. Delete ${LEGACY_BASELINE_FILE}.`);
|
|
929
|
+
return current;
|
|
930
|
+
}
|
|
931
|
+
warn?.(`${LEGACY_BASELINE_FILE} is the baseline's name from before design-system-mcp became onsystem. Rename it to ${BASELINE_FILE} (git mv ${LEGACY_BASELINE_FILE} ${BASELINE_FILE}); the old name still works for now.`);
|
|
932
|
+
return legacy;
|
|
933
|
+
}
|
|
934
|
+
/** Tests and stories, which `check` leaves out of folders and globs unless asked. */
|
|
935
|
+
const TEST_FILE = /\.(?:test|spec|stories)\.[cm]?[jt]sx?$|[\\/]__(?:tests|stories|mocks)__[\\/]/;
|
|
936
|
+
/** The default patterns `--include-tests` lifts. */
|
|
937
|
+
function isTestPattern(pattern) {
|
|
938
|
+
return pattern === "**/*.{test,spec,stories}.{ts,tsx,js,jsx}" || pattern === "**/__{tests,stories,mocks}__/**";
|
|
939
|
+
}
|
|
940
|
+
/** What a run left out, for the summary line: `5 design-system files skipped`. */
|
|
941
|
+
function scopeNotes(skipped, left) {
|
|
942
|
+
return [
|
|
943
|
+
skipped ? `${plural(skipped, "design-system file")} skipped` : "",
|
|
944
|
+
left.tests ? `${left.tests.toLocaleString("en-US")} ${left.tests === 1 ? "test or story" : "tests and stories"} left out` : "",
|
|
945
|
+
left.excluded ? `${plural(left.excluded, "excluded file")} left out` : "",
|
|
946
|
+
left.gitignored ? `${plural(left.gitignored, "file")} git ignores left out` : ""
|
|
947
|
+
].filter(Boolean);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* A config `exclude` pattern, relative to the root, as a pattern relative to the
|
|
951
|
+
* working directory the globs run from; undefined when it only covers files
|
|
952
|
+
* outside that directory. `**\/` patterns hold anywhere.
|
|
953
|
+
*/
|
|
954
|
+
function excludeFromCwd(pattern, root, cwd) {
|
|
955
|
+
if (pattern.startsWith("**/")) return pattern;
|
|
956
|
+
const fromRoot = toPosix(path.relative(root, cwd));
|
|
957
|
+
if (!fromRoot) return pattern;
|
|
958
|
+
if (fromRoot.startsWith("..")) return toPosix(path.join(path.relative(cwd, root), pattern));
|
|
959
|
+
return pattern.startsWith(`${fromRoot}/`) ? pattern.slice(fromRoot.length + 1) : void 0;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Files a glob matches, as Node spells absolute paths (with backslashes on Windows, where
|
|
963
|
+
* tinyglobby gives forward slashes), so they compare equal to `path.resolve` and git's.
|
|
964
|
+
*/
|
|
965
|
+
async function findFiles(globs, options) {
|
|
966
|
+
return (await glob(globs, options)).map((file) => path.resolve(file));
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* The files git ignores, through `git check-ignore`: nested .gitignore files,
|
|
970
|
+
* `.git/info/exclude` and tracked-file exceptions, exactly as git reads them.
|
|
971
|
+
* Outside a repository, or without git, nothing is ignored.
|
|
972
|
+
*/
|
|
973
|
+
function gitIgnored(cwd, files) {
|
|
974
|
+
if (!files.length) return /* @__PURE__ */ new Set();
|
|
975
|
+
const result = spawnSync("git", [
|
|
976
|
+
"check-ignore",
|
|
977
|
+
"-z",
|
|
978
|
+
"--stdin"
|
|
979
|
+
], {
|
|
980
|
+
cwd,
|
|
981
|
+
input: files.join("\0"),
|
|
982
|
+
encoding: "utf8",
|
|
983
|
+
maxBuffer: 268435456,
|
|
984
|
+
windowsHide: true
|
|
985
|
+
});
|
|
986
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return /* @__PURE__ */ new Set();
|
|
987
|
+
return new Set(result.stdout.split("\0").filter(Boolean).map((file) => path.resolve(cwd, file)));
|
|
988
|
+
}
|
|
989
|
+
/** `1,307 findings`. */
|
|
990
|
+
function count(n, word) {
|
|
991
|
+
return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
|
|
992
|
+
}
|
|
993
|
+
/** Quotes a pattern for the hint when the shell would expand or split it. */
|
|
994
|
+
function shellQuote(pattern) {
|
|
995
|
+
return /^[\w./@-]+$/.test(pattern) ? pattern : `"${pattern.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
996
|
+
}
|
|
997
|
+
/** The file's real path, so a linked root or a differently spelled path matches. */
|
|
998
|
+
function realPath(file) {
|
|
999
|
+
try {
|
|
1000
|
+
return fs.realpathSync.native(file);
|
|
1001
|
+
} catch {
|
|
1002
|
+
return file;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/** Relative to cwd when the file is below it, absolute otherwise. */
|
|
1006
|
+
function displayPath(cwd, file) {
|
|
1007
|
+
const rel = path.relative(cwd, file);
|
|
1008
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : file;
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/bin.ts
|
|
1012
|
+
main(process.argv.slice(2)).then((code) => {
|
|
1013
|
+
process.exitCode = code;
|
|
1014
|
+
}, (error) => {
|
|
1015
|
+
process.stderr.write(`${error.stack ?? String(error)}\n`);
|
|
1016
|
+
process.exitCode = 1;
|
|
1017
|
+
});
|
|
1018
|
+
//#endregion
|
|
1019
|
+
export {};
|