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