onsystem 0.4.0 → 0.6.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,154 +1,14 @@
1
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";
2
+ import { A as toDtcg, B as DEFAULT_DOCS, C as updateBaseline, G as RULE_IDS, R as CONFIG_FILES, S as readBaseline, V as DEFAULT_SEVERITY, W as LEGACY_CONFIG_FILES, Z as rulesFor, _ as VERSION, _t as relativePath, at as detectProject, b as applyBaseline, c as silentLogger, et as LIBRARY_IDS, ft as plural, g as NAME, gt as matchesGlob, h as formatDiagnostics, ht as isInside, i as componentFiles, it as declaredConfig, k as toDesignMd, l as stderrLogger, m as uncheckedNotice, mt as unique, nt as PACKAGE_FIELD, o as loadDesignSystem, ot as findDesignSystemPackage, s as tokenFiles, st as isDesignSystemName, tt as libraryAdapter, v as BASELINE_FILE, vt as toPosix, w as writeBaseline, x as countBaseline, y as LEGACY_BASELINE_FILE, z as ConfigError } from "./design-system-A3pRX3Ka.js";
3
+ import { t as serveStdio } from "./stdio-CEChNFLR.js";
4
+ import { r as loadTarget } from "./workspace-CHvUwgz9.js";
3
5
  import fs from "node:fs";
4
6
  import path from "node:path";
5
7
  import { escapePath, glob } from "tinyglobby";
6
- import fs$1 from "node:fs/promises";
8
+ import fsp from "node:fs/promises";
7
9
  import { spawnSync } from "node:child_process";
8
10
  import readline from "node:readline/promises";
9
11
  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
12
  //#region src/explain.ts
153
13
  /**
154
14
  * What `inspect --explain` says about one project: every candidate zero config looked
@@ -160,24 +20,32 @@ function explainProject(ds, cwd, indent = "") {
160
20
  const lines = [];
161
21
  const out = (line = "") => lines.push(line ? `${indent}${line}` : "");
162
22
  out("How the design system was found");
23
+ for (const step of packageSteps(config, cwd)) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
163
24
  if (config.configured) out(` components come from ${config.configFile ? display(cwd, config.configFile) : "--components"}, so zero config did not look`);
164
25
  else {
165
26
  const steps = [];
166
- detectProject(config.root, config.tsconfig, steps);
27
+ detectProject(config.root, config.tsconfig, steps, config.designSystemPackage);
167
28
  for (const step of steps) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
168
29
  if (!config.detected) out(` So the defaults apply: ${config.components.join(", ")}${model.stats.files.components ? "" : ", which match nothing"}.`);
169
30
  }
31
+ const named = (library) => `${library.package}${library.version ? ` ${library.version}` : ""}`;
32
+ for (const library of config.libraries ?? []) {
33
+ const count = model.components.filter((c) => c.library === library.package).length;
34
+ out(library.dir ? ` ${"found".padEnd(6)} ${named(library)}: a dependency, ${count} components from its type declarations in ${display(cwd, library.dir)}` : ` ${"no".padEnd(6)} ${named(library)}: a dependency, but not installed`);
35
+ }
36
+ for (const library of config.librariesOff ?? []) out(` ${"no".padEnd(6)} ${named(library)}: a dependency, ${librariesLeftOut(config, cwd)}`);
170
37
  out();
171
38
  out("Resolved config");
172
39
  const field = (name, value) => out(` ${name.padEnd(12)} ${value}`);
173
40
  field("root", config.root);
174
- field("config", config.configFile ? display(cwd, config.configFile) : "(none)");
41
+ field("config", config.configFile ? `${display(cwd, config.configFile)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ""}` : "(none)");
175
42
  field("components", listOf$1(config.components) || "(none)");
176
43
  field("exclude", listOf$1(config.exclude));
177
- field("tokens", listOf$1(config.tokens.map((t) => `${t.path}${t.prefix ? ` (prefix ${t.prefix})` : ""}`)));
44
+ field("tokens", listOf$1(config.tokens.map((t) => `${t.path}${t.prefix ? ` (prefix ${t.prefix})` : ""}${t.fallback ? " (when nothing else is found)" : ""}`)));
178
45
  field("docs", listOf$1(config.docs));
179
46
  field("tsconfig", config.tsconfig ?? (fs.existsSync(path.join(config.root, "tsconfig.json")) ? "tsconfig.json" : "(none)"));
180
47
  field("importPath", importPaths(ds));
48
+ field("libraries", config.libraryIds === void 0 ? `every library onsystem reads (${LIBRARY_IDS.join(", ")})` : config.libraryIds !== false && config.libraryIds.length ? config.libraryIds.join(", ") : `none ("libraries": ${JSON.stringify(config.libraryIds)})`);
181
49
  if (config.tailwindConfig) field("tailwind", display(config.root, config.tailwindConfig));
182
50
  if (config.designSystems) field("primary", config.designSystems.map((d, i) => `${i ? "then " : ""}${d.source} (${d.imports} imports)`).join("; "));
183
51
  const changed = RULE_IDS.filter((id) => config.rules[id].severity !== DEFAULT_SEVERITY[id]);
@@ -188,7 +56,12 @@ function explainProject(ds, cwd, indent = "") {
188
56
  out("Tokens");
189
57
  const found = ds.tokens;
190
58
  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(", ")}`}`);
59
+ const themed = found.filter((t) => t.origin === "library");
60
+ out(` ${found.length - themed.length} tokens from ${files} ${files === 1 ? "file" : "files"}${files ? "" : `: no file matches ${config.tokens.map((t) => t.path).join(", ")}`}`);
61
+ if (themed.length) {
62
+ const sources = [...new Set(themed.map((t) => t.source.file))];
63
+ out(` ${themed.length} tokens from a library's theme in ${sources.join(", ")}`);
64
+ }
192
65
  const defined = new Set(found.map((t) => t.cssVar).filter((v) => Boolean(v)));
193
66
  const missing = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] ?? "").filter((v) => !defined.has(v));
194
67
  const unresolved = found.filter((t) => missing(t.value).length);
@@ -209,6 +82,10 @@ function explainProject(ds, cwd, indent = "") {
209
82
  }
210
83
  return lines;
211
84
  }
85
+ /** Why a library the project depends on is not read: `left out by "libraries" in onsystem.config.json`. */
86
+ function librariesLeftOut(config, cwd) {
87
+ return `left out by "libraries" in ${config.configFile ? display(cwd, config.configFile) : "the config"}`;
88
+ }
212
89
  /** The first few, then how many more: a package's exports can name hundreds of files. */
213
90
  function listOf$1(items, max = 8) {
214
91
  const shown = items.slice(0, max).join(", ");
@@ -235,15 +112,46 @@ function display(cwd, file) {
235
112
  const rel = relativePath(cwd, file);
236
113
  return rel && !rel.startsWith("..") ? rel : file;
237
114
  }
115
+ /**
116
+ * Whether a dependency's declared config was taken on, for a project without a config of its
117
+ * own: the package it came from and the others that declare one, or that none does.
118
+ */
119
+ function packageSteps(config, cwd) {
120
+ const declared = config.designSystemPackage;
121
+ if (config.configFile && !declared) return [];
122
+ const lookup = findDesignSystemPackage(config.root);
123
+ if (!lookup) return [];
124
+ const candidate = "a design-system package's config";
125
+ const skipped = lookup.skipped.length ? `; ${lookup.skipped.map((p) => p.name).join(", ")} ${lookup.skipped.length === 1 ? "declares one" : "declare one"} that cannot be read, skipped since installed` : "";
126
+ if (!declared) return [{
127
+ candidate,
128
+ accepted: false,
129
+ reason: `no dependency declares one ("onsystem" in its package.json) among ${lookup.dependencies} ${lookup.dependencies === 1 ? "dependency" : "dependencies"}${skipped}`
130
+ }];
131
+ return [{
132
+ candidate,
133
+ accepted: true,
134
+ reason: `${declared.name} (${declared.installed ? "installed" : "a workspace package"}) declares ${display(cwd, declared.config)}, which this project takes on, with its paths from ${display(cwd, declared.dir)}${lookup.others.length ? `; ${lookup.others.join(", ")} also declare one, not read: one config is taken on, from the package the code imports most` : ""}${skipped}`
135
+ }];
136
+ }
238
137
  //#endregion
239
138
  //#region src/init.ts
240
139
  const SCHEMA = "https://unpkg.com/onsystem/schema.json";
241
140
  /** More detected globs than this are files, not folders: zero config keeps them. */
242
141
  const MAX_GLOBS = 6;
142
+ /** What a design-system package's apps are told about it, next to its config. */
143
+ const GUIDE_FILE = "ONSYSTEM.md";
243
144
  /**
244
145
  * Writes `onsystem.config.json` in the root from what zero config finds there, or from
245
146
  * the answers to up to three questions when it finds nothing, then checks the project
246
147
  * once without failing and offers to record what it found as the baseline.
148
+ *
149
+ * In a design-system package (one zero config reads through its own `exports`, or named like
150
+ * one, as apps look for it), it also declares the config in package.json
151
+ * (`"onsystem": "./onsystem.config.json"`, and in `files` when the package lists them), so
152
+ * every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those
153
+ * apps' teams how to add the agent tools and the CI check. In an app that takes on such a
154
+ * package's config, it writes no config of its own unless --force says to.
247
155
  */
248
156
  async function init(configOptions, options, io, runCheck) {
249
157
  const { config, workspace } = await loadTarget({
@@ -251,16 +159,23 @@ async function init(configOptions, options, io, runCheck) {
251
159
  config: void 0
252
160
  });
253
161
  const root = config.root;
254
- const existing = [...CONFIG_FILES, ...LEGACY_CONFIG_FILES].map((name) => path.join(root, name)).find((file) => fs.existsSync(file));
162
+ const declared = declaredConfig(root);
163
+ 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);
164
+ const manifest = readManifest(root);
165
+ const name = typeof manifest?.json.name === "string" ? manifest.json.name : void 0;
166
+ const designSystem = !workspace && !config.designSystemPackage && name !== void 0 && (config.detected?.startsWith("package.json exports of") === true || isDesignSystemName(name));
255
167
  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.`);
168
+ 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).` : "";
169
+ 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}`);
257
170
  return 2;
258
171
  }
259
172
  const ask = options.yes ? void 0 : io.prompt;
260
173
  const file = { $schema: SCHEMA };
261
174
  const notes = [];
262
175
  const relative = (abs) => relativePath(root, abs);
263
- if (configOptions.components?.length) {
176
+ const inherited = options.force ? void 0 : config.designSystemPackage;
177
+ 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.`);
178
+ else if (configOptions.components?.length) {
264
179
  file.components = configOptions.components;
265
180
  if (configOptions.tokens?.length) file.tokens = configOptions.tokens;
266
181
  if (configOptions.docs?.length) file.docs = configOptions.docs;
@@ -278,6 +193,16 @@ async function init(configOptions, options, io, runCheck) {
278
193
  if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs];
279
194
  }
280
195
  if (tokens.length) file.tokens = tokens;
196
+ } else if ((await componentFiles(config)).length) {
197
+ const matching = [];
198
+ for (const glob of config.components) if ((await componentFiles({
199
+ ...config,
200
+ components: [glob]
201
+ })).length) matching.push(glob);
202
+ notes.push(`Found the design system in the default folders: ${matching.join(", ")}.`);
203
+ file.components = matching;
204
+ const tokens = (await tokenFiles(config)).map(relative);
205
+ if (tokens.length) file.tokens = tokens;
281
206
  } else {
282
207
  if (!ask) {
283
208
  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.`);
@@ -307,10 +232,19 @@ async function init(configOptions, options, io, runCheck) {
307
232
  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
233
  if (importPath) file.importPath = importPath;
309
234
  }
310
- const out = path.join(root, "onsystem.config.json");
311
- fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
235
+ const written = [];
236
+ if (!inherited) {
237
+ const out = path.join(root, CONFIG_FILE);
238
+ fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
239
+ written.push(CONFIG_FILE);
240
+ }
241
+ if (designSystem && manifest && name) {
242
+ written.push(...declarePackage(root, manifest, name, options.force, notes));
243
+ const shipped = await unshipped(config, manifest.json);
244
+ if (shipped) notes.push(shipped);
245
+ }
312
246
  for (const note of notes) io.stdout(note);
313
- io.stdout(`Wrote ${relativePath(io.cwd, out) || out}.`);
247
+ if (written.length) io.stdout(`Wrote ${written.map((f) => relativePath(io.cwd, path.join(root, f)) || f).join(", ")}.`);
314
248
  const checkIo = {
315
249
  ...io,
316
250
  cwd: root
@@ -342,9 +276,11 @@ async function init(configOptions, options, io, runCheck) {
342
276
  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
277
  for (const line of dry.stderr.split("\n").filter(Boolean)) io.stdout(line);
344
278
  const hasBaseline = [BASELINE_FILE, LEGACY_BASELINE_FILE].some((name) => fs.existsSync(path.join(root, name)));
279
+ let baselineWritten = false;
345
280
  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")) {
281
+ 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;
282
+ 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.`);
283
+ else if (answer === "" || answer.startsWith("y")) {
348
284
  const recorded = await runCheck([
349
285
  "check",
350
286
  ".",
@@ -352,17 +288,134 @@ async function init(configOptions, options, io, runCheck) {
352
288
  ...options.cache ? [] : ["--no-cache"]
353
289
  ], checkIo);
354
290
  if (recorded.stdout) io.stdout(recorded.stdout);
291
+ baselineWritten = fs.existsSync(path.join(root, BASELINE_FILE));
355
292
  } else io.stdout(`No baseline written; \`${NAME} check . --update-baseline\` records one later.`);
356
293
  }
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.`);
294
+ const commit = [...written, ...baselineWritten ? [BASELINE_FILE] : []];
295
+ const files = commit.length > 1 ? `${commit.slice(0, -1).join(", ")} and ${commit.at(-1) ?? ""}` : commit[0];
296
+ io.stdout(designSystem && name ? `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.`);
358
297
  return 0;
359
298
  }
299
+ const CONFIG_FILE = "onsystem.config.json";
300
+ function readManifest(root) {
301
+ const file = path.join(root, "package.json");
302
+ try {
303
+ const text = fs.readFileSync(file, "utf8");
304
+ const json = JSON.parse(text);
305
+ if (!json || typeof json !== "object" || Array.isArray(json)) return void 0;
306
+ return {
307
+ file,
308
+ json,
309
+ indent: /^([ \t]+)"/m.exec(text)?.[1] ?? " ",
310
+ newline: text.endsWith("\n")
311
+ };
312
+ } catch {
313
+ return;
314
+ }
315
+ }
316
+ /**
317
+ * Declares the config in the package's package.json, `"onsystem": "./onsystem.config.json"`,
318
+ * and adds it to `files` when the package lists the files it publishes, so apps that install it
319
+ * get it too. Writes ONSYSTEM.md for the apps' teams unless one exists. Returns what it wrote.
320
+ */
321
+ function declarePackage(root, manifest, name, force, notes) {
322
+ const written = [];
323
+ const { json } = manifest;
324
+ const value = `./${CONFIG_FILE}`;
325
+ let changed = false;
326
+ if (json["onsystem"] !== value) {
327
+ json[PACKAGE_FIELD] = value;
328
+ changed = true;
329
+ }
330
+ const files = Array.isArray(json.files) ? json.files : void 0;
331
+ if (files && !ships(files, CONFIG_FILE)) {
332
+ files.push(CONFIG_FILE);
333
+ changed = true;
334
+ }
335
+ if (changed) {
336
+ fs.writeFileSync(manifest.file, `${JSON.stringify(json, null, manifest.indent)}${manifest.newline ? "\n" : ""}`);
337
+ written.push("package.json");
338
+ }
339
+ 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.`);
340
+ const guide = path.join(root, GUIDE_FILE);
341
+ if (force || !fs.existsSync(guide)) {
342
+ fs.writeFileSync(guide, consumerGuide(name));
343
+ written.push(GUIDE_FILE);
344
+ } else notes.push(`${GUIDE_FILE} already exists; it was left as it is (--force replaces it).`);
345
+ return written;
346
+ }
347
+ /**
348
+ * What apps that install the package from a registry will not find: the component and token
349
+ * files its `files` list leaves out of the published package. Undefined for a private package,
350
+ * one without a `files` list (npm then ships everything), or when nothing is left out.
351
+ */
352
+ async function unshipped(config, json) {
353
+ if (json.private === true || !Array.isArray(json.files)) return void 0;
354
+ const files = json.files;
355
+ const missing = [...await componentFiles(config), ...await tokenFiles(config)].map((f) => relativePath(config.root, f)).filter((f) => !f.startsWith("../")).filter((f) => !ships(files, f));
356
+ if (!missing.length) return void 0;
357
+ const folders = [...new Set(missing.map((f) => f.includes("/") ? `${f.slice(0, f.indexOf("/"))}/` : f))];
358
+ return `Apps that install ${typeof json.name === "string" ? json.name : "the package"} from a registry get only what "files" in package.json publishes, which leaves out ${folders.join(", ")} (${count$1(missing.length, "component or token file")}): they will not find those. Add ${folders.length === 1 ? "it" : "them"} to "files" for them; workspace apps read the sources either way.`;
359
+ }
360
+ /** Whether npm's `files` list publishes `file`, a path relative to the package. */
361
+ function ships(files, file) {
362
+ return files.some((entry) => {
363
+ if (typeof entry !== "string") return false;
364
+ const pattern = entry.replace(/^\.?\//, "").replace(/\/+$/, "");
365
+ if (!pattern || pattern.startsWith("!")) return false;
366
+ return file === pattern || file.startsWith(`${pattern}/`) || matchesGlob(file, pattern) || matchesGlob(file, `${pattern}/**`);
367
+ });
368
+ }
369
+ /** ONSYSTEM.md: how an app that uses the package gets the design system to its agents and CI. */
370
+ function consumerGuide(name) {
371
+ return `${[
372
+ `# onsystem in apps that use ${name}`,
373
+ "",
374
+ `${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:`,
375
+ "",
376
+ "```sh",
377
+ `npx -y ${NAME} inspect`,
378
+ "```",
379
+ "",
380
+ `An app with an ${CONFIG_FILE} of its own reads that instead.`,
381
+ "",
382
+ "## Coding agents",
383
+ "",
384
+ "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.",
385
+ "",
386
+ "```text",
387
+ "/plugin marketplace add dgesteves/onsystem",
388
+ "/plugin install onsystem@dgesteves",
389
+ "```",
390
+ "",
391
+ `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.`,
392
+ "",
393
+ "## CI",
394
+ "",
395
+ "In the app, record the findings it already has once, and commit the baseline, so pull requests fail only on new ones:",
396
+ "",
397
+ "```sh",
398
+ `npm install --save-dev ${NAME}`,
399
+ `npx ${NAME} check . --update-baseline`,
400
+ "```",
401
+ "",
402
+ "```yaml",
403
+ `- run: npx ${NAME} check . --format github --require-design-system`,
404
+ "```",
405
+ "",
406
+ "[CI and baselines](https://onsystem.vercel.app/docs/ci) has the GitHub Action and the rest.",
407
+ "",
408
+ "## Changing the config",
409
+ "",
410
+ `${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.`
411
+ ].join("\n")}\n`;
412
+ }
360
413
  function count$1(n, word) {
361
414
  return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
362
415
  }
363
416
  //#endregion
364
417
  //#region src/cli.ts
365
- const DOCS = "https://design-system-mcp-demo.vercel.app/docs";
418
+ const DOCS = "https://onsystem.vercel.app/docs";
366
419
  const HELP = `${NAME} ${VERSION}
367
420
 
368
421
  Keeps coding agents on your React design system: checks UI code against your
@@ -374,6 +427,9 @@ Usage
374
427
  onsystem check <paths...> Lint files, folders or globs with the check_ui rules
375
428
  (for CI)
376
429
  onsystem inspect Print what was extracted from the project
430
+ onsystem export --format dtcg|design-md
431
+ Print the tokens it found as a DTCG 2025.10 file or a
432
+ DESIGN.md (--out <file> writes them to a file)
377
433
  onsystem init Write onsystem.config.json from what was found, run a
378
434
  dry-run check and offer a baseline
379
435
  onsystem help Show this help
@@ -392,7 +448,9 @@ Options
392
448
  --docs <glob> Component docs (Markdown/MDX), repeatable
393
449
  --no-cache Ignore the on-disk extraction cache
394
450
  --no-watch serve: do not reload when files change
395
- --format <format> check: pretty | json | github (default: pretty)
451
+ --format <format> check: pretty | json | github | sarif (default: pretty);
452
+ export: dtcg | design-md
453
+ --out <file> export: write to this file instead of stdout
396
454
  --max-warnings <n> check: exit 1 when there are more than n warnings
397
455
  --update-baseline check: record the current findings as accepted, and exit 0
398
456
  --baseline <file> check: baseline file (default: ${BASELINE_FILE} in the root,
@@ -432,7 +490,8 @@ const defaultIo = {
432
490
  stdout: (text) => process.stdout.write(`${text}\n`),
433
491
  stderr: (text) => process.stderr.write(`${text}\n`),
434
492
  color: process.stdout.isTTY && !process.env.NO_COLOR,
435
- prompt: process.stdin.isTTY && process.stdout.isTTY ? askInTerminal : void 0
493
+ prompt: process.stdin.isTTY && process.stdout.isTTY ? askInTerminal : void 0,
494
+ stdinIsTTY: process.stdin.isTTY
436
495
  };
437
496
  /** One question on the terminal, answered with a line. */
438
497
  async function askInTerminal(question) {
@@ -477,10 +536,8 @@ async function main(argv, io = defaultIo) {
477
536
  type: "boolean",
478
537
  default: true
479
538
  },
480
- format: {
481
- type: "string",
482
- default: "pretty"
483
- },
539
+ format: { type: "string" },
540
+ out: { type: "string" },
484
541
  "max-warnings": { type: "string" },
485
542
  baseline: { type: "string" },
486
543
  "update-baseline": {
@@ -562,15 +619,18 @@ async function main(argv, io = defaultIo) {
562
619
  try {
563
620
  switch (command) {
564
621
  case "serve":
622
+ 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.`);
565
623
  await serveStdio({
566
624
  ...configOptions,
567
625
  cache: values.cache,
568
626
  watch: values.watch,
569
- logger: stderrLogger
627
+ logger: stderrLogger,
628
+ ...io.transport ? { transport: io.transport } : {}
570
629
  });
571
630
  return 0;
572
631
  case "check": return await check(rest, configOptions, values, io);
573
632
  case "inspect": return await inspect(configOptions, values, io);
633
+ case "export": return await exportTokens(configOptions, values, io);
574
634
  case "init": return await init(configOptions, {
575
635
  yes: values.yes,
576
636
  force: values.force,
@@ -610,13 +670,14 @@ async function check(patterns, configOptions, values, io) {
610
670
  io.stderr(`check: pass files, folders or globs, e.g. ${NAME} check .`);
611
671
  return 2;
612
672
  }
613
- const format = values.format;
673
+ const format = values.format ?? "pretty";
614
674
  if (![
615
675
  "pretty",
616
676
  "json",
617
- "github"
677
+ "github",
678
+ "sarif"
618
679
  ].includes(format)) {
619
- io.stderr(`check: unknown --format "${values.format}" (pretty, json, github)`);
680
+ io.stderr(`check: unknown --format "${format}" (pretty, json, github, sarif)`);
620
681
  return 2;
621
682
  }
622
683
  const maxWarnings = values["max-warnings"] === void 0 ? void 0 : Number(values["max-warnings"]);
@@ -626,15 +687,15 @@ async function check(patterns, configOptions, values, io) {
626
687
  }
627
688
  const target = await loadTarget(configOptions);
628
689
  warnDeprecated(target.config.deprecations, format, io);
690
+ warnDeprecated(configWarnings(target), format, io);
629
691
  const selection = target.workspace ? await selectInWorkspace(target.workspace, patterns, values, format, io) : await selectInProject(target.config, patterns, values, format, io);
630
692
  if (typeof selection === "number") return selection;
631
693
  const { files, notes } = selection;
632
694
  if (!files.length) {
633
- if (format === "json") io.stdout("[]");
634
- else if (format === "pretty") {
695
+ if (format === "pretty") {
635
696
  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
697
  io.stdout(`Nothing to check: ${notes.join("; ")}. ${hints.join(" ")}`.trimEnd());
637
- }
698
+ } else printNoResults(format, io);
638
699
  return 0;
639
700
  }
640
701
  const update = values["update-baseline"];
@@ -658,7 +719,7 @@ async function check(patterns, configOptions, values, io) {
658
719
  let baselined = 0;
659
720
  let fixed = 0;
660
721
  for (const file of files) {
661
- const code = await fs$1.readFile(file, "utf8");
722
+ const code = await fsp.readFile(file, "utf8");
662
723
  const ds = selection.designSystemOf(file);
663
724
  let result = ds.check(code, relativePath(ds.root, file));
664
725
  const key = baselineKey(file);
@@ -693,6 +754,7 @@ async function check(patterns, configOptions, values, io) {
693
754
  }
694
755
  const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
695
756
  color: io.color,
757
+ uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
696
758
  baselined: baseline ? baselined : void 0,
697
759
  notes,
698
760
  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
@@ -713,6 +775,35 @@ async function check(patterns, configOptions, values, io) {
713
775
  }
714
776
  return 0;
715
777
  }
778
+ /** What `json` and `sarif` print when there is nothing to report on: `[]`, or a log without results. */
779
+ function printNoResults(format, io) {
780
+ if (format === "json") io.stdout("[]");
781
+ else if (format === "sarif") io.stdout(formatDiagnostics([], "sarif"));
782
+ }
783
+ /**
784
+ * Maps a path relative to the working directory to one relative to the repository root, which
785
+ * GitHub resolves workflow command annotations (`--format github`) and code scanning locations
786
+ * (`--format sarif`) against, so `check` run from `apps/web` still reports
787
+ * `apps/web/app/page.tsx`. The root is GITHUB_WORKSPACE or the git top-level, whichever is the
788
+ * innermost folder holding the file: the git top-level outside Actions, GITHUB_WORKSPACE where
789
+ * git is missing or refuses the checkout, and the repository checked out in a folder of the
790
+ * workspace (`actions/checkout` with `path:`). A file under neither keeps its path relative to
791
+ * the working directory.
792
+ */
793
+ function repositoryPath(cwd, env) {
794
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
795
+ cwd,
796
+ encoding: "utf8",
797
+ windowsHide: true
798
+ });
799
+ const roots = [result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim() : "", env.GITHUB_WORKSPACE ?? ""].filter(Boolean).map((dir) => realPath(path.resolve(cwd, dir))).sort((a, b) => b.length - a.length);
800
+ if (!roots.length) return toPosix;
801
+ return (file) => {
802
+ const absolute = realPath(path.resolve(cwd, file));
803
+ const root = roots.find((dir) => isInside(dir, absolute));
804
+ return root ? relativePath(root, absolute) : toPosix(file);
805
+ };
806
+ }
716
807
  /** Splits the patterns into files named outright and globs (a folder: every TSX/JSX file under it). */
717
808
  function expandPatterns(patterns, cwd) {
718
809
  const named = [];
@@ -766,11 +857,11 @@ async function selectInProject(config, patterns, values, format, io) {
766
857
  return 2;
767
858
  }
768
859
  if (values["quiet-without-design-system"] && !config.configFile && !ds.components.length) {
769
- if (format === "json") io.stdout("[]");
860
+ printNoResults(format, io);
770
861
  return 0;
771
862
  }
772
863
  const notice = ds.notice();
773
- if (notice && values["require-design-system"]) {
864
+ if (notice && values["require-design-system"] && !ds.hasDesignSystem()) {
774
865
  io.stderr(`check --require-design-system: ${notice}`);
775
866
  return 2;
776
867
  }
@@ -851,13 +942,13 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
851
942
  if (values["quiet-without-design-system"]) {
852
943
  files = files.filter((file) => Boolean(project(file).config.configFile) || designSystemOf(file).components.length > 0);
853
944
  if (!files.length) {
854
- if (format === "json") io.stdout("[]");
945
+ printNoResults(format, io);
855
946
  return 0;
856
947
  }
857
948
  }
858
949
  if (values["require-design-system"]) {
859
950
  let complete = false;
860
- for (const p of workspace.projects) if (!(await load(p)).notice()) {
951
+ for (const p of workspace.projects) if ((await load(p)).hasDesignSystem()) {
861
952
  complete = true;
862
953
  break;
863
954
  }
@@ -909,6 +1000,7 @@ async function inspect(configOptions, values, io) {
909
1000
  const target = await loadTarget(configOptions);
910
1001
  const { config } = target;
911
1002
  warnDeprecated(config.deprecations, "pretty", io);
1003
+ warnDeprecated(configWarnings(target), "pretty", io);
912
1004
  if (target.workspace) return inspectWorkspace(target.workspace, values, io);
913
1005
  const ds = await loadDesignSystem(config, {
914
1006
  cache: values.cache,
@@ -921,12 +1013,13 @@ async function inspect(configOptions, values, io) {
921
1013
  }
922
1014
  const lines = [
923
1015
  `root ${model.root}`,
924
- `config ${config.configFile ? displayPath(io.cwd, config.configFile) : "(defaults)"}`,
1016
+ `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : "(defaults)"}`,
925
1017
  ...config.detected ? [`detected ${config.detected}`] : [],
1018
+ ...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`),
926
1019
  `files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`,
927
1020
  `time ${model.stats.durationMs}ms${model.stats.fromCache ? " (cache)" : ""}`,
928
1021
  "",
929
- `Components (${ds.roots().length} + ${model.components.length - ds.roots().length} parts)`
1022
+ `Components (${ds.roots().length} + ${ds.components.length - ds.roots().length} parts)`
930
1023
  ];
931
1024
  for (const c of ds.roots()) {
932
1025
  const variants = c.variants.map((v) => `${v.name}[${v.values.length}]`).join(" ");
@@ -950,6 +1043,64 @@ async function inspect(configOptions, values, io) {
950
1043
  io.stdout(lines.join("\n"));
951
1044
  return 0;
952
1045
  }
1046
+ /**
1047
+ * `MUI (@mui/material 9.4.0): 149 components from its type declarations`, per library the
1048
+ * project depends on, including those the config's `libraries` leaves out.
1049
+ */
1050
+ function describeLibraries(config, model, cwd) {
1051
+ const name = (library) => `${libraryAdapter(library.package)?.title ?? library.package} (${library.package}${library.version ? ` ${library.version}` : ""})`;
1052
+ return [...(config.libraries ?? []).map((library) => {
1053
+ if (!library.dir) return `${name(library)}: a dependency, but not installed`;
1054
+ const count = model.components.filter((c) => c.library === library.package).length;
1055
+ return `${name(library)}: ${plural(count, "component")} from its type declarations`;
1056
+ }), ...(config.librariesOff ?? []).map((library) => `${name(library)}: ${librariesLeftOut(config, cwd)}`)];
1057
+ }
1058
+ /**
1059
+ * `export --format dtcg|design-md`: the project's tokens as a DTCG 2025.10 file or a
1060
+ * DESIGN.md, on stdout or in the `--out` file.
1061
+ */
1062
+ async function exportTokens(configOptions, values, io) {
1063
+ const format = values.format;
1064
+ if (format !== "dtcg" && format !== "design-md") {
1065
+ io.stderr(`export: pass --format dtcg or --format design-md${format === void 0 ? "" : ` (not "${format}")`}`);
1066
+ return 2;
1067
+ }
1068
+ const target = await loadTarget(configOptions);
1069
+ warnDeprecated(target.config.deprecations, "pretty", io);
1070
+ warnDeprecated(configWarnings(target), "pretty", io);
1071
+ if (target.workspace) {
1072
+ const dirs = target.workspace.projects.map((p) => p.dir);
1073
+ 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)})` : ""}.`);
1074
+ return 2;
1075
+ }
1076
+ const ds = await loadDesignSystem(target.config, {
1077
+ cache: values.cache,
1078
+ logger: silentLogger
1079
+ });
1080
+ if (!ds.tokens.length) {
1081
+ io.stderr(`export: no design tokens found in ${ds.root}. \`${NAME} inspect --explain\` shows where it looked.`);
1082
+ return 2;
1083
+ }
1084
+ const result = format === "dtcg" ? toDtcg(ds.tokens) : toDesignMd(ds.tokens, { name: projectName(ds.root) });
1085
+ for (const warning of result.warnings) io.stderr(`export: ${warning}`);
1086
+ if (values.out === void 0) {
1087
+ io.stdout(result.text.trimEnd());
1088
+ return 0;
1089
+ }
1090
+ const file = path.resolve(io.cwd, values.out);
1091
+ await fsp.mkdir(path.dirname(file), { recursive: true });
1092
+ await fsp.writeFile(file, result.text);
1093
+ io.stdout(`Wrote ${plural(result.count, "token")} to ${displayPath(io.cwd, file)} (${format === "dtcg" ? "DTCG 2025.10" : "DESIGN.md"})`);
1094
+ return 0;
1095
+ }
1096
+ /** The package name in the root's package.json, else the folder's name. */
1097
+ function projectName(root) {
1098
+ try {
1099
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
1100
+ if (typeof pkg.name === "string" && pkg.name) return pkg.name;
1101
+ } catch {}
1102
+ return path.basename(root);
1103
+ }
953
1104
  function tokenCounts(model) {
954
1105
  const counts = /* @__PURE__ */ new Map();
955
1106
  for (const t of model.tokens) counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
@@ -990,8 +1141,8 @@ async function inspectWorkspace(workspace, values, io) {
990
1141
  const { model } = ds;
991
1142
  const roots = ds.roots();
992
1143
  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] ?? ""}`] : []);
1144
+ const own = project.config.configFile && project.config.configFile !== workspace.configFile ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}` : void 0;
1145
+ 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] ?? ""}`] : []);
995
1146
  }
996
1147
  if (!loaded.length) lines.push("", `No workspace package has a design system that zero config finds. See ${DOCS}/troubleshooting`);
997
1148
  if (workspace.others.length) lines.push("", `Without a design system (${workspace.others.length}): ${listNames(workspace.others)}`);
@@ -1013,6 +1164,10 @@ function explainWorkspace(workspace, loaded, cwd) {
1013
1164
  for (const { project, ds } of loaded) lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...explainProject(ds, cwd, " "));
1014
1165
  return lines;
1015
1166
  }
1167
+ /** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */
1168
+ function declaredBy(config) {
1169
+ return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : "";
1170
+ }
1016
1171
  /** `A, B, C` up to a dozen names, then `and 40 more`. */
1017
1172
  function listNames(names, max = 12) {
1018
1173
  const shown = names.slice(0, max).join(", ");
@@ -1022,6 +1177,10 @@ function listNames(names, max = 12) {
1022
1177
  * Says once what a file's old name means: on stderr, or as a workflow warning with
1023
1178
  * `--format github` so it shows on the run. Never on stdout otherwise, which JSON output owns.
1024
1179
  */
1180
+ /** What the configs read have to say: the project's, or every package's at a monorepo root. */
1181
+ function configWarnings(target) {
1182
+ return target.workspace ? target.workspace.warnings : target.config.warnings;
1183
+ }
1025
1184
  function warnDeprecated(messages, format, io) {
1026
1185
  for (const text of messages ?? []) if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
1027
1186
  else io.stderr(text);