onsystem 0.6.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,7 +1,7 @@
1
1
  #!/usr/bin/env node
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";
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";
@@ -9,132 +9,6 @@ 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
- for (const step of packageSteps(config, cwd)) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
24
- if (config.configured) out(` components come from ${config.configFile ? display(cwd, config.configFile) : "--components"}, so zero config did not look`);
25
- else {
26
- const steps = [];
27
- detectProject(config.root, config.tsconfig, steps, config.designSystemPackage);
28
- for (const step of steps) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
29
- if (!config.detected) out(` So the defaults apply: ${config.components.join(", ")}${model.stats.files.components ? "" : ", which match nothing"}.`);
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)}`);
37
- out();
38
- out("Resolved config");
39
- const field = (name, value) => out(` ${name.padEnd(12)} ${value}`);
40
- field("root", config.root);
41
- field("config", config.configFile ? `${display(cwd, config.configFile)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ""}` : "(none)");
42
- field("components", listOf$1(config.components) || "(none)");
43
- field("exclude", listOf$1(config.exclude));
44
- field("tokens", listOf$1(config.tokens.map((t) => `${t.path}${t.prefix ? ` (prefix ${t.prefix})` : ""}${t.fallback ? " (when nothing else is found)" : ""}`)));
45
- field("docs", listOf$1(config.docs));
46
- field("tsconfig", config.tsconfig ?? (fs.existsSync(path.join(config.root, "tsconfig.json")) ? "tsconfig.json" : "(none)"));
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)})`);
49
- if (config.tailwindConfig) field("tailwind", display(config.root, config.tailwindConfig));
50
- if (config.designSystems) field("primary", config.designSystems.map((d, i) => `${i ? "then " : ""}${d.source} (${d.imports} imports)`).join("; "));
51
- const changed = RULE_IDS.filter((id) => config.rules[id].severity !== DEFAULT_SEVERITY[id]);
52
- field("rules", `${RULE_IDS.map((id) => `${id} ${config.rules[id].severity}`).join(", ")}${changed.length ? ` (changed: ${changed.join(", ")})` : ""}`);
53
- 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(", ")}`);
54
- if (config.base) field("relative to", `${config.base} (a config at the workspace root)`);
55
- out();
56
- out("Tokens");
57
- const found = ds.tokens;
58
- const files = model.stats.files.tokens;
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
- }
65
- const defined = new Set(found.map((t) => t.cssVar).filter((v) => Boolean(v)));
66
- const missing = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] ?? "").filter((v) => !defined.has(v));
67
- const unresolved = found.filter((t) => missing(t.value).length);
68
- 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` : ""}`);
69
- else if (found.length) out(" every value resolves");
70
- for (const warning of model.warnings.filter((w) => /alias|token/i.test(w))) out(` ${warning}`);
71
- out();
72
- out("Props");
73
- const empty = ds.roots().filter((c) => c.openProps && !c.props.length && !c.inherits.length);
74
- const open = ds.components.filter((c) => c.openProps);
75
- if (!open.length) out(` every component's props resolved (${ds.components.length} components)`);
76
- else {
77
- const installed = nodeModulesFor(config.root);
78
- const names = (list) => `${list.slice(0, 6).map((c) => c.name).join(", ")}${list.length > 6 ? ` and ${list.length - 6} more` : ""}`;
79
- if (empty.length) out(` ${empty.length} ${empty.length === 1 ? "component has" : "components have"} 0 props: ${names(empty)}.`);
80
- 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"}.`);
81
- 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.`);
82
- }
83
- return lines;
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
- }
89
- /** The first few, then how many more: a package's exports can name hundreds of files. */
90
- function listOf$1(items, max = 8) {
91
- const shown = items.slice(0, max).join(", ");
92
- return items.length > max ? `${shown}, and ${items.length - max} more` : shown;
93
- }
94
- /** How components are suggested to be imported. */
95
- function importPaths(ds) {
96
- if (ds.config.importPath) return `${ds.config.importPath} (from the config)`;
97
- const counts = /* @__PURE__ */ new Map();
98
- for (const c of ds.roots()) counts.set(c.importPath, (counts.get(c.importPath) ?? 0) + 1);
99
- const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3);
100
- const from = ds.config.imports?.length ? "package exports" : "tsconfig paths or relative paths";
101
- return top.length ? `inferred from ${from}: ${top.map(([p, n]) => `${p} (${n})`).join(", ")}${counts.size > 3 ? `, and ${counts.size - 3} more` : ""}` : `inferred from ${from}`;
102
- }
103
- /** The nearest node_modules at or above `dir`, if any. */
104
- function nodeModulesFor(dir) {
105
- for (let current = path.resolve(dir);; current = path.dirname(current)) {
106
- const candidate = path.join(current, "node_modules");
107
- if (fs.existsSync(candidate)) return candidate;
108
- if (current === path.dirname(current)) return void 0;
109
- }
110
- }
111
- function display(cwd, file) {
112
- const rel = relativePath(cwd, file);
113
- return rel && !rel.startsWith("..") ? rel : file;
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
- }
137
- //#endregion
138
12
  //#region src/init.ts
139
13
  const SCHEMA = "https://unpkg.com/onsystem/schema.json";
140
14
  /** More detected globs than this are files, not folders: zero config keeps them. */
@@ -174,6 +48,7 @@ async function init(configOptions, options, io, runCheck) {
174
48
  const notes = [];
175
49
  const relative = (abs) => relativePath(root, abs);
176
50
  const inherited = options.force ? void 0 : config.designSystemPackage;
51
+ const replaced = options.force ? config.designSystemPackage : void 0;
177
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.`);
178
53
  else if (configOptions.components?.length) {
179
54
  file.components = configOptions.components;
@@ -188,19 +63,22 @@ async function init(configOptions, options, io, runCheck) {
188
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.`);
189
64
  else {
190
65
  file.components = config.components;
191
- if ([...specifiers][0]) file.importPath = [...specifiers][0];
66
+ const [specifier] = specifiers;
67
+ if (specifier && !specifier.includes("*")) file.importPath = specifier;
192
68
  const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d));
193
69
  if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs];
194
70
  }
195
71
  if (tokens.length) file.tokens = tokens;
196
72
  } else if ((await componentFiles(config)).length) {
73
+ const declared = replaced && config.configured;
197
74
  const matching = [];
198
75
  for (const glob of config.components) if ((await componentFiles({
199
76
  ...config,
200
77
  components: [glob]
201
78
  })).length) matching.push(glob);
202
- notes.push(`Found the design system in the default folders: ${matching.join(", ")}.`);
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(", ")}.`);
203
80
  file.components = matching;
81
+ if (declared && config.importPath) file.importPath = config.importPath;
204
82
  const tokens = (await tokenFiles(config)).map(relative);
205
83
  if (tokens.length) file.tokens = tokens;
206
84
  } else {
@@ -238,10 +116,18 @@ async function init(configOptions, options, io, runCheck) {
238
116
  fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
239
117
  written.push(CONFIG_FILE);
240
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;
241
127
  if (designSystem && manifest && name) {
242
128
  written.push(...declarePackage(root, manifest, name, options.force, notes));
243
- const shipped = await unshipped(config, manifest.json);
244
- if (shipped) notes.push(shipped);
129
+ registry = await unshipped(config, manifest.json);
130
+ if (registry) notes.push(...registry.notes);
245
131
  }
246
132
  for (const note of notes) io.stdout(note);
247
133
  if (written.length) io.stdout(`Wrote ${written.map((f) => relativePath(io.cwd, path.join(root, f)) || f).join(", ")}.`);
@@ -293,7 +179,7 @@ async function init(configOptions, options, io, runCheck) {
293
179
  }
294
180
  const commit = [...written, ...baselineWritten ? [BASELINE_FILE] : []];
295
181
  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.`);
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.`);
297
183
  return 0;
298
184
  }
299
185
  const CONFIG_FILE = "onsystem.config.json";
@@ -346,16 +232,82 @@ function declarePackage(root, manifest, name, force, notes) {
346
232
  }
347
233
  /**
348
234
  * 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.
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.
351
239
  */
352
240
  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.`;
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.`;
359
311
  }
360
312
  /** Whether npm's `files` list publishes `file`, a path relative to the package. */
361
313
  function ships(files, file) {
@@ -413,6 +365,10 @@ function consumerGuide(name) {
413
365
  function count$1(n, word) {
414
366
  return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
415
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
+ }
416
372
  //#endregion
417
373
  //#region src/cli.ts
418
374
  const DOCS = "https://onsystem.vercel.app/docs";
@@ -453,6 +409,8 @@ Options
453
409
  --out <file> export: write to this file instead of stdout
454
410
  --max-warnings <n> check: exit 1 when there are more than n warnings
455
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
456
414
  --baseline <file> check: baseline file (default: ${BASELINE_FILE} in the root,
457
415
  used whenever it exists)
458
416
  --ignore-baseline check: report every finding, baseline or not
@@ -544,6 +502,10 @@ async function main(argv, io = defaultIo) {
544
502
  type: "boolean",
545
503
  default: false
546
504
  },
505
+ "prune-baseline": {
506
+ type: "boolean",
507
+ default: false
508
+ },
547
509
  "ignore-baseline": {
548
510
  type: "boolean",
549
511
  default: false
@@ -666,6 +628,10 @@ async function check(patterns, configOptions, values, io) {
666
628
  io.stderr("check: pass --quiet-without-design-system or --require-design-system, not both");
667
629
  return 2;
668
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
+ }
669
635
  if (!patterns.length) {
670
636
  io.stderr(`check: pass files, folders or globs, e.g. ${NAME} check .`);
671
637
  return 2;
@@ -699,6 +665,7 @@ async function check(patterns, configOptions, values, io) {
699
665
  return 0;
700
666
  }
701
667
  const update = values["update-baseline"];
668
+ const prune = values["prune-baseline"];
702
669
  const baselineFile = values.baseline ? path.resolve(io.cwd, values.baseline) : defaultBaseline(selection.root, values["ignore-baseline"] ? void 0 : (text) => {
703
670
  warnDeprecated([text], format, io);
704
671
  });
@@ -707,6 +674,10 @@ async function check(patterns, configOptions, values, io) {
707
674
  io.stderr(`check: baseline not found: ${displayPath(io.cwd, baselineFile)}`);
708
675
  return 2;
709
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
+ }
710
681
  const disabledFor = (file) => {
711
682
  const ds = selection.designSystemOf(file);
712
683
  return new Set(Object.entries(rulesFor(ds.config, relativePath(ds.root, file))).filter(([, rule]) => rule.severity === "off").map(([id]) => id));
@@ -716,6 +687,7 @@ async function check(patterns, configOptions, values, io) {
716
687
  const baselineKey = (file) => relativePath(realRoot, fs.realpathSync.native(file)).normalize("NFC");
717
688
  const results = [];
718
689
  const checked = /* @__PURE__ */ new Map();
690
+ const ran = /* @__PURE__ */ new Set();
719
691
  let baselined = 0;
720
692
  let fixed = 0;
721
693
  for (const file of files) {
@@ -725,9 +697,12 @@ async function check(patterns, configOptions, values, io) {
725
697
  const key = baselineKey(file);
726
698
  const disabled = disabledFor(file);
727
699
  disabledByKey.set(key, disabled);
728
- 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
+ }
729
704
  if (baseline && !update) {
730
- const match = applyBaseline(baseline, key, result, disabled);
705
+ const match = applyBaseline(baseline.entries, key, result, disabled);
731
706
  result = {
732
707
  ...match.result,
733
708
  baselined: match.baselined
@@ -744,22 +719,51 @@ async function check(patterns, configOptions, values, io) {
744
719
  if (elsewhere) notes.push(`${plural(elsewhere, "image or email file")} not checked`);
745
720
  const unchecked = uncheckedNotice(results);
746
721
  if (update) {
747
- const next = updateBaseline(baseline, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
748
- 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
+ });
749
731
  const counts = countBaseline(next);
750
- 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);
751
735
  for (const text of selection.notices) io.stderr(text);
752
736
  if (unchecked) io.stderr(unchecked);
753
737
  return 0;
754
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
+ }
755
756
  const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
756
757
  color: io.color,
757
758
  uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
758
759
  baselined: baseline ? baselined : void 0,
759
760
  notes,
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
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
761
762
  });
762
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);
763
767
  for (const text of [...selection.notices, unchecked]) {
764
768
  if (!text) continue;
765
769
  if (format === "pretty") io.stdout(text);
@@ -851,7 +855,7 @@ async function selectInProject(config, patterns, values, format, io) {
851
855
  excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
852
856
  gitignored: ignored.size
853
857
  };
854
- let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
858
+ let files = oncePerFile([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
855
859
  if (!files.length && !all.length) {
856
860
  io.stderr(`check: no files match ${patterns.join(" ")}`);
857
861
  return 2;
@@ -863,6 +867,7 @@ async function selectInProject(config, patterns, values, format, io) {
863
867
  const notice = ds.notice();
864
868
  if (notice && values["require-design-system"] && !ds.hasDesignSystem()) {
865
869
  io.stderr(`check --require-design-system: ${notice}`);
870
+ for (const text of notReadNotices([{ ds }])) io.stderr(text);
866
871
  return 2;
867
872
  }
868
873
  let skipped = 0;
@@ -877,7 +882,7 @@ async function selectInProject(config, patterns, values, format, io) {
877
882
  designSystemOf: () => ds,
878
883
  root: ds.root,
879
884
  notes: scopeNotes(skipped, left),
880
- notices: notice ? [notice] : [],
885
+ notices: [...notice ? [notice] : [], ...notReadNotices([{ ds }])],
881
886
  skipped,
882
887
  testsLeftOut: left.tests
883
888
  };
@@ -927,7 +932,7 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
927
932
  excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
928
933
  gitignored: ignored.size
929
934
  };
930
- let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
935
+ let files = oncePerFile([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
931
936
  if (!files.length && !all.length) {
932
937
  io.stderr(`check: no files match ${patterns.join(" ")}`);
933
938
  return 2;
@@ -979,6 +984,14 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
979
984
  gaps.set(notice, gap);
980
985
  }
981
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));
982
995
  const used = unique(files.map((file) => project(file).root)).length;
983
996
  return {
984
997
  files,
@@ -1041,6 +1054,8 @@ async function inspect(configOptions, values, io) {
1041
1054
  }
1042
1055
  if (values.explain) lines.push("", ...explainProject(ds, io.cwd));
1043
1056
  io.stdout(lines.join("\n"));
1057
+ const missing = values.explain ? void 0 : missingDependencies([{ ds }]);
1058
+ if (missing) io.stderr(missing);
1044
1059
  return 0;
1045
1060
  }
1046
1061
  /**
@@ -1048,12 +1063,16 @@ async function inspect(configOptions, values, io) {
1048
1063
  * project depends on, including those the config's `libraries` leaves out.
1049
1064
  */
1050
1065
  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)}`)];
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
+ ];
1057
1076
  }
1058
1077
  /**
1059
1078
  * `export --format dtcg|design-md`: the project's tokens as a DTCG 2025.10 file or a
@@ -1149,6 +1168,11 @@ async function inspectWorkspace(workspace, values, io) {
1149
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>`);
1150
1169
  if (values.explain) lines.push("", ...explainWorkspace(workspace, loaded, io.cwd));
1151
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);
1152
1176
  return 0;
1153
1177
  }
1154
1178
  /** Why the root is read as a monorepo, then each project's explanation. */
@@ -1259,6 +1283,50 @@ function gitIgnored(cwd, files) {
1259
1283
  function count(n, word) {
1260
1284
  return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
1261
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
+ }
1262
1330
  /** Quotes a pattern for the hint when the shell would expand or split it. */
1263
1331
  function shellQuote(pattern) {
1264
1332
  return /^[\w./@-]+$/.test(pattern) ? pattern : `"${pattern.replace(/(["\\$`])/g, "\\$1")}"`;
@@ -1271,6 +1339,19 @@ function realPath(file) {
1271
1339
  return file;
1272
1340
  }
1273
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
+ }
1274
1355
  /** Relative to cwd when the file is below it, absolute otherwise. */
1275
1356
  function displayPath(cwd, file) {
1276
1357
  const rel = path.relative(cwd, file);
@@ -391,6 +391,12 @@ interface ResolvedConfig {
391
391
  */
392
392
  designSystemExclude?: string[] | undefined;
393
393
  tokens: TokenSourceConfig[];
394
+ /**
395
+ * `tokens` comes from zero config (detection or the default paths), not the config or
396
+ * `--tokens`: the stylesheets those files import are read too, and so are the ones in a
397
+ * shared Tailwind preset's workspace package that set the colors the config maps.
398
+ */
399
+ tokensDetected?: boolean | undefined;
394
400
  docs: string[];
395
401
  tsconfig?: string | undefined;
396
402
  importPath?: string | undefined;
@@ -418,6 +424,8 @@ interface ResolvedConfig {
418
424
  libraryIds?: false | LibraryId[] | undefined;
419
425
  /** Libraries the project depends on that `libraries` leaves out, so they are not read. */
420
426
  librariesOff?: LibrarySource[] | undefined;
427
+ /** Component libraries the project depends on that onsystem does not read yet (Chakra UI). */
428
+ librariesUnread?: LibrarySource[] | undefined;
421
429
  elements: Record<string, string>;
422
430
  /** `check` lints the component files themselves, which it skips by default. */
423
431
  includeDesignSystem: boolean;