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/README.md +41 -17
- package/dist/cli.js +476 -126
- package/dist/{types-DJtccu3A.d.ts → config-1XSTYI44.d.ts} +271 -167
- package/dist/{design-system-CnEv5sIL.js → design-system-C4bhqLLw.js} +4426 -679
- package/dist/eslint-worker.d.ts +1 -1
- package/dist/eslint-worker.js +9 -2
- package/dist/eslint.js +66 -5
- package/dist/index.d.ts +74 -1
- package/dist/index.js +3 -3
- package/dist/{stdio-DjfkiDMV.js → stdio-BojAQ6JP.js} +230 -24
- package/dist/{workspace-DzWQ4L8R.js → workspace-Z0tZf_Dv.js} +44 -10
- package/package.json +11 -10
- package/schema.json +19 -1
package/dist/cli.js
CHANGED
|
@@ -1,109 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { t as serveStdio } from "./stdio-
|
|
4
|
-
import { r as loadTarget } from "./workspace-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
|
|
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,
|
|
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() :
|
|
207
|
-
if (answer ===
|
|
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
|
-
|
|
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
|
-
|
|
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 "${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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} + ${
|
|
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} + ${
|
|
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);
|