onsystem 0.5.0 → 0.7.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 CHANGED
@@ -1,109 +1,31 @@
1
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";
2
+ import { C as pruneBaseline, Ct as relativePath, D as updateBaseline, E as unrecordedRules, H as CONFIG_FILES, J as LEGACY_CONFIG_FILES, N as toDesignMd, O as writeBaseline, P as toDtcg, S as countBaseline, St as matchesGlob, T as rulesRun, U as ConfigError, W as DEFAULT_DOCS, Y as RULE_IDS, _ as NAME, b as LEGACY_BASELINE_FILE, bt as unique, c as silentLogger, ct as PACKAGE_FIELD, dt as detectProject, g as formatDiagnostics, h as escapeData, i as componentFiles, l as stderrLogger, m as uncheckedNotice, o as loadDesignSystem, ot as libraryTitle, pt as isDesignSystemName, s as tokenFiles, st as notReadYet, tt as rulesFor, ut as declaredConfig, v as VERSION, vt as plural, w as readBaseline, wt as toPosix, x as applyBaseline, xt as isInside, y as BASELINE_FILE } from "./design-system-C4bhqLLw.js";
3
+ import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-BojAQ6JP.js";
4
+ import { r as loadTarget } from "./workspace-Z0tZf_Dv.js";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { escapePath, glob } from "tinyglobby";
8
- import fs$1 from "node:fs/promises";
8
+ import fsp from "node:fs/promises";
9
9
  import { spawnSync } from "node:child_process";
10
10
  import readline from "node:readline/promises";
11
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
12
  //#region src/init.ts
100
13
  const SCHEMA = "https://unpkg.com/onsystem/schema.json";
101
14
  /** More detected globs than this are files, not folders: zero config keeps them. */
102
15
  const MAX_GLOBS = 6;
16
+ /** What a design-system package's apps are told about it, next to its config. */
17
+ const GUIDE_FILE = "ONSYSTEM.md";
103
18
  /**
104
19
  * Writes `onsystem.config.json` in the root from what zero config finds there, or from
105
20
  * the answers to up to three questions when it finds nothing, then checks the project
106
21
  * once without failing and offers to record what it found as the baseline.
22
+ *
23
+ * In a design-system package (one zero config reads through its own `exports`, or named like
24
+ * one, as apps look for it), it also declares the config in package.json
25
+ * (`"onsystem": "./onsystem.config.json"`, and in `files` when the package lists them), so
26
+ * every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those
27
+ * apps' teams how to add the agent tools and the CI check. In an app that takes on such a
28
+ * package's config, it writes no config of its own unless --force says to.
107
29
  */
108
30
  async function init(configOptions, options, io, runCheck) {
109
31
  const { config, workspace } = await loadTarget({
@@ -111,16 +33,24 @@ async function init(configOptions, options, io, runCheck) {
111
33
  config: void 0
112
34
  });
113
35
  const root = config.root;
114
- const existing = [...CONFIG_FILES, ...LEGACY_CONFIG_FILES].map((name) => path.join(root, name)).find((file) => fs.existsSync(file));
36
+ const declared = declaredConfig(root);
37
+ const existing = [...CONFIG_FILES, ...LEGACY_CONFIG_FILES].map((name) => path.join(root, name)).find((file) => fs.existsSync(file)) ?? (declared && "file" in declared ? declared.file : void 0);
38
+ const manifest = readManifest(root);
39
+ const name = typeof manifest?.json.name === "string" ? manifest.json.name : void 0;
40
+ const designSystem = !workspace && !config.designSystemPackage && name !== void 0 && (config.detected?.startsWith("package.json exports of") === true || isDesignSystemName(name));
115
41
  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.`);
42
+ const share = designSystem && !declared && existing.endsWith(".json") ? ` To share it with the apps that depend on ${name}, add "${PACKAGE_FIELD}": "./${path.basename(existing)}" to package.json, and to "files" when it lists them (https://onsystem.vercel.app/docs/design-system-packages).` : "";
43
+ 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.${share}`);
117
44
  return 2;
118
45
  }
119
46
  const ask = options.yes ? void 0 : io.prompt;
120
47
  const file = { $schema: SCHEMA };
121
48
  const notes = [];
122
49
  const relative = (abs) => relativePath(root, abs);
123
- if (configOptions.components?.length) {
50
+ const inherited = options.force ? void 0 : config.designSystemPackage;
51
+ const replaced = options.force ? config.designSystemPackage : void 0;
52
+ if (inherited) notes.push(`This project takes on the config ${inherited.name} declares in its package.json (${relativePath(io.cwd, inherited.config) || inherited.config}), so it needs none of its own; nothing was written. Pass --force to write one, which is read instead.`);
53
+ else if (configOptions.components?.length) {
124
54
  file.components = configOptions.components;
125
55
  if (configOptions.tokens?.length) file.tokens = configOptions.tokens;
126
56
  if (configOptions.docs?.length) file.docs = configOptions.docs;
@@ -133,11 +63,24 @@ async function init(configOptions, options, io, runCheck) {
133
63
  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
64
  else {
135
65
  file.components = config.components;
136
- if ([...specifiers][0]) file.importPath = [...specifiers][0];
66
+ const [specifier] = specifiers;
67
+ if (specifier && !specifier.includes("*")) file.importPath = specifier;
137
68
  const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d));
138
69
  if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs];
139
70
  }
140
71
  if (tokens.length) file.tokens = tokens;
72
+ } else if ((await componentFiles(config)).length) {
73
+ const declared = replaced && config.configured;
74
+ const matching = [];
75
+ for (const glob of config.components) if ((await componentFiles({
76
+ ...config,
77
+ components: [glob]
78
+ })).length) matching.push(glob);
79
+ notes.push(declared ? `Found the design system through the config ${replaced.name} declares: ${matching.join(", ")}.` : `Found the design system in the default folders: ${matching.join(", ")}.`);
80
+ file.components = matching;
81
+ if (declared && config.importPath) file.importPath = config.importPath;
82
+ const tokens = (await tokenFiles(config)).map(relative);
83
+ if (tokens.length) file.tokens = tokens;
141
84
  } else {
142
85
  if (!ask) {
143
86
  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.`);
@@ -167,10 +110,27 @@ async function init(configOptions, options, io, runCheck) {
167
110
  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
111
  if (importPath) file.importPath = importPath;
169
112
  }
170
- const out = path.join(root, "onsystem.config.json");
171
- fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
113
+ const written = [];
114
+ if (!inherited) {
115
+ const out = path.join(root, CONFIG_FILE);
116
+ fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
117
+ written.push(CONFIG_FILE);
118
+ }
119
+ if (replaced) {
120
+ const own = (await loadTarget({
121
+ ...configOptions,
122
+ config: void 0
123
+ })).config;
124
+ notes.push(await replacedNote(replaced, config, own, io.cwd));
125
+ }
126
+ let registry;
127
+ if (designSystem && manifest && name) {
128
+ written.push(...declarePackage(root, manifest, name, options.force, notes));
129
+ registry = await unshipped(config, manifest.json);
130
+ if (registry) notes.push(...registry.notes);
131
+ }
172
132
  for (const note of notes) io.stdout(note);
173
- io.stdout(`Wrote ${relativePath(io.cwd, out) || out}.`);
133
+ if (written.length) io.stdout(`Wrote ${written.map((f) => relativePath(io.cwd, path.join(root, f)) || f).join(", ")}.`);
174
134
  const checkIo = {
175
135
  ...io,
176
136
  cwd: root
@@ -202,9 +162,11 @@ async function init(configOptions, options, io, runCheck) {
202
162
  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
163
  for (const line of dry.stderr.split("\n").filter(Boolean)) io.stdout(line);
204
164
  const hasBaseline = [BASELINE_FILE, LEGACY_BASELINE_FILE].some((name) => fs.existsSync(path.join(root, name)));
165
+ let baselineWritten = false;
205
166
  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")) {
167
+ 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() : void 0;
168
+ if (answer === void 0) io.stdout(`No terminal to ask in, so the baseline question was skipped and no baseline was written; \`${NAME} check . --update-baseline\` records one.`);
169
+ else if (answer === "" || answer.startsWith("y")) {
208
170
  const recorded = await runCheck([
209
171
  "check",
210
172
  ".",
@@ -212,14 +174,201 @@ async function init(configOptions, options, io, runCheck) {
212
174
  ...options.cache ? [] : ["--no-cache"]
213
175
  ], checkIo);
214
176
  if (recorded.stdout) io.stdout(recorded.stdout);
177
+ baselineWritten = fs.existsSync(path.join(root, BASELINE_FILE));
215
178
  } else io.stdout(`No baseline written; \`${NAME} check . --update-baseline\` records one later.`);
216
179
  }
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.`);
180
+ const commit = [...written, ...baselineWritten ? [BASELINE_FILE] : []];
181
+ const files = commit.length > 1 ? `${commit.slice(0, -1).join(", ")} and ${commit.at(-1) ?? ""}` : commit[0];
182
+ io.stdout(designSystem && name ? registry ? `Next: commit ${files}. A workspace app that depends on ${name} now finds it with no config of its own: run \`npx ${NAME} inspect\` in one to see it, and give its team ${GUIDE_FILE}. An app that installs ${name} from a registry ${registry.next}.` : `Next: commit ${files}. An app that depends on ${name} now finds it with no config of its own: run \`npx ${NAME} inspect\` in one to see it, and give its team ${GUIDE_FILE}.` : `Next: ${files ? `commit ${files}, and ` : ""}run \`npx ${NAME} check . --format github --require-design-system\` in CI.`);
218
183
  return 0;
219
184
  }
185
+ const CONFIG_FILE = "onsystem.config.json";
186
+ function readManifest(root) {
187
+ const file = path.join(root, "package.json");
188
+ try {
189
+ const text = fs.readFileSync(file, "utf8");
190
+ const json = JSON.parse(text);
191
+ if (!json || typeof json !== "object" || Array.isArray(json)) return void 0;
192
+ return {
193
+ file,
194
+ json,
195
+ indent: /^([ \t]+)"/m.exec(text)?.[1] ?? " ",
196
+ newline: text.endsWith("\n")
197
+ };
198
+ } catch {
199
+ return;
200
+ }
201
+ }
202
+ /**
203
+ * Declares the config in the package's package.json, `"onsystem": "./onsystem.config.json"`,
204
+ * and adds it to `files` when the package lists the files it publishes, so apps that install it
205
+ * get it too. Writes ONSYSTEM.md for the apps' teams unless one exists. Returns what it wrote.
206
+ */
207
+ function declarePackage(root, manifest, name, force, notes) {
208
+ const written = [];
209
+ const { json } = manifest;
210
+ const value = `./${CONFIG_FILE}`;
211
+ let changed = false;
212
+ if (json["onsystem"] !== value) {
213
+ json[PACKAGE_FIELD] = value;
214
+ changed = true;
215
+ }
216
+ const files = Array.isArray(json.files) ? json.files : void 0;
217
+ if (files && !ships(files, CONFIG_FILE)) {
218
+ files.push(CONFIG_FILE);
219
+ changed = true;
220
+ }
221
+ if (changed) {
222
+ fs.writeFileSync(manifest.file, `${JSON.stringify(json, null, manifest.indent)}${manifest.newline ? "\n" : ""}`);
223
+ written.push("package.json");
224
+ }
225
+ notes.push(`This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ", also added to \"files\"" : ""}), which every app that depends on ${name} reads when it has no config of its own.`);
226
+ const guide = path.join(root, GUIDE_FILE);
227
+ if (force || !fs.existsSync(guide)) {
228
+ fs.writeFileSync(guide, consumerGuide(name));
229
+ written.push(GUIDE_FILE);
230
+ } else notes.push(`${GUIDE_FILE} already exists; it was left as it is (--force replaces it).`);
231
+ return written;
232
+ }
233
+ /**
234
+ * What apps that install the package from a registry will not find: the component and token
235
+ * files its `files` list leaves out of the published package, and those outside the package
236
+ * (`../tailwind-config/theme.css`), which an installed package's config cannot reach. Undefined
237
+ * for a private package, or when they find every file. Without a `files` list, npm publishes
238
+ * everything in the package.
239
+ */
240
+ async function unshipped(config, json) {
241
+ if (json.private === true) return void 0;
242
+ const name = typeof json.name === "string" ? json.name : "the package";
243
+ const files = Array.isArray(json.files) ? json.files : void 0;
244
+ const relative = (abs) => abs.map((f) => relativePath(config.root, f));
245
+ const components = relative(await componentFiles(config));
246
+ const tokens = relative(await tokenFiles(config));
247
+ const outside = (f) => f.startsWith("../");
248
+ const left = (list) => files ? list.filter((f) => !outside(f) && !ships(files, f)) : [];
249
+ const missing = {
250
+ components: left(components),
251
+ tokens: left(tokens)
252
+ };
253
+ const away = {
254
+ components: components.filter(outside),
255
+ tokens: tokens.filter(outside)
256
+ };
257
+ const noComponents = components.length > 0 && missing.components.length + away.components.length === components.length;
258
+ const lost = (lists, one) => {
259
+ const c = lists.components.length;
260
+ const t = lists.tokens.length;
261
+ const kinds = andList([...c ? [count$1(c, "component file")] : [], ...t ? [count$1(t, "token file")] : []]);
262
+ const what = andList([...c ? ["components"] : [], ...t ? ["tokens"] : []], "or");
263
+ const checks = c && noComponents ? `: they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` : "";
264
+ return `(${kinds}). Those apps do not know the ${what} in ${one ? "it" : "them"}${checks}.`;
265
+ };
266
+ const notes = [];
267
+ const top = (f) => f.includes("/") ? `${f.slice(0, f.indexOf("/"))}/` : f;
268
+ const folders = [...new Set([...missing.components, ...missing.tokens].map(top))];
269
+ if (folders.length) {
270
+ const one = folders.length === 1;
271
+ notes.push(`Apps that install ${name} from a registry get only what "files" in package.json publishes, which leaves out ${andList(folders)} ${lost(missing, one)} Add ${one ? "it" : "them"} to "files" for them; workspace apps read the sources either way.`);
272
+ }
273
+ const beyond = [...away.components, ...away.tokens];
274
+ if (beyond.length) {
275
+ const one = beyond.length === 1;
276
+ notes.push(`Apps that install ${name} from a registry read only the files in it, so they skip ${andList(beyond)}, outside the package ${lost(away, one)} Workspace apps read ${one ? "it" : "them"}. To give installed apps what ${one ? "it holds" : "they hold"}, keep a copy in the package, publish it, and name the copy in the config instead.`);
277
+ }
278
+ if (!notes.length) return void 0;
279
+ return {
280
+ notes,
281
+ next: noComponents && missing.components.length ? `gets no component checks until "files" publishes ${andList([...new Set(missing.components.map(top))])}` : `does not read ${andList([...folders, ...beyond])}`
282
+ };
283
+ }
284
+ /**
285
+ * Said when --force writes a config in an app that took on a design-system package's: the new
286
+ * one is read instead, and what it does not take from the package. `taken` is the config the
287
+ * app took on, `own` the one it reads now.
288
+ */
289
+ async function replacedNote(pkg, taken, own, cwd) {
290
+ const lacks = async (list) => {
291
+ const kept = new Set((await list(own)).map((f) => path.resolve(f)));
292
+ return (await list(taken)).some((f) => !kept.has(path.resolve(f)));
293
+ };
294
+ const components = await lacks(componentFiles);
295
+ const importPath = taken.importPath !== void 0 && own.importPath === void 0;
296
+ const tokens = await lacks(tokenFiles);
297
+ const declared = relativePath(cwd, pkg.config) || pkg.config;
298
+ const replaced = `This project took on the config ${pkg.name} declares (${declared}); with --force it has one of its own, which is read instead, so ${pkg.name}'s rules and later changes to its config no longer reach it.`;
299
+ if (!components && !importPath && !tokens) return replaced;
300
+ const effects = andList([
301
+ ...components ? [`does not know ${pkg.name}'s components`] : [],
302
+ ...importPath && !components ? [`does not match imports from ${pkg.name} to its components`] : [],
303
+ ...tokens ? [`does not read ${pkg.name}'s tokens`] : []
304
+ ]);
305
+ const fields = [
306
+ ...components ? ["\"components\""] : [],
307
+ ...importPath || components && taken.importPath !== void 0 ? ["\"importPath\""] : [],
308
+ ...tokens ? ["\"tokens\""] : []
309
+ ];
310
+ return `${replaced} It ${effects}: ${(!components || taken.configured === true) && (!tokens || taken.tokensDetected !== true) ? `copy ${andList(fields)} from ${declared} into ${CONFIG_FILE}, or ` : ""}delete ${CONFIG_FILE} to take on ${pkg.name}'s config again.`;
311
+ }
312
+ /** Whether npm's `files` list publishes `file`, a path relative to the package. */
313
+ function ships(files, file) {
314
+ return files.some((entry) => {
315
+ if (typeof entry !== "string") return false;
316
+ const pattern = entry.replace(/^\.?\//, "").replace(/\/+$/, "");
317
+ if (!pattern || pattern.startsWith("!")) return false;
318
+ return file === pattern || file.startsWith(`${pattern}/`) || matchesGlob(file, pattern) || matchesGlob(file, `${pattern}/**`);
319
+ });
320
+ }
321
+ /** ONSYSTEM.md: how an app that uses the package gets the design system to its agents and CI. */
322
+ function consumerGuide(name) {
323
+ return `${[
324
+ `# onsystem in apps that use ${name}`,
325
+ "",
326
+ `${name} declares its [onsystem](https://onsystem.vercel.app) config in its package.json: \`"${PACKAGE_FIELD}": "./${CONFIG_FILE}"\`. An app that depends on ${name}, as a workspace package or installed from npm, reads that config when it has none of its own, and its UI is checked against ${name}'s components, props, variants and tokens. To see what an app finds, run this in its folder:`,
327
+ "",
328
+ "```sh",
329
+ `npx -y ${NAME} inspect`,
330
+ "```",
331
+ "",
332
+ `An app with an ${CONFIG_FILE} of its own reads that instead.`,
333
+ "",
334
+ "## Coding agents",
335
+ "",
336
+ "Claude Code: the plugin checks every UI file the agent writes and hands the errors back to fix, and adds the MCP server and a skill.",
337
+ "",
338
+ "```text",
339
+ "/plugin marketplace add dgesteves/onsystem",
340
+ "/plugin install onsystem@dgesteves",
341
+ "```",
342
+ "",
343
+ `Other agents: \`npx skills add dgesteves/onsystem\` adds the skill, and \`npx -y ${NAME}\` started in the app is the MCP server. [Set up your agent](https://onsystem.vercel.app/docs/clients) has the config for each client.`,
344
+ "",
345
+ "## CI",
346
+ "",
347
+ "In the app, record the findings it already has once, and commit the baseline, so pull requests fail only on new ones:",
348
+ "",
349
+ "```sh",
350
+ `npm install --save-dev ${NAME}`,
351
+ `npx ${NAME} check . --update-baseline`,
352
+ "```",
353
+ "",
354
+ "```yaml",
355
+ `- run: npx ${NAME} check . --format github --require-design-system`,
356
+ "```",
357
+ "",
358
+ "[CI and baselines](https://onsystem.vercel.app/docs/ci) has the GitHub Action and the rest.",
359
+ "",
360
+ "## Changing the config",
361
+ "",
362
+ `${CONFIG_FILE} in ${name} sets what every app gets: where the components, tokens and docs are, and the rules' severities. Its \`tsconfig\`, \`overrides\` and \`includeDesignSystem\` apply to ${name}'s own files only. [Design-system packages](https://onsystem.vercel.app/docs/design-system-packages) has the details.`
363
+ ].join("\n")}\n`;
364
+ }
220
365
  function count$1(n, word) {
221
366
  return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
222
367
  }
368
+ /** `a`, `a and b`, `a, b and c` (or `or`). */
369
+ function andList(items, conjunction = "and") {
370
+ return items.length > 1 ? `${items.slice(0, -1).join(", ")} ${conjunction} ${items.at(-1) ?? ""}` : items.join("");
371
+ }
223
372
  //#endregion
224
373
  //#region src/cli.ts
225
374
  const DOCS = "https://onsystem.vercel.app/docs";
@@ -234,6 +383,9 @@ Usage
234
383
  onsystem check <paths...> Lint files, folders or globs with the check_ui rules
235
384
  (for CI)
236
385
  onsystem inspect Print what was extracted from the project
386
+ onsystem export --format dtcg|design-md
387
+ Print the tokens it found as a DTCG 2025.10 file or a
388
+ DESIGN.md (--out <file> writes them to a file)
237
389
  onsystem init Write onsystem.config.json from what was found, run a
238
390
  dry-run check and offer a baseline
239
391
  onsystem help Show this help
@@ -252,9 +404,13 @@ Options
252
404
  --docs <glob> Component docs (Markdown/MDX), repeatable
253
405
  --no-cache Ignore the on-disk extraction cache
254
406
  --no-watch serve: do not reload when files change
255
- --format <format> check: pretty | json | github | sarif (default: pretty)
407
+ --format <format> check: pretty | json | github | sarif (default: pretty);
408
+ export: dtcg | design-md
409
+ --out <file> export: write to this file instead of stdout
256
410
  --max-warnings <n> check: exit 1 when there are more than n warnings
257
411
  --update-baseline check: record the current findings as accepted, and exit 0
412
+ --prune-baseline check: drop baseline entries that no longer occur, and
413
+ accept nothing new
258
414
  --baseline <file> check: baseline file (default: ${BASELINE_FILE} in the root,
259
415
  used whenever it exists)
260
416
  --ignore-baseline check: report every finding, baseline or not
@@ -292,7 +448,8 @@ const defaultIo = {
292
448
  stdout: (text) => process.stdout.write(`${text}\n`),
293
449
  stderr: (text) => process.stderr.write(`${text}\n`),
294
450
  color: process.stdout.isTTY && !process.env.NO_COLOR,
295
- prompt: process.stdin.isTTY && process.stdout.isTTY ? askInTerminal : void 0
451
+ prompt: process.stdin.isTTY && process.stdout.isTTY ? askInTerminal : void 0,
452
+ stdinIsTTY: process.stdin.isTTY
296
453
  };
297
454
  /** One question on the terminal, answered with a line. */
298
455
  async function askInTerminal(question) {
@@ -337,16 +494,18 @@ async function main(argv, io = defaultIo) {
337
494
  type: "boolean",
338
495
  default: true
339
496
  },
340
- format: {
341
- type: "string",
342
- default: "pretty"
343
- },
497
+ format: { type: "string" },
498
+ out: { type: "string" },
344
499
  "max-warnings": { type: "string" },
345
500
  baseline: { type: "string" },
346
501
  "update-baseline": {
347
502
  type: "boolean",
348
503
  default: false
349
504
  },
505
+ "prune-baseline": {
506
+ type: "boolean",
507
+ default: false
508
+ },
350
509
  "ignore-baseline": {
351
510
  type: "boolean",
352
511
  default: false
@@ -422,15 +581,18 @@ async function main(argv, io = defaultIo) {
422
581
  try {
423
582
  switch (command) {
424
583
  case "serve":
584
+ if (io.stdinIsTTY) io.stderr(`${NAME} is an MCP server: it is waiting for an agent on stdin. Add it to your agent (${DOCS}/clients), or run \`npx -y ${NAME} inspect\` or \`npx -y ${NAME} check .\` here. Ctrl+C quits.`);
425
585
  await serveStdio({
426
586
  ...configOptions,
427
587
  cache: values.cache,
428
588
  watch: values.watch,
429
- logger: stderrLogger
589
+ logger: stderrLogger,
590
+ ...io.transport ? { transport: io.transport } : {}
430
591
  });
431
592
  return 0;
432
593
  case "check": return await check(rest, configOptions, values, io);
433
594
  case "inspect": return await inspect(configOptions, values, io);
595
+ case "export": return await exportTokens(configOptions, values, io);
434
596
  case "init": return await init(configOptions, {
435
597
  yes: values.yes,
436
598
  force: values.force,
@@ -466,18 +628,22 @@ async function check(patterns, configOptions, values, io) {
466
628
  io.stderr("check: pass --quiet-without-design-system or --require-design-system, not both");
467
629
  return 2;
468
630
  }
631
+ for (const other of ["update-baseline", "ignore-baseline"]) if (values["prune-baseline"] && values[other]) {
632
+ io.stderr(`check: pass --prune-baseline or --${other}, not both`);
633
+ return 2;
634
+ }
469
635
  if (!patterns.length) {
470
636
  io.stderr(`check: pass files, folders or globs, e.g. ${NAME} check .`);
471
637
  return 2;
472
638
  }
473
- const format = values.format;
639
+ const format = values.format ?? "pretty";
474
640
  if (![
475
641
  "pretty",
476
642
  "json",
477
643
  "github",
478
644
  "sarif"
479
645
  ].includes(format)) {
480
- io.stderr(`check: unknown --format "${values.format}" (pretty, json, github, sarif)`);
646
+ io.stderr(`check: unknown --format "${format}" (pretty, json, github, sarif)`);
481
647
  return 2;
482
648
  }
483
649
  const maxWarnings = values["max-warnings"] === void 0 ? void 0 : Number(values["max-warnings"]);
@@ -487,6 +653,7 @@ async function check(patterns, configOptions, values, io) {
487
653
  }
488
654
  const target = await loadTarget(configOptions);
489
655
  warnDeprecated(target.config.deprecations, format, io);
656
+ warnDeprecated(configWarnings(target), format, io);
490
657
  const selection = target.workspace ? await selectInWorkspace(target.workspace, patterns, values, format, io) : await selectInProject(target.config, patterns, values, format, io);
491
658
  if (typeof selection === "number") return selection;
492
659
  const { files, notes } = selection;
@@ -498,6 +665,7 @@ async function check(patterns, configOptions, values, io) {
498
665
  return 0;
499
666
  }
500
667
  const update = values["update-baseline"];
668
+ const prune = values["prune-baseline"];
501
669
  const baselineFile = values.baseline ? path.resolve(io.cwd, values.baseline) : defaultBaseline(selection.root, values["ignore-baseline"] ? void 0 : (text) => {
502
670
  warnDeprecated([text], format, io);
503
671
  });
@@ -506,6 +674,10 @@ async function check(patterns, configOptions, values, io) {
506
674
  io.stderr(`check: baseline not found: ${displayPath(io.cwd, baselineFile)}`);
507
675
  return 2;
508
676
  }
677
+ if (prune && !baseline) {
678
+ io.stderr(`check --prune-baseline: no baseline to prune at ${displayPath(io.cwd, baselineFile)}. Pass --baseline <file> to prune another one, or record the current findings with \`${checkCommand(patterns, configOptions, values, "--update-baseline")}\`.`);
679
+ return 2;
680
+ }
509
681
  const disabledFor = (file) => {
510
682
  const ds = selection.designSystemOf(file);
511
683
  return new Set(Object.entries(rulesFor(ds.config, relativePath(ds.root, file))).filter(([, rule]) => rule.severity === "off").map(([id]) => id));
@@ -515,18 +687,22 @@ async function check(patterns, configOptions, values, io) {
515
687
  const baselineKey = (file) => relativePath(realRoot, fs.realpathSync.native(file)).normalize("NFC");
516
688
  const results = [];
517
689
  const checked = /* @__PURE__ */ new Map();
690
+ const ran = /* @__PURE__ */ new Set();
518
691
  let baselined = 0;
519
692
  let fixed = 0;
520
693
  for (const file of files) {
521
- const code = await fs$1.readFile(file, "utf8");
694
+ const code = await fsp.readFile(file, "utf8");
522
695
  const ds = selection.designSystemOf(file);
523
696
  let result = ds.check(code, relativePath(ds.root, file));
524
697
  const key = baselineKey(file);
525
698
  const disabled = disabledFor(file);
526
699
  disabledByKey.set(key, disabled);
527
- if (update) checked.set(key, result.diagnostics);
700
+ if (update || prune) checked.set(key, result.diagnostics);
701
+ if (update) {
702
+ for (const rule of RULE_IDS) if (!disabled.has(rule) && canRun(ds, rule)) ran.add(rule);
703
+ }
528
704
  if (baseline && !update) {
529
- const match = applyBaseline(baseline, key, result, disabled);
705
+ const match = applyBaseline(baseline.entries, key, result, disabled);
530
706
  result = {
531
707
  ...match.result,
532
708
  baselined: match.baselined
@@ -543,22 +719,51 @@ async function check(patterns, configOptions, values, io) {
543
719
  if (elsewhere) notes.push(`${plural(elsewhere, "image or email file")} not checked`);
544
720
  const unchecked = uncheckedNotice(results);
545
721
  if (update) {
546
- const next = updateBaseline(baseline, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
547
- writeBaseline(baselineFile, next);
722
+ const next = updateBaseline(baseline?.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
723
+ const wholeRoot = patterns.some((pattern) => {
724
+ const dir = path.resolve(io.cwd, pattern);
725
+ return Boolean(fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() && isInside(realPath(dir), realRoot));
726
+ });
727
+ writeBaseline(baselineFile, next, {
728
+ onsystem: VERSION,
729
+ rules: rulesRun(baseline, next, checked, ran, wholeRoot)
730
+ });
548
731
  const counts = countBaseline(next);
549
- io.stdout(`Baseline: ${count(counts.findings, "finding")} in ${count(counts.files, "file")} → ${displayPath(io.cwd, baselineFile)}`);
732
+ const summary = `Baseline: ${count(counts.findings, "finding")} in ${count(counts.files, "file")} → ${displayPath(io.cwd, baselineFile)}`;
733
+ if (format === "pretty") io.stdout(summary);
734
+ else io.stderr(summary);
550
735
  for (const text of selection.notices) io.stderr(text);
551
736
  if (unchecked) io.stderr(unchecked);
552
737
  return 0;
553
738
  }
739
+ const command = (flag) => checkCommand(patterns, configOptions, values, flag);
740
+ const baselineNotes = [];
741
+ if (baseline) {
742
+ const fresh = results.flatMap((r) => r.diagnostics);
743
+ const unrecorded = unrecordedRules(baseline, fresh);
744
+ if (unrecorded.size) baselineNotes.push(unrecordedNote(unrecorded, fresh.length, baseline.onsystem, command("--update-baseline")));
745
+ }
746
+ if (prune && baseline) {
747
+ const next = pruneBaseline(baseline.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
748
+ const left = countBaseline(next);
749
+ const dropped = countBaseline(baseline.entries).findings - left.findings;
750
+ if (dropped) writeBaseline(baselineFile, next, {
751
+ onsystem: baseline.onsystem,
752
+ rules: baseline.rules
753
+ });
754
+ baselineNotes.push(`Baseline: ${dropped ? `dropped ${count(dropped, "finding")} that no longer ${dropped === 1 ? "occurs" : "occur"}` : "nothing to drop"}; ${count(left.findings, "finding")} in ${count(left.files, "file")} → ${displayPath(io.cwd, baselineFile)}`);
755
+ }
554
756
  const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
555
757
  color: io.color,
556
758
  uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
557
759
  baselined: baseline ? baselined : void 0,
558
760
  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
761
+ fixedHint: fixed && !prune ? `${count(fixed, "baseline finding")} no longer ${fixed === 1 ? "occurs" : "occur"}: run \`${command("--prune-baseline")}\` to drop ${fixed === 1 ? "it" : "them"}.` : void 0
560
762
  });
561
763
  if (output) io.stdout(output);
764
+ for (const text of baselineNotes) if (format === "pretty") io.stdout(text);
765
+ else if (format === "github") io.stdout(`::notice title=${NAME} baseline::${escapeData(text)}`);
766
+ else io.stderr(text);
562
767
  for (const text of [...selection.notices, unchecked]) {
563
768
  if (!text) continue;
564
769
  if (format === "pretty") io.stdout(text);
@@ -650,7 +855,7 @@ async function selectInProject(config, patterns, values, format, io) {
650
855
  excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
651
856
  gitignored: ignored.size
652
857
  };
653
- let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
858
+ let files = oncePerFile([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
654
859
  if (!files.length && !all.length) {
655
860
  io.stderr(`check: no files match ${patterns.join(" ")}`);
656
861
  return 2;
@@ -660,8 +865,9 @@ async function selectInProject(config, patterns, values, format, io) {
660
865
  return 0;
661
866
  }
662
867
  const notice = ds.notice();
663
- if (notice && values["require-design-system"]) {
868
+ if (notice && values["require-design-system"] && !ds.hasDesignSystem()) {
664
869
  io.stderr(`check --require-design-system: ${notice}`);
870
+ for (const text of notReadNotices([{ ds }])) io.stderr(text);
665
871
  return 2;
666
872
  }
667
873
  let skipped = 0;
@@ -676,7 +882,7 @@ async function selectInProject(config, patterns, values, format, io) {
676
882
  designSystemOf: () => ds,
677
883
  root: ds.root,
678
884
  notes: scopeNotes(skipped, left),
679
- notices: notice ? [notice] : [],
885
+ notices: [...notice ? [notice] : [], ...notReadNotices([{ ds }])],
680
886
  skipped,
681
887
  testsLeftOut: left.tests
682
888
  };
@@ -726,7 +932,7 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
726
932
  excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
727
933
  gitignored: ignored.size
728
934
  };
729
- let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
935
+ let files = oncePerFile([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
730
936
  if (!files.length && !all.length) {
731
937
  io.stderr(`check: no files match ${patterns.join(" ")}`);
732
938
  return 2;
@@ -747,7 +953,7 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
747
953
  }
748
954
  if (values["require-design-system"]) {
749
955
  let complete = false;
750
- for (const p of workspace.projects) if (!(await load(p)).notice()) {
956
+ for (const p of workspace.projects) if ((await load(p)).hasDesignSystem()) {
751
957
  complete = true;
752
958
  break;
753
959
  }
@@ -778,6 +984,14 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
778
984
  gaps.set(notice, gap);
779
985
  }
780
986
  const notices = [...gaps.values()].map(({ ds, dirs }) => ds.notice(listOf(dirs)) ?? "");
987
+ const checkedProjects = unique(files.map(project)).flatMap((p) => {
988
+ const ds = loaded.get(p.root);
989
+ return ds ? [{
990
+ ds,
991
+ dir: p.dir
992
+ }] : [];
993
+ });
994
+ notices.push(...notReadNotices(checkedProjects));
781
995
  const used = unique(files.map((file) => project(file).root)).length;
782
996
  return {
783
997
  files,
@@ -799,6 +1013,7 @@ async function inspect(configOptions, values, io) {
799
1013
  const target = await loadTarget(configOptions);
800
1014
  const { config } = target;
801
1015
  warnDeprecated(config.deprecations, "pretty", io);
1016
+ warnDeprecated(configWarnings(target), "pretty", io);
802
1017
  if (target.workspace) return inspectWorkspace(target.workspace, values, io);
803
1018
  const ds = await loadDesignSystem(config, {
804
1019
  cache: values.cache,
@@ -811,12 +1026,13 @@ async function inspect(configOptions, values, io) {
811
1026
  }
812
1027
  const lines = [
813
1028
  `root ${model.root}`,
814
- `config ${config.configFile ? displayPath(io.cwd, config.configFile) : "(defaults)"}`,
1029
+ `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : "(defaults)"}`,
815
1030
  ...config.detected ? [`detected ${config.detected}`] : [],
1031
+ ...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`),
816
1032
  `files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`,
817
1033
  `time ${model.stats.durationMs}ms${model.stats.fromCache ? " (cache)" : ""}`,
818
1034
  "",
819
- `Components (${ds.roots().length} + ${model.components.length - ds.roots().length} parts)`
1035
+ `Components (${ds.roots().length} + ${ds.components.length - ds.roots().length} parts)`
820
1036
  ];
821
1037
  for (const c of ds.roots()) {
822
1038
  const variants = c.variants.map((v) => `${v.name}[${v.values.length}]`).join(" ");
@@ -838,8 +1054,72 @@ async function inspect(configOptions, values, io) {
838
1054
  }
839
1055
  if (values.explain) lines.push("", ...explainProject(ds, io.cwd));
840
1056
  io.stdout(lines.join("\n"));
1057
+ const missing = values.explain ? void 0 : missingDependencies([{ ds }]);
1058
+ if (missing) io.stderr(missing);
841
1059
  return 0;
842
1060
  }
1061
+ /**
1062
+ * `MUI (@mui/material 9.4.0): 149 components from its type declarations`, per library the
1063
+ * project depends on, including those the config's `libraries` leaves out.
1064
+ */
1065
+ function describeLibraries(config, model, cwd) {
1066
+ const name = (library) => `${libraryTitle(library.package)} (${library.package}${library.version ? ` ${library.version}` : ""})`;
1067
+ return [
1068
+ ...(config.libraries ?? []).map((library) => {
1069
+ if (!library.dir) return `${name(library)}: a dependency, but not installed`;
1070
+ const count = model.components.filter((c) => c.library === library.package).length;
1071
+ return `${name(library)}: ${plural(count, "component")} from its type declarations`;
1072
+ }),
1073
+ ...(config.librariesOff ?? []).map((library) => `${name(library)}: ${librariesLeftOut(config, cwd)}`),
1074
+ ...(config.librariesUnread ?? []).map((library) => `${name(library)}: ${notReadYet()}`)
1075
+ ];
1076
+ }
1077
+ /**
1078
+ * `export --format dtcg|design-md`: the project's tokens as a DTCG 2025.10 file or a
1079
+ * DESIGN.md, on stdout or in the `--out` file.
1080
+ */
1081
+ async function exportTokens(configOptions, values, io) {
1082
+ const format = values.format;
1083
+ if (format !== "dtcg" && format !== "design-md") {
1084
+ io.stderr(`export: pass --format dtcg or --format design-md${format === void 0 ? "" : ` (not "${format}")`}`);
1085
+ return 2;
1086
+ }
1087
+ const target = await loadTarget(configOptions);
1088
+ warnDeprecated(target.config.deprecations, "pretty", io);
1089
+ warnDeprecated(configWarnings(target), "pretty", io);
1090
+ if (target.workspace) {
1091
+ const dirs = target.workspace.projects.map((p) => p.dir);
1092
+ io.stderr(`export: ${target.workspace.root} is a monorepo root, and each project has its own tokens. Pass --root with one of them${dirs.length ? ` (${listOf(dirs)})` : ""}.`);
1093
+ return 2;
1094
+ }
1095
+ const ds = await loadDesignSystem(target.config, {
1096
+ cache: values.cache,
1097
+ logger: silentLogger
1098
+ });
1099
+ if (!ds.tokens.length) {
1100
+ io.stderr(`export: no design tokens found in ${ds.root}. \`${NAME} inspect --explain\` shows where it looked.`);
1101
+ return 2;
1102
+ }
1103
+ const result = format === "dtcg" ? toDtcg(ds.tokens) : toDesignMd(ds.tokens, { name: projectName(ds.root) });
1104
+ for (const warning of result.warnings) io.stderr(`export: ${warning}`);
1105
+ if (values.out === void 0) {
1106
+ io.stdout(result.text.trimEnd());
1107
+ return 0;
1108
+ }
1109
+ const file = path.resolve(io.cwd, values.out);
1110
+ await fsp.mkdir(path.dirname(file), { recursive: true });
1111
+ await fsp.writeFile(file, result.text);
1112
+ io.stdout(`Wrote ${plural(result.count, "token")} to ${displayPath(io.cwd, file)} (${format === "dtcg" ? "DTCG 2025.10" : "DESIGN.md"})`);
1113
+ return 0;
1114
+ }
1115
+ /** The package name in the root's package.json, else the folder's name. */
1116
+ function projectName(root) {
1117
+ try {
1118
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
1119
+ if (typeof pkg.name === "string" && pkg.name) return pkg.name;
1120
+ } catch {}
1121
+ return path.basename(root);
1122
+ }
843
1123
  function tokenCounts(model) {
844
1124
  const counts = /* @__PURE__ */ new Map();
845
1125
  for (const t of model.tokens) counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
@@ -880,14 +1160,19 @@ async function inspectWorkspace(workspace, values, io) {
880
1160
  const { model } = ds;
881
1161
  const roots = ds.roots();
882
1162
  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] ?? ""}`] : []);
1163
+ const own = project.config.configFile && project.config.configFile !== workspace.configFile ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}` : void 0;
1164
+ lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...own ? [` config ${own}`] : [], ...project.config.detected ? [` detected ${project.config.detected}`] : [], ...describeLibraries(project.config, model, io.cwd).map((line) => ` library ${line}`), ` files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`, ` components ${roots.length} + ${ds.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
1165
  }
886
1166
  if (!loaded.length) lines.push("", `No workspace package has a design system that zero config finds. See ${DOCS}/troubleshooting`);
887
1167
  if (workspace.others.length) lines.push("", `Without a design system (${workspace.others.length}): ${listNames(workspace.others)}`);
888
1168
  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
1169
  if (values.explain) lines.push("", ...explainWorkspace(workspace, loaded, io.cwd));
890
1170
  io.stdout(lines.join("\n"));
1171
+ const missing = values.explain ? void 0 : missingDependencies(loaded.map(({ project, ds }) => ({
1172
+ ds,
1173
+ dir: project.dir
1174
+ })));
1175
+ if (missing) io.stderr(missing);
891
1176
  return 0;
892
1177
  }
893
1178
  /** Why the root is read as a monorepo, then each project's explanation. */
@@ -903,6 +1188,10 @@ function explainWorkspace(workspace, loaded, cwd) {
903
1188
  for (const { project, ds } of loaded) lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...explainProject(ds, cwd, " "));
904
1189
  return lines;
905
1190
  }
1191
+ /** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */
1192
+ function declaredBy(config) {
1193
+ return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : "";
1194
+ }
906
1195
  /** `A, B, C` up to a dozen names, then `and 40 more`. */
907
1196
  function listNames(names, max = 12) {
908
1197
  const shown = names.slice(0, max).join(", ");
@@ -912,6 +1201,10 @@ function listNames(names, max = 12) {
912
1201
  * Says once what a file's old name means: on stderr, or as a workflow warning with
913
1202
  * `--format github` so it shows on the run. Never on stdout otherwise, which JSON output owns.
914
1203
  */
1204
+ /** What the configs read have to say: the project's, or every package's at a monorepo root. */
1205
+ function configWarnings(target) {
1206
+ return target.workspace ? target.workspace.warnings : target.config.warnings;
1207
+ }
915
1208
  function warnDeprecated(messages, format, io) {
916
1209
  for (const text of messages ?? []) if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
917
1210
  else io.stderr(text);
@@ -990,6 +1283,50 @@ function gitIgnored(cwd, files) {
990
1283
  function count(n, word) {
991
1284
  return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
992
1285
  }
1286
+ /**
1287
+ * `check` as this run was given, with `flag`: the patterns and the options that decide what it
1288
+ * finds and which baseline it reads (output options left out).
1289
+ */
1290
+ function checkCommand(patterns, configOptions, values, flag) {
1291
+ const args = [...patterns];
1292
+ const option = (name, value) => {
1293
+ if (value !== void 0) args.push(`--${name}`, value);
1294
+ };
1295
+ option("root", configOptions.root);
1296
+ option("config", configOptions.config);
1297
+ for (const name of [
1298
+ "components",
1299
+ "tokens",
1300
+ "docs"
1301
+ ]) for (const value of configOptions[name] ?? []) option(name, value);
1302
+ option("baseline", values.baseline);
1303
+ if (configOptions.includeDesignSystem !== void 0) args.push(`--${configOptions.includeDesignSystem ? "" : "no-"}include-design-system`);
1304
+ if (values["include-tests"]) args.push("--include-tests");
1305
+ return `check ${args.map(shellQuote).join(" ")} ${flag}`;
1306
+ }
1307
+ /**
1308
+ * Whether a rule has something to compare against in a design system, as `notice()` says when
1309
+ * it has not: no-hardcoded-color needs color tokens, the spacing and radius rules tokens of
1310
+ * their kind, and the component rules components. Keep it in step with `DesignSystem.notice()`
1311
+ * in src/design-system.ts, or the baseline records a rule as run when it was skipped, or the
1312
+ * other way round.
1313
+ */
1314
+ function canRun(ds, rule) {
1315
+ switch (rule) {
1316
+ case "no-hardcoded-color": return ds.tokenIndex.has("color");
1317
+ case "no-hardcoded-spacing": return ds.tokenIndex.has("spacing");
1318
+ case "no-hardcoded-radius": return ds.tokenIndex.has("radius");
1319
+ case "icon-button-accessible-name": return true;
1320
+ default: return ds.model.components.length > 0;
1321
+ }
1322
+ }
1323
+ /** Why findings of rules that did not run when the baseline was recorded are new to it. */
1324
+ function unrecordedNote(byRule, total, version, update) {
1325
+ const rules = [...byRule.keys()].sort();
1326
+ const n = [...byRule.values()].reduce((sum, k) => sum + k, 0);
1327
+ const others = total - n;
1328
+ return `${listOf(rules, rules.length)} did not run when the baseline was written${version ? ` (onsystem ${version})` : ""}, so ${rules.length === 1 ? "its" : "their"} ${count(n, "finding")} ${n === 1 ? "is" : "are"} new to the baseline, not necessarily to the code. To accept ${n === 1 ? "it" : "them"}, run \`${update}\`${others ? `, which also accepts the ${count(others, "other new finding")}` : ""}.`;
1329
+ }
993
1330
  /** Quotes a pattern for the hint when the shell would expand or split it. */
994
1331
  function shellQuote(pattern) {
995
1332
  return /^[\w./@-]+$/.test(pattern) ? pattern : `"${pattern.replace(/(["\\$`])/g, "\\$1")}"`;
@@ -1002,6 +1339,19 @@ function realPath(file) {
1002
1339
  return file;
1003
1340
  }
1004
1341
  }
1342
+ /**
1343
+ * One path per file, links resolved: a folder that links back to its parent reaches the same
1344
+ * files again. The shortest path stays.
1345
+ */
1346
+ function oncePerFile(files) {
1347
+ const kept = /* @__PURE__ */ new Map();
1348
+ for (const file of files) {
1349
+ const real = realPath(file);
1350
+ const previous = kept.get(real);
1351
+ if (previous === void 0 || file.length < previous.length) kept.set(real, file);
1352
+ }
1353
+ return [...kept.values()];
1354
+ }
1005
1355
  /** Relative to cwd when the file is below it, absolute otherwise. */
1006
1356
  function displayPath(cwd, file) {
1007
1357
  const rel = path.relative(cwd, file);