onsystem 0.6.0 → 0.8.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 +29 -22
- package/dist/cli.js +629 -221
- package/dist/{config-lbfW91qB.d.ts → config-kYGiD5oW.d.ts} +15 -0
- package/dist/{design-system-A3pRX3Ka.js → design-system-Dmcv7eHR.js} +2641 -331
- package/dist/eslint-worker.d.ts +1 -1
- package/dist/eslint-worker.js +3 -5
- package/dist/eslint.js +66 -5
- package/dist/index.d.ts +35 -3
- package/dist/index.js +2 -2
- package/dist/{stdio-CEChNFLR.js → stdio-MTS6QAgo.js} +241 -9
- package/dist/{workspace-CHvUwgz9.js → workspace-CM1RyqZA.js} +1 -1
- package/package.json +9 -9
- package/schema.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,140 +1,15 @@
|
|
|
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 isInside, D as updateBaseline, E as unrecordedRules, Et as toPosix, H as CONFIG_FILES, J as LEGACY_CONFIG_FILES, N as toDesignMd, O as writeBaseline, Ot as readProjectConfig, P as toDtcg, Q as excludedBy, S as countBaseline, St as SCRIPT_FILE, T as rulesRun, Tt as relativePath, U as ConfigError, W as DEFAULT_DOCS, Y as RULE_IDS, _ as NAME, b as LEGACY_BASELINE_FILE, c as silentLogger, ct as notReadYet, dt as declaredConfig, ft as detectProject, g as formatDiagnostics, h as escapeData, i as componentFiles, l as stderrLogger, lt as PACKAGE_FIELD, m as uncheckedNotice, mt as isDesignSystemName, nt as rulesFor, o as loadDesignSystem, s as tokenFiles, st as libraryTitle, v as VERSION, w as readBaseline, wt as matchesGlob, x as applyBaseline, xt as unique, y as BASELINE_FILE, yt as plural } from "./design-system-Dmcv7eHR.js";
|
|
3
|
+
import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-MTS6QAgo.js";
|
|
4
|
+
import { r as loadTarget } from "./workspace-CM1RyqZA.js";
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { escapePath, glob } from "tinyglobby";
|
|
8
|
+
import ts from "typescript";
|
|
8
9
|
import fsp from "node:fs/promises";
|
|
9
10
|
import { spawnSync } from "node:child_process";
|
|
10
11
|
import readline from "node:readline/promises";
|
|
11
12
|
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
13
|
//#region src/init.ts
|
|
139
14
|
const SCHEMA = "https://unpkg.com/onsystem/schema.json";
|
|
140
15
|
/** More detected globs than this are files, not folders: zero config keeps them. */
|
|
@@ -152,6 +27,9 @@ const GUIDE_FILE = "ONSYSTEM.md";
|
|
|
152
27
|
* every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those
|
|
153
28
|
* apps' teams how to add the agent tools and the CI check. In an app that takes on such a
|
|
154
29
|
* package's config, it writes no config of its own unless --force says to.
|
|
30
|
+
*
|
|
31
|
+
* In a terminal, it offers to add a short section on onsystem to the project's AGENTS.md or
|
|
32
|
+
* CLAUDE.md, for coding agents.
|
|
155
33
|
*/
|
|
156
34
|
async function init(configOptions, options, io, runCheck) {
|
|
157
35
|
const { config, workspace } = await loadTarget({
|
|
@@ -174,6 +52,8 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
174
52
|
const notes = [];
|
|
175
53
|
const relative = (abs) => relativePath(root, abs);
|
|
176
54
|
const inherited = options.force ? void 0 : config.designSystemPackage;
|
|
55
|
+
const replaced = options.force ? config.designSystemPackage : void 0;
|
|
56
|
+
let found = false;
|
|
177
57
|
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
58
|
else if (configOptions.components?.length) {
|
|
179
59
|
file.components = configOptions.components;
|
|
@@ -188,19 +68,24 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
188
68
|
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
69
|
else {
|
|
190
70
|
file.components = config.components;
|
|
191
|
-
|
|
71
|
+
found = true;
|
|
72
|
+
const [specifier] = specifiers;
|
|
73
|
+
if (specifier && !specifier.includes("*")) file.importPath = specifier;
|
|
192
74
|
const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d));
|
|
193
75
|
if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs];
|
|
194
76
|
}
|
|
195
77
|
if (tokens.length) file.tokens = tokens;
|
|
196
78
|
} else if ((await componentFiles(config)).length) {
|
|
79
|
+
const declared = replaced && config.configured;
|
|
197
80
|
const matching = [];
|
|
198
81
|
for (const glob of config.components) if ((await componentFiles({
|
|
199
82
|
...config,
|
|
200
83
|
components: [glob]
|
|
201
84
|
})).length) matching.push(glob);
|
|
202
|
-
notes.push(`Found the design system in the default folders: ${matching.join(", ")}.`);
|
|
85
|
+
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
86
|
file.components = matching;
|
|
87
|
+
found = true;
|
|
88
|
+
if (declared && config.importPath) file.importPath = config.importPath;
|
|
204
89
|
const tokens = (await tokenFiles(config)).map(relative);
|
|
205
90
|
if (tokens.length) file.tokens = tokens;
|
|
206
91
|
} else {
|
|
@@ -232,16 +117,25 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
232
117
|
const importPath = (await ask("How do apps import them? A package name such as @acme/ui, or leave it empty for tsconfig paths: ")).trim();
|
|
233
118
|
if (importPath) file.importPath = importPath;
|
|
234
119
|
}
|
|
120
|
+
if (!inherited && !workspace) notes.push(...await registryCode(config, file, found));
|
|
235
121
|
const written = [];
|
|
236
122
|
if (!inherited) {
|
|
237
123
|
const out = path.join(root, CONFIG_FILE);
|
|
238
124
|
fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
|
|
239
125
|
written.push(CONFIG_FILE);
|
|
240
126
|
}
|
|
127
|
+
if (replaced) {
|
|
128
|
+
const own = (await loadTarget({
|
|
129
|
+
...configOptions,
|
|
130
|
+
config: void 0
|
|
131
|
+
})).config;
|
|
132
|
+
notes.push(await replacedNote(replaced, config, own, io.cwd));
|
|
133
|
+
}
|
|
134
|
+
let registry;
|
|
241
135
|
if (designSystem && manifest && name) {
|
|
242
136
|
written.push(...declarePackage(root, manifest, name, options.force, notes));
|
|
243
|
-
|
|
244
|
-
if (
|
|
137
|
+
registry = await unshipped(config, manifest.json);
|
|
138
|
+
if (registry) notes.push(...registry.notes);
|
|
245
139
|
}
|
|
246
140
|
for (const note of notes) io.stdout(note);
|
|
247
141
|
if (written.length) io.stdout(`Wrote ${written.map((f) => relativePath(io.cwd, path.join(root, f)) || f).join(", ")}.`);
|
|
@@ -291,12 +185,93 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
291
185
|
baselineWritten = fs.existsSync(path.join(root, BASELINE_FILE));
|
|
292
186
|
} else io.stdout(`No baseline written; \`${NAME} check . --update-baseline\` records one later.`);
|
|
293
187
|
}
|
|
294
|
-
const
|
|
188
|
+
const instructed = await agentRules(root, ask, io);
|
|
189
|
+
const commit = [
|
|
190
|
+
...written,
|
|
191
|
+
...baselineWritten ? [BASELINE_FILE] : [],
|
|
192
|
+
...instructed
|
|
193
|
+
];
|
|
295
194
|
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.`);
|
|
195
|
+
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
196
|
return 0;
|
|
298
197
|
}
|
|
299
198
|
const CONFIG_FILE = "onsystem.config.json";
|
|
199
|
+
/** The instruction files agents read, which `init` offers to add onsystem's section to. */
|
|
200
|
+
const AGENT_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
201
|
+
const RULES_BEGIN = "<!-- BEGIN:onsystem-agent-rules -->";
|
|
202
|
+
const RULES_END = "<!-- END:onsystem-agent-rules -->";
|
|
203
|
+
/** onsystem's section of AGENTS.md or CLAUDE.md: the MCP tools, and the CLI for agents without them. */
|
|
204
|
+
const AGENT_RULES = [
|
|
205
|
+
RULES_BEGIN,
|
|
206
|
+
"# Design system",
|
|
207
|
+
"",
|
|
208
|
+
`This project's UI is checked against its design system by ${NAME}.`,
|
|
209
|
+
"",
|
|
210
|
+
`- Before writing UI, look up its components, props, variants and tokens with the ${NAME} tools (\`list_components\`, \`get_component\`, \`get_tokens\`).`,
|
|
211
|
+
`- After changing a \`.tsx\` or \`.jsx\` file, run \`check_ui\` on it and fix every error. Without the ${NAME} tools, run \`npx -y ${NAME} check <file>\` instead.`,
|
|
212
|
+
RULES_END
|
|
213
|
+
].join("\n");
|
|
214
|
+
/**
|
|
215
|
+
* Offers to add onsystem's section to the AGENTS.md and CLAUDE.md in the root, between markers a
|
|
216
|
+
* later run updates in place, so agents that read them check the UI they write, through the CLI
|
|
217
|
+
* when they have no MCP config. A CLAUDE.md that imports AGENTS.md (an `@AGENTS.md` line) is left
|
|
218
|
+
* alone: Claude Code reads the section there. Asked only in a terminal, never with --yes; nothing
|
|
219
|
+
* is asked when neither file exists or the section is current. Returns the files it wrote.
|
|
220
|
+
*/
|
|
221
|
+
async function agentRules(root, ask, io) {
|
|
222
|
+
if (!ask) return [];
|
|
223
|
+
const found = AGENT_FILES.flatMap((name) => {
|
|
224
|
+
const file = path.join(root, name);
|
|
225
|
+
try {
|
|
226
|
+
return [{
|
|
227
|
+
name,
|
|
228
|
+
file,
|
|
229
|
+
text: fs.readFileSync(file, "utf8")
|
|
230
|
+
}];
|
|
231
|
+
} catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
const imports = (text) => /^\s*@(?:\.\/)?AGENTS\.md\s*$/m.test(text);
|
|
236
|
+
const changes = found.filter((f) => !(f.name === "CLAUDE.md" && imports(f.text) && found.length > 1)).map((f) => ({
|
|
237
|
+
...f,
|
|
238
|
+
next: withAgentRules(f.text)
|
|
239
|
+
})).filter((f) => f.next !== f.text);
|
|
240
|
+
if (!changes.length) return [];
|
|
241
|
+
const names = andList(changes.map((f) => f.name));
|
|
242
|
+
const update = changes.every((f) => rulesSection(f.text) !== void 0);
|
|
243
|
+
const answer = (await ask(update ? `Update the onsystem section in ${names}? [Y/n] ` : `Add a short onsystem section to ${names}, so coding agents check the UI they write? [Y/n] `)).trim().toLowerCase();
|
|
244
|
+
if (answer !== "" && !answer.startsWith("y")) {
|
|
245
|
+
io.stdout(`${names} ${changes.length === 1 ? "was" : "were"} left as ${changes.length === 1 ? "it is" : "they are"}.`);
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
for (const f of changes) fs.writeFileSync(f.file, f.next);
|
|
249
|
+
io.stdout(update ? `Updated the onsystem section in ${names}.` : `Added an onsystem section to ${names}: it tells agents to run check_ui, or \`npx -y ${NAME} check <file>\` without the ${NAME} tools, on the UI files they change.`);
|
|
250
|
+
return changes.map((f) => f.name);
|
|
251
|
+
}
|
|
252
|
+
/** `text` with onsystem's section: replaced between its markers, or added at the end. */
|
|
253
|
+
function withAgentRules(text) {
|
|
254
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
255
|
+
const rules = AGENT_RULES.replaceAll("\n", eol);
|
|
256
|
+
const found = rulesSection(text);
|
|
257
|
+
if (found) return `${text.slice(0, found.start)}${rules}${text.slice(found.end)}`;
|
|
258
|
+
if (!text.trim()) return `${rules}${eol}`;
|
|
259
|
+
return `${text.endsWith("\n") ? text : `${text}${eol}`}${eol}${rules}${eol}`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Where onsystem's section is in `text`, from its BEGIN marker to the end of its END marker: the
|
|
263
|
+
* first END with a BEGIN before it, and the BEGIN nearest that END. A BEGIN whose END was deleted
|
|
264
|
+
* is then not a section, so the text after it, which may be the user's, is never replaced.
|
|
265
|
+
*/
|
|
266
|
+
function rulesSection(text) {
|
|
267
|
+
for (let end = text.indexOf(RULES_END); end !== -1; end = text.indexOf(RULES_END, end + 1)) {
|
|
268
|
+
const start = text.lastIndexOf(RULES_BEGIN, end);
|
|
269
|
+
if (start !== -1) return {
|
|
270
|
+
start,
|
|
271
|
+
end: end + 33
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
300
275
|
function readManifest(root) {
|
|
301
276
|
const file = path.join(root, "package.json");
|
|
302
277
|
try {
|
|
@@ -346,16 +321,189 @@ function declarePackage(root, manifest, name, force, notes) {
|
|
|
346
321
|
}
|
|
347
322
|
/**
|
|
348
323
|
* 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
|
|
350
|
-
*
|
|
324
|
+
* files its `files` list leaves out of the published package, and those outside the package
|
|
325
|
+
* (`../tailwind-config/theme.css`), which an installed package's config cannot reach. Undefined
|
|
326
|
+
* for a private package, or when they find every file. Without a `files` list, npm publishes
|
|
327
|
+
* everything in the package.
|
|
351
328
|
*/
|
|
352
329
|
async function unshipped(config, json) {
|
|
353
|
-
if (json.private === true
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
330
|
+
if (json.private === true) return void 0;
|
|
331
|
+
const name = typeof json.name === "string" ? json.name : "the package";
|
|
332
|
+
const files = Array.isArray(json.files) ? json.files : void 0;
|
|
333
|
+
const relative = (abs) => abs.map((f) => relativePath(config.root, f));
|
|
334
|
+
const components = relative(await componentFiles(config));
|
|
335
|
+
const tokens = relative(await tokenFiles(config));
|
|
336
|
+
const outside = (f) => f.startsWith("../");
|
|
337
|
+
const left = (list) => files ? list.filter((f) => !outside(f) && !ships(files, f)) : [];
|
|
338
|
+
const missing = {
|
|
339
|
+
components: left(components),
|
|
340
|
+
tokens: left(tokens)
|
|
341
|
+
};
|
|
342
|
+
const away = {
|
|
343
|
+
components: components.filter(outside),
|
|
344
|
+
tokens: tokens.filter(outside)
|
|
345
|
+
};
|
|
346
|
+
const noComponents = components.length > 0 && missing.components.length + away.components.length === components.length;
|
|
347
|
+
const lost = (lists, one) => {
|
|
348
|
+
const c = lists.components.length;
|
|
349
|
+
const t = lists.tokens.length;
|
|
350
|
+
const kinds = andList([...c ? [count$1(c, "component file")] : [], ...t ? [count$1(t, "token file")] : []]);
|
|
351
|
+
const what = andList([...c ? ["components"] : [], ...t ? ["tokens"] : []], "or");
|
|
352
|
+
const checks = c && noComponents ? `: they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` : "";
|
|
353
|
+
return `(${kinds}). Those apps do not know the ${what} in ${one ? "it" : "them"}${checks}.`;
|
|
354
|
+
};
|
|
355
|
+
const notes = [];
|
|
356
|
+
const top = (f) => f.includes("/") ? `${f.slice(0, f.indexOf("/"))}/` : f;
|
|
357
|
+
const folders = [...new Set([...missing.components, ...missing.tokens].map(top))];
|
|
358
|
+
if (folders.length) {
|
|
359
|
+
const one = folders.length === 1;
|
|
360
|
+
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.`);
|
|
361
|
+
}
|
|
362
|
+
const beyond = [...away.components, ...away.tokens];
|
|
363
|
+
if (beyond.length) {
|
|
364
|
+
const one = beyond.length === 1;
|
|
365
|
+
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.`);
|
|
366
|
+
}
|
|
367
|
+
if (!notes.length) return void 0;
|
|
368
|
+
return {
|
|
369
|
+
notes,
|
|
370
|
+
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])}`
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Said when --force writes a config in an app that took on a design-system package's: the new
|
|
375
|
+
* one is read instead, and what it does not take from the package. `taken` is the config the
|
|
376
|
+
* app took on, `own` the one it reads now.
|
|
377
|
+
*/
|
|
378
|
+
async function replacedNote(pkg, taken, own, cwd) {
|
|
379
|
+
const lacks = async (list) => {
|
|
380
|
+
const kept = new Set((await list(own)).map((f) => path.resolve(f)));
|
|
381
|
+
return (await list(taken)).some((f) => !kept.has(path.resolve(f)));
|
|
382
|
+
};
|
|
383
|
+
const components = await lacks(componentFiles);
|
|
384
|
+
const importPath = taken.importPath !== void 0 && own.importPath === void 0;
|
|
385
|
+
const tokens = await lacks(tokenFiles);
|
|
386
|
+
const declared = relativePath(cwd, pkg.config) || pkg.config;
|
|
387
|
+
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.`;
|
|
388
|
+
if (!components && !importPath && !tokens) return replaced;
|
|
389
|
+
const effects = andList([
|
|
390
|
+
...components ? [`does not know ${pkg.name}'s components`] : [],
|
|
391
|
+
...importPath && !components ? [`does not match imports from ${pkg.name} to its components`] : [],
|
|
392
|
+
...tokens ? [`does not read ${pkg.name}'s tokens`] : []
|
|
393
|
+
]);
|
|
394
|
+
const fields = [
|
|
395
|
+
...components ? ["\"components\""] : [],
|
|
396
|
+
...importPath || components && taken.importPath !== void 0 ? ["\"importPath\""] : [],
|
|
397
|
+
...tokens ? ["\"tokens\""] : []
|
|
398
|
+
];
|
|
399
|
+
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.`;
|
|
400
|
+
}
|
|
401
|
+
const REGISTRY_DOCS = "https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries";
|
|
402
|
+
/**
|
|
403
|
+
* Sets apart the code `npx shadcn add` copies from the registries components.json names
|
|
404
|
+
* (`"registries": { "@signoff-ui": … }`) into a folder of its own under the components alias
|
|
405
|
+
* (`components/signoff-ui`). When `found` says "components" holds what zero config found, and
|
|
406
|
+
* no `importPath` names a package for them, each such folder joins it: `check` and the hook skip
|
|
407
|
+
* that code, as they skip components/ui, and the app's code is checked against its components.
|
|
408
|
+
* A folder with no .tsx or .jsx file yet joins it too, and so does one that does not exist yet:
|
|
409
|
+
* a registry is often named in components.json before its first `shadcn add`, and an item of
|
|
410
|
+
* only .ts files (signoff-ui's ag-ui) makes the folder before a component comes. Otherwise the
|
|
411
|
+
* notes name the folders `check` reads as the app's own code, and what to add to set them apart.
|
|
412
|
+
* Folders the components already take in are left alone. Returns what to tell the user.
|
|
413
|
+
*/
|
|
414
|
+
async function registryCode(config, file, found) {
|
|
415
|
+
const root = config.root;
|
|
416
|
+
const folders = registryFolders(root, config.tsconfig);
|
|
417
|
+
if (!folders.length) return [];
|
|
418
|
+
const globs = Array.isArray(file.components) ? file.components : config.components;
|
|
419
|
+
const taken = new Set(await componentFiles({
|
|
420
|
+
...config,
|
|
421
|
+
components: globs
|
|
422
|
+
}));
|
|
423
|
+
const copied = [];
|
|
424
|
+
const pending = [];
|
|
425
|
+
const added = [];
|
|
426
|
+
for (const { namespace, dir } of folders) {
|
|
427
|
+
const glob = `${escapePath(dir)}/**/*.{tsx,jsx}`;
|
|
428
|
+
const files = await componentFiles({
|
|
429
|
+
...config,
|
|
430
|
+
components: [glob]
|
|
431
|
+
});
|
|
432
|
+
const folder = {
|
|
433
|
+
namespace,
|
|
434
|
+
dir,
|
|
435
|
+
glob,
|
|
436
|
+
files: files.length
|
|
437
|
+
};
|
|
438
|
+
if (files.some((f) => !taken.has(f))) copied.push(folder);
|
|
439
|
+
else if (files.length || globs.some((g) => matchesGlob(`${dir}/index.tsx`, g))) continue;
|
|
440
|
+
else pending.push(folder);
|
|
441
|
+
added.push(glob);
|
|
442
|
+
}
|
|
443
|
+
const add = found && file.importPath === void 0;
|
|
444
|
+
if (add && added.length) file.components = [...globs, ...added];
|
|
445
|
+
const listed = Array.isArray(file.components) && file.importPath === void 0;
|
|
446
|
+
const how = (list) => listed ? `add ${andList(list.map((f) => `"${f.glob}"`))} to "components" (${REGISTRY_DOCS})` : `turn the rules off for ${andList(list.map((f) => `"${escapePath(f.dir)}/**"`))} in "overrides" (${REGISTRY_DOCS})`;
|
|
447
|
+
const notes = [];
|
|
448
|
+
if (copied.length) {
|
|
449
|
+
const one = copied.length === 1;
|
|
450
|
+
notes.push(add ? `Added ${andList(copied.map((f) => `${f.dir} (${f.namespace}, ${count$1(f.files, "file")})`))} to "components": ${one ? "it holds" : "they hold"} the code \`shadcn add\` copied from ${one ? "that registry" : "those registries"} in components.json. \`check\` and the hook skip it as design-system code, and your code is checked against every component its files export, helpers included, so a native element such as <img> can be told to use one of them. To leave the helpers out, see ${REGISTRY_DOCS}; to check that code as your own, take ${one ? "the folder" : "the folders"} out of "components".` : `components.json names the ${one ? "registry" : "registries"} ${andList(copied.map((f) => f.namespace))}, which \`shadcn add\` copies into ${andList(copied.map((f) => f.dir))}: \`check\` reads ${one ? "its" : "their"} code as your own. To set it apart, ${how(copied)}.`);
|
|
451
|
+
}
|
|
452
|
+
if (pending.length) {
|
|
453
|
+
const one = pending.length === 1;
|
|
454
|
+
const state = pending.some((f) => isDirectory(path.join(root, f.dir))) ? `${one ? "holds" : "hold"} no .tsx or .jsx file yet` : `${one ? "does" : "do"} not exist yet`;
|
|
455
|
+
notes.push(add ? `${copied.length ? "Also added" : "Added"} ${andList(pending.map((f) => `${f.dir} (${f.namespace})`))} to "components", though ${one ? "it" : "they"} ${state}: \`check\` and the hook skip the code \`shadcn add\` copies there from ${one ? "that registry" : "those registries"} in components.json. Code a registry copies somewhere else, such as loose into ${path.posix.dirname(pending[0]?.dir ?? "")}/, is checked as your own (${REGISTRY_DOCS}).` : `components.json ${copied.length ? "also " : ""}names ${andList(pending.map((f) => f.namespace))}, and ${andList(pending.map((f) => f.dir))} ${state}: once \`shadcn add\` copies a .tsx or .jsx file there, \`check\` reads it as your own. To set it apart, ${how(pending)}.`);
|
|
456
|
+
}
|
|
457
|
+
return notes;
|
|
458
|
+
}
|
|
459
|
+
function isDirectory(dir) {
|
|
460
|
+
try {
|
|
461
|
+
return fs.statSync(dir).isDirectory();
|
|
462
|
+
} catch {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The folders, relative to the root, that `npx shadcn add` copies the code of the registries
|
|
468
|
+
* components.json names into: the components alias, resolved through tsconfig `paths`, and the
|
|
469
|
+
* namespace without its `@` (`@/components` and `@signoff-ui` → `components/signoff-ui`).
|
|
470
|
+
*/
|
|
471
|
+
function registryFolders(root, tsconfig) {
|
|
472
|
+
let json;
|
|
473
|
+
try {
|
|
474
|
+
json = JSON.parse(fs.readFileSync(path.join(root, "components.json"), "utf8"));
|
|
475
|
+
} catch {
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
const alias = json?.aliases?.components;
|
|
479
|
+
const registries = json?.registries;
|
|
480
|
+
if (typeof alias !== "string" || !registries || typeof registries !== "object") return [];
|
|
481
|
+
const namespaces = Object.keys(registries).filter((n) => /^@[a-z0-9][\w-]*$/i.test(n)).sort();
|
|
482
|
+
const base = namespaces.length ? aliasFolder(alias, readProjectConfig(root, tsconfig)) : void 0;
|
|
483
|
+
if (!base || !isInside(root, base)) return [];
|
|
484
|
+
return namespaces.map((namespace) => ({
|
|
485
|
+
namespace,
|
|
486
|
+
dir: relativePath(root, path.join(base, namespace.slice(1)))
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* A components.json alias (`@/components`) through tsconfig `paths` (`"@/*": ["./*"]`), as zero
|
|
491
|
+
* config resolves the ui alias, when the folder exists.
|
|
492
|
+
*/
|
|
493
|
+
function aliasFolder(alias, project) {
|
|
494
|
+
for (const [pattern, targets] of Object.entries(project.paths)) {
|
|
495
|
+
const star = pattern.indexOf("*");
|
|
496
|
+
const prefix = star === -1 ? pattern : pattern.slice(0, star);
|
|
497
|
+
const suffix = star === -1 ? "" : pattern.slice(star + 1);
|
|
498
|
+
if (!(star === -1 ? alias === pattern : alias.startsWith(prefix) && alias.endsWith(suffix) && alias.length >= prefix.length + suffix.length)) continue;
|
|
499
|
+
const middle = star === -1 ? "" : alias.slice(prefix.length, alias.length - suffix.length);
|
|
500
|
+
for (const target of targets) {
|
|
501
|
+
const dir = path.resolve(project.pathsBase, target.replace("*", middle));
|
|
502
|
+
try {
|
|
503
|
+
if (fs.statSync(dir).isDirectory()) return dir;
|
|
504
|
+
} catch {}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
359
507
|
}
|
|
360
508
|
/** Whether npm's `files` list publishes `file`, a path relative to the package. */
|
|
361
509
|
function ships(files, file) {
|
|
@@ -413,6 +561,10 @@ function consumerGuide(name) {
|
|
|
413
561
|
function count$1(n, word) {
|
|
414
562
|
return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
|
|
415
563
|
}
|
|
564
|
+
/** `a`, `a and b`, `a, b and c` (or `or`). */
|
|
565
|
+
function andList(items, conjunction = "and") {
|
|
566
|
+
return items.length > 1 ? `${items.slice(0, -1).join(", ")} ${conjunction} ${items.at(-1) ?? ""}` : items.join("");
|
|
567
|
+
}
|
|
416
568
|
//#endregion
|
|
417
569
|
//#region src/cli.ts
|
|
418
570
|
const DOCS = "https://onsystem.vercel.app/docs";
|
|
@@ -453,6 +605,8 @@ Options
|
|
|
453
605
|
--out <file> export: write to this file instead of stdout
|
|
454
606
|
--max-warnings <n> check: exit 1 when there are more than n warnings
|
|
455
607
|
--update-baseline check: record the current findings as accepted, and exit 0
|
|
608
|
+
--prune-baseline check: drop baseline entries that no longer occur, and
|
|
609
|
+
accept nothing new
|
|
456
610
|
--baseline <file> check: baseline file (default: ${BASELINE_FILE} in the root,
|
|
457
611
|
used whenever it exists)
|
|
458
612
|
--ignore-baseline check: report every finding, baseline or not
|
|
@@ -461,6 +615,9 @@ Options
|
|
|
461
615
|
files, which are skipped by default
|
|
462
616
|
--include-tests check: also lint tests and stories (*.test.tsx,
|
|
463
617
|
*.spec.tsx, *.stories.tsx) in folders and globs
|
|
618
|
+
--skip-excluded check: leave out files named outright that the config's
|
|
619
|
+
exclude covers, as folders and globs leave them out
|
|
620
|
+
(the Claude Code hook passes it)
|
|
464
621
|
--quiet-without-design-system
|
|
465
622
|
check: print nothing and exit 0 when the project has no
|
|
466
623
|
design-system components and no config (tokens alone
|
|
@@ -468,7 +625,8 @@ Options
|
|
|
468
625
|
many projects
|
|
469
626
|
--require-design-system
|
|
470
627
|
check: exit 2 when no components or no color tokens
|
|
471
|
-
are found,
|
|
628
|
+
are found, or when the folders and globs given leave
|
|
629
|
+
no file to check, so CI cannot pass by checking nothing
|
|
472
630
|
--explain inspect: every candidate zero config looked at and why
|
|
473
631
|
it was taken or not, the resolved config, unresolved
|
|
474
632
|
tokens, and components whose props did not resolve
|
|
@@ -544,6 +702,10 @@ async function main(argv, io = defaultIo) {
|
|
|
544
702
|
type: "boolean",
|
|
545
703
|
default: false
|
|
546
704
|
},
|
|
705
|
+
"prune-baseline": {
|
|
706
|
+
type: "boolean",
|
|
707
|
+
default: false
|
|
708
|
+
},
|
|
547
709
|
"ignore-baseline": {
|
|
548
710
|
type: "boolean",
|
|
549
711
|
default: false
|
|
@@ -557,6 +719,10 @@ async function main(argv, io = defaultIo) {
|
|
|
557
719
|
type: "boolean",
|
|
558
720
|
default: false
|
|
559
721
|
},
|
|
722
|
+
"skip-excluded": {
|
|
723
|
+
type: "boolean",
|
|
724
|
+
default: false
|
|
725
|
+
},
|
|
560
726
|
"require-design-system": {
|
|
561
727
|
type: "boolean",
|
|
562
728
|
default: false
|
|
@@ -666,6 +832,10 @@ async function check(patterns, configOptions, values, io) {
|
|
|
666
832
|
io.stderr("check: pass --quiet-without-design-system or --require-design-system, not both");
|
|
667
833
|
return 2;
|
|
668
834
|
}
|
|
835
|
+
for (const other of ["update-baseline", "ignore-baseline"]) if (values["prune-baseline"] && values[other]) {
|
|
836
|
+
io.stderr(`check: pass --prune-baseline or --${other}, not both`);
|
|
837
|
+
return 2;
|
|
838
|
+
}
|
|
669
839
|
if (!patterns.length) {
|
|
670
840
|
io.stderr(`check: pass files, folders or globs, e.g. ${NAME} check .`);
|
|
671
841
|
return 2;
|
|
@@ -690,15 +860,34 @@ async function check(patterns, configOptions, values, io) {
|
|
|
690
860
|
warnDeprecated(configWarnings(target), format, io);
|
|
691
861
|
const selection = target.workspace ? await selectInWorkspace(target.workspace, patterns, values, format, io) : await selectInProject(target.config, patterns, values, format, io);
|
|
692
862
|
if (typeof selection === "number") return selection;
|
|
863
|
+
const command = (flag) => checkCommand(patterns, configOptions, values, flag);
|
|
864
|
+
if ("noMatch" in selection) {
|
|
865
|
+
const jsx = jsxNotice(selection, io.cwd, command);
|
|
866
|
+
io.stderr(jsx ? `check: nothing to check in ${patterns.join(" ")}. ${jsx}` : `check: no files match ${patterns.join(" ")}`);
|
|
867
|
+
return 2;
|
|
868
|
+
}
|
|
693
869
|
const { files, notes } = selection;
|
|
870
|
+
const notify = (text) => {
|
|
871
|
+
if (format === "pretty") io.stdout(text);
|
|
872
|
+
else if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
|
|
873
|
+
else io.stderr(text);
|
|
874
|
+
};
|
|
875
|
+
const jsx = jsxNotice(selection, io.cwd, command);
|
|
694
876
|
if (!files.length) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
io.
|
|
698
|
-
|
|
877
|
+
const hints = jsx ? [jsx] : [selection.skipped ? `Pass --include-design-system to check ${selection.skipped === 1 ? "it" : "them"}.` : "", selection.testsLeftOut ? "Pass --include-tests to check tests and stories." : ""].filter(Boolean);
|
|
878
|
+
if (values["require-design-system"] && selection.searched) {
|
|
879
|
+
io.stderr(`check --require-design-system: nothing was checked${notes.length ? ` (${notes.join("; ")})` : ""}. ${hints.join(" ")}`.trimEnd());
|
|
880
|
+
return 2;
|
|
881
|
+
}
|
|
882
|
+
if (format === "pretty") io.stdout(`Nothing to check${notes.length ? `: ${notes.join("; ")}` : ""}. ${hints.join(" ")}`.trimEnd());
|
|
883
|
+
else {
|
|
884
|
+
printNoResults(format, io);
|
|
885
|
+
if (jsx) notify(jsx);
|
|
886
|
+
}
|
|
699
887
|
return 0;
|
|
700
888
|
}
|
|
701
889
|
const update = values["update-baseline"];
|
|
890
|
+
const prune = values["prune-baseline"];
|
|
702
891
|
const baselineFile = values.baseline ? path.resolve(io.cwd, values.baseline) : defaultBaseline(selection.root, values["ignore-baseline"] ? void 0 : (text) => {
|
|
703
892
|
warnDeprecated([text], format, io);
|
|
704
893
|
});
|
|
@@ -707,6 +896,10 @@ async function check(patterns, configOptions, values, io) {
|
|
|
707
896
|
io.stderr(`check: baseline not found: ${displayPath(io.cwd, baselineFile)}`);
|
|
708
897
|
return 2;
|
|
709
898
|
}
|
|
899
|
+
if (prune && !baseline) {
|
|
900
|
+
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")}\`.`);
|
|
901
|
+
return 2;
|
|
902
|
+
}
|
|
710
903
|
const disabledFor = (file) => {
|
|
711
904
|
const ds = selection.designSystemOf(file);
|
|
712
905
|
return new Set(Object.entries(rulesFor(ds.config, relativePath(ds.root, file))).filter(([, rule]) => rule.severity === "off").map(([id]) => id));
|
|
@@ -716,6 +909,7 @@ async function check(patterns, configOptions, values, io) {
|
|
|
716
909
|
const baselineKey = (file) => relativePath(realRoot, fs.realpathSync.native(file)).normalize("NFC");
|
|
717
910
|
const results = [];
|
|
718
911
|
const checked = /* @__PURE__ */ new Map();
|
|
912
|
+
const ran = /* @__PURE__ */ new Set();
|
|
719
913
|
let baselined = 0;
|
|
720
914
|
let fixed = 0;
|
|
721
915
|
for (const file of files) {
|
|
@@ -725,9 +919,12 @@ async function check(patterns, configOptions, values, io) {
|
|
|
725
919
|
const key = baselineKey(file);
|
|
726
920
|
const disabled = disabledFor(file);
|
|
727
921
|
disabledByKey.set(key, disabled);
|
|
728
|
-
if (update) checked.set(key, result.diagnostics);
|
|
922
|
+
if (update || prune) checked.set(key, result.diagnostics);
|
|
923
|
+
if (update) {
|
|
924
|
+
for (const rule of RULE_IDS) if (!disabled.has(rule) && canRun(ds, rule)) ran.add(rule);
|
|
925
|
+
}
|
|
729
926
|
if (baseline && !update) {
|
|
730
|
-
const match = applyBaseline(baseline, key, result, disabled);
|
|
927
|
+
const match = applyBaseline(baseline.entries, key, result, disabled);
|
|
731
928
|
result = {
|
|
732
929
|
...match.result,
|
|
733
930
|
baselined: match.baselined
|
|
@@ -744,28 +941,56 @@ async function check(patterns, configOptions, values, io) {
|
|
|
744
941
|
if (elsewhere) notes.push(`${plural(elsewhere, "image or email file")} not checked`);
|
|
745
942
|
const unchecked = uncheckedNotice(results);
|
|
746
943
|
if (update) {
|
|
747
|
-
const next = updateBaseline(baseline, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
|
|
748
|
-
|
|
944
|
+
const next = updateBaseline(baseline?.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
|
|
945
|
+
const wholeRoot = patterns.some((pattern) => {
|
|
946
|
+
const dir = path.resolve(io.cwd, pattern);
|
|
947
|
+
return Boolean(fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() && isInside(realPath(dir), realRoot));
|
|
948
|
+
});
|
|
949
|
+
writeBaseline(baselineFile, next, {
|
|
950
|
+
onsystem: VERSION,
|
|
951
|
+
rules: rulesRun(baseline, next, checked, ran, wholeRoot)
|
|
952
|
+
});
|
|
749
953
|
const counts = countBaseline(next);
|
|
750
|
-
|
|
954
|
+
const summary = `Baseline: ${count(counts.findings, "finding")} in ${count(counts.files, "file")} → ${displayPath(io.cwd, baselineFile)}`;
|
|
955
|
+
if (format === "pretty") io.stdout(summary);
|
|
956
|
+
else io.stderr(summary);
|
|
751
957
|
for (const text of selection.notices) io.stderr(text);
|
|
752
958
|
if (unchecked) io.stderr(unchecked);
|
|
959
|
+
if (jsx) io.stderr(jsx);
|
|
753
960
|
return 0;
|
|
754
961
|
}
|
|
962
|
+
const baselineNotes = [];
|
|
963
|
+
if (baseline) {
|
|
964
|
+
const fresh = results.flatMap((r) => r.diagnostics);
|
|
965
|
+
const unrecorded = unrecordedRules(baseline, fresh);
|
|
966
|
+
if (unrecorded.size) baselineNotes.push(unrecordedNote(unrecorded, fresh.length, baseline.onsystem, command("--update-baseline")));
|
|
967
|
+
}
|
|
968
|
+
if (prune && baseline) {
|
|
969
|
+
const next = pruneBaseline(baseline.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
|
|
970
|
+
const left = countBaseline(next);
|
|
971
|
+
const dropped = countBaseline(baseline.entries).findings - left.findings;
|
|
972
|
+
if (dropped) writeBaseline(baselineFile, next, {
|
|
973
|
+
onsystem: baseline.onsystem,
|
|
974
|
+
rules: baseline.rules
|
|
975
|
+
});
|
|
976
|
+
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)}`);
|
|
977
|
+
}
|
|
755
978
|
const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
|
|
756
979
|
color: io.color,
|
|
757
980
|
uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
|
|
758
981
|
baselined: baseline ? baselined : void 0,
|
|
759
982
|
notes,
|
|
760
|
-
fixedHint: fixed ? `${count(fixed, "baseline finding")} no longer ${fixed === 1 ? "occurs" : "occur"}: run
|
|
983
|
+
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
984
|
});
|
|
762
985
|
if (output) io.stdout(output);
|
|
763
|
-
for (const text of
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
986
|
+
for (const text of baselineNotes) if (format === "pretty") io.stdout(text);
|
|
987
|
+
else if (format === "github") io.stdout(`::notice title=${NAME} baseline::${escapeData(text)}`);
|
|
988
|
+
else io.stderr(text);
|
|
989
|
+
for (const text of [
|
|
990
|
+
...selection.notices,
|
|
991
|
+
unchecked,
|
|
992
|
+
jsx
|
|
993
|
+
]) if (text) notify(text);
|
|
769
994
|
const errors = results.reduce((n, r) => n + r.errorCount, 0);
|
|
770
995
|
const warnings = results.reduce((n, r) => n + r.warningCount, 0);
|
|
771
996
|
if (errors > 0) return 1;
|
|
@@ -804,10 +1029,15 @@ function repositoryPath(cwd, env) {
|
|
|
804
1029
|
return root ? relativePath(root, absolute) : toPosix(file);
|
|
805
1030
|
};
|
|
806
1031
|
}
|
|
807
|
-
/**
|
|
1032
|
+
/**
|
|
1033
|
+
* Splits the patterns into files named outright and globs (a folder: every TSX/JSX file under
|
|
1034
|
+
* it). `scripts` globs the `.js` files under the folders, which are not checked, to say which
|
|
1035
|
+
* of them have JSX.
|
|
1036
|
+
*/
|
|
808
1037
|
function expandPatterns(patterns, cwd) {
|
|
809
1038
|
const named = [];
|
|
810
1039
|
const globs = [];
|
|
1040
|
+
const scripts = [];
|
|
811
1041
|
for (const pattern of patterns) {
|
|
812
1042
|
const stat = fs.statSync(path.resolve(cwd, pattern), { throwIfNoEntry: false });
|
|
813
1043
|
if (stat && !stat.isDirectory()) {
|
|
@@ -815,47 +1045,131 @@ function expandPatterns(patterns, cwd) {
|
|
|
815
1045
|
continue;
|
|
816
1046
|
}
|
|
817
1047
|
const literal = stat ? escapePath(toPosix(path.join(pattern, "."))) : toPosix(pattern);
|
|
818
|
-
|
|
1048
|
+
if (!stat) {
|
|
1049
|
+
globs.push(literal);
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
const under = literal === "." ? "**/*" : `${literal}/**/*`;
|
|
1053
|
+
globs.push(`${under}.{tsx,jsx}`);
|
|
1054
|
+
scripts.push(`${under}.js`);
|
|
819
1055
|
}
|
|
820
1056
|
return {
|
|
821
1057
|
named,
|
|
822
|
-
globs
|
|
1058
|
+
globs,
|
|
1059
|
+
scripts
|
|
823
1060
|
};
|
|
824
1061
|
}
|
|
1062
|
+
/** A `.js` file larger than this is a bundle or generated, not a page: not read for JSX. */
|
|
1063
|
+
const MAX_PAGE_BYTES = 524288;
|
|
1064
|
+
/**
|
|
1065
|
+
* `.js` files the folders hold that have JSX, which are not checked: folders expand to TSX and
|
|
1066
|
+
* JSX files, and a JavaScript app writes its pages as `.js` (Next.js's JavaScript template has
|
|
1067
|
+
* `app/page.js`). Files the run checks anyway (a glob names them), what git ignores and what
|
|
1068
|
+
* `leftOut` says (the config's `exclude`, the design system's own files) are not counted, nor
|
|
1069
|
+
* are minified files and files over 512 KB, which are bundles rather than pages.
|
|
1070
|
+
*/
|
|
1071
|
+
async function jsxLeftOut(scripts, taken, cwd, leftOut) {
|
|
1072
|
+
if (!scripts.length) return [];
|
|
1073
|
+
const checked = new Set(taken.map(realPath));
|
|
1074
|
+
const found = oncePerFile((await findFiles(scripts, {
|
|
1075
|
+
cwd,
|
|
1076
|
+
absolute: true,
|
|
1077
|
+
expandDirectories: false,
|
|
1078
|
+
ignore: ["**/node_modules/**", "**/*.min.js"]
|
|
1079
|
+
})).filter((file) => !checked.has(realPath(file)))).sort();
|
|
1080
|
+
const ignored = gitIgnored(cwd, found);
|
|
1081
|
+
const files = [];
|
|
1082
|
+
for (const file of found) {
|
|
1083
|
+
if (ignored.has(file) || await leftOut(file)) continue;
|
|
1084
|
+
let code;
|
|
1085
|
+
try {
|
|
1086
|
+
if (fs.statSync(file).size > MAX_PAGE_BYTES) continue;
|
|
1087
|
+
code = fs.readFileSync(file, "utf8");
|
|
1088
|
+
} catch {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (hasJsx(code)) files.push(file);
|
|
1092
|
+
}
|
|
1093
|
+
return files;
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Where a JSX element can start: after `(`, `,`, `=`, `=>`, `?` (not a regex's `(?<name>`), `:`,
|
|
1097
|
+
* `[`, `{`, `&`, `|`, `return`, `yield`, `await` or `default`, with spaces and short comments
|
|
1098
|
+
* between. Plain JavaScript has a `<` and a name there only in strings and comments.
|
|
1099
|
+
*/
|
|
1100
|
+
const JSX_START = /(?:(?<!\()\?|=>|[(,=:[{&|]|\b(?:return|yield|await|default))(?:\s|\/\/[^\n]{0,200}\n|\/\*(?:[^*]|\*(?!\/)){0,500}\*\/)*<([A-Za-z][\w.:-]*|>(?=[\s<{]))/g;
|
|
1101
|
+
/**
|
|
1102
|
+
* Whether the code may have JSX: an element starts where only JSX can start one, and it is a
|
|
1103
|
+
* fragment, its closing tag is there, or a `/>` follows it. A library bundle whose strings hold
|
|
1104
|
+
* HTML rarely passes, so most `.js` files are never parsed.
|
|
1105
|
+
*/
|
|
1106
|
+
function mayHaveJsx(code) {
|
|
1107
|
+
for (const match of code.matchAll(JSX_START)) {
|
|
1108
|
+
const name = match[1] ?? "";
|
|
1109
|
+
if (name === ">" || code.includes(`</${name}>`)) return true;
|
|
1110
|
+
const from = match.index + match[0].length;
|
|
1111
|
+
const close = code.indexOf("/>", from);
|
|
1112
|
+
if (close >= 0 && close - from < 2e3) return true;
|
|
1113
|
+
}
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
/** Whether the code has a JSX element or fragment. */
|
|
1117
|
+
function hasJsx(code) {
|
|
1118
|
+
if (!code.includes("/>") && !code.includes("</")) return false;
|
|
1119
|
+
if (!mayHaveJsx(code)) return false;
|
|
1120
|
+
const source = ts.createSourceFile("file.js", code, ts.ScriptTarget.Latest, false, ts.ScriptKind.JSX);
|
|
1121
|
+
const visit = (node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node) || ts.forEachChild(node, visit) === true;
|
|
1122
|
+
try {
|
|
1123
|
+
return visit(source);
|
|
1124
|
+
} catch {
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/** Which `.js` files with JSX the folders left out, and the command that checks them. */
|
|
1129
|
+
function jsxNotice(selection, cwd, command) {
|
|
1130
|
+
const files = selection.jsx.map((file) => relativePath(cwd, file));
|
|
1131
|
+
if (!files.length) return void 0;
|
|
1132
|
+
const check = command(selection.jsxGlobs.map(shellQuote).join(" "));
|
|
1133
|
+
return files.length === 1 ? `${files[0] ?? ""} has JSX, but folders are checked for .tsx and .jsx files, so it was not checked. Name it to check it: \`${check}\`.` : `${files.length.toLocaleString("en-US")} .js files have JSX (${listOf(files)}), but folders are checked for .tsx and .jsx files, so they were not checked. Name them to check them: \`${check}\`.`;
|
|
1134
|
+
}
|
|
825
1135
|
/** One project: the root's config and design system for every file. */
|
|
826
1136
|
async function selectInProject(config, patterns, values, format, io) {
|
|
827
1137
|
const ds = await loadDesignSystem(config, {
|
|
828
1138
|
cache: values.cache,
|
|
829
1139
|
logger: silentLogger
|
|
830
1140
|
});
|
|
831
|
-
const { named, globs } = expandPatterns(patterns, io.cwd);
|
|
1141
|
+
const { named: given, globs, scripts } = expandPatterns(patterns, io.cwd);
|
|
832
1142
|
const options = {
|
|
833
1143
|
cwd: io.cwd,
|
|
834
1144
|
absolute: true,
|
|
835
1145
|
expandDirectories: false
|
|
836
1146
|
};
|
|
837
|
-
const
|
|
1147
|
+
const globbed = globs.length ? await findFiles(globs, {
|
|
838
1148
|
...options,
|
|
839
1149
|
ignore: ["**/node_modules/**"]
|
|
840
1150
|
}) : [];
|
|
841
|
-
const
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1151
|
+
const named = given.filter((file) => SCRIPT_FILE.test(file));
|
|
1152
|
+
const all = globbed.filter((file) => SCRIPT_FILE.test(file));
|
|
1153
|
+
const excluded = (file) => excludedBy(config, relativePath(config.root, file), { includeTests: values["include-tests"] }) !== void 0;
|
|
1154
|
+
const ownFiles = async () => new Set(config.includeDesignSystem ? [] : (await componentFiles(config)).map(realPath));
|
|
1155
|
+
const jsxIn = (taken, own) => jsxLeftOut(scripts, taken, io.cwd, (file) => excluded(file) || own.has(realPath(file)));
|
|
1156
|
+
const skip = values["skip-excluded"];
|
|
1157
|
+
const leftOut = unique([...skip ? named : [], ...all]).filter(excluded);
|
|
1158
|
+
const leftOutSet = new Set(leftOut);
|
|
1159
|
+
const kept = all.filter((file) => !leftOutSet.has(file));
|
|
846
1160
|
const ignored = gitIgnored(io.cwd, kept);
|
|
847
|
-
const keptSet = new Set(kept);
|
|
848
|
-
const leftOut = all.filter((file) => !keptSet.has(file));
|
|
849
1161
|
const left = {
|
|
850
1162
|
tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
|
|
851
1163
|
excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
|
|
852
|
-
gitignored: ignored.size
|
|
1164
|
+
gitignored: ignored.size,
|
|
1165
|
+
notScripts: notScripts(given, globbed)
|
|
1166
|
+
};
|
|
1167
|
+
let files = oncePerFile([...skip ? named.filter((file) => !leftOutSet.has(file)) : named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
1168
|
+
if (!files.length && !all.length && !leftOut.length && !left.notScripts) return {
|
|
1169
|
+
noMatch: true,
|
|
1170
|
+
jsx: scripts.length ? await jsxIn([], await ownFiles()) : [],
|
|
1171
|
+
jsxGlobs: scripts
|
|
853
1172
|
};
|
|
854
|
-
let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
855
|
-
if (!files.length && !all.length) {
|
|
856
|
-
io.stderr(`check: no files match ${patterns.join(" ")}`);
|
|
857
|
-
return 2;
|
|
858
|
-
}
|
|
859
1173
|
if (values["quiet-without-design-system"] && !config.configFile && !ds.components.length) {
|
|
860
1174
|
printNoResults(format, io);
|
|
861
1175
|
return 0;
|
|
@@ -863,23 +1177,28 @@ async function selectInProject(config, patterns, values, format, io) {
|
|
|
863
1177
|
const notice = ds.notice();
|
|
864
1178
|
if (notice && values["require-design-system"] && !ds.hasDesignSystem()) {
|
|
865
1179
|
io.stderr(`check --require-design-system: ${notice}`);
|
|
1180
|
+
for (const text of notReadNotices([{ ds }])) io.stderr(text);
|
|
866
1181
|
return 2;
|
|
867
1182
|
}
|
|
868
1183
|
let skipped = 0;
|
|
869
|
-
|
|
870
|
-
|
|
1184
|
+
const own = await ownFiles();
|
|
1185
|
+
if (own.size) {
|
|
871
1186
|
const rest = files.filter((file) => !own.has(realPath(file)));
|
|
872
1187
|
skipped = files.length - rest.length;
|
|
873
1188
|
files = rest;
|
|
874
1189
|
}
|
|
1190
|
+
const jsx = await jsxIn([...named, ...all], own);
|
|
875
1191
|
return {
|
|
876
1192
|
files,
|
|
877
1193
|
designSystemOf: () => ds,
|
|
878
1194
|
root: ds.root,
|
|
879
1195
|
notes: scopeNotes(skipped, left),
|
|
880
|
-
notices: notice ? [notice] : [],
|
|
1196
|
+
notices: [...notice ? [notice] : [], ...notReadNotices([{ ds }])],
|
|
881
1197
|
skipped,
|
|
882
|
-
testsLeftOut: left.tests
|
|
1198
|
+
testsLeftOut: left.tests,
|
|
1199
|
+
jsx,
|
|
1200
|
+
jsxGlobs: scripts,
|
|
1201
|
+
searched: globs.length > 0
|
|
883
1202
|
};
|
|
884
1203
|
}
|
|
885
1204
|
/**
|
|
@@ -888,13 +1207,15 @@ async function selectInProject(config, patterns, values, format, io) {
|
|
|
888
1207
|
* config, exactly as `check` from that folder (and the Claude Code hook) would check it.
|
|
889
1208
|
*/
|
|
890
1209
|
async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
891
|
-
const { named, globs } = expandPatterns(patterns, io.cwd);
|
|
892
|
-
const
|
|
1210
|
+
const { named: given, globs, scripts } = expandPatterns(patterns, io.cwd);
|
|
1211
|
+
const globbed = globs.length ? await findFiles(globs, {
|
|
893
1212
|
cwd: io.cwd,
|
|
894
1213
|
absolute: true,
|
|
895
1214
|
expandDirectories: false,
|
|
896
1215
|
ignore: ["**/node_modules/**"]
|
|
897
1216
|
}) : [];
|
|
1217
|
+
const named = given.filter((file) => SCRIPT_FILE.test(file));
|
|
1218
|
+
const all = globbed.filter((file) => SCRIPT_FILE.test(file));
|
|
898
1219
|
const projectOf = /* @__PURE__ */ new Map();
|
|
899
1220
|
for (const file of unique([...named, ...all])) projectOf.set(file, await workspace.projectFor(file));
|
|
900
1221
|
const project = (file) => {
|
|
@@ -914,24 +1235,42 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
914
1235
|
}
|
|
915
1236
|
return ds;
|
|
916
1237
|
};
|
|
917
|
-
const
|
|
1238
|
+
const excluded = (file) => {
|
|
918
1239
|
const { root, config } = project(file);
|
|
919
1240
|
const relative = relativePath(root, file);
|
|
920
|
-
return config
|
|
921
|
-
}
|
|
1241
|
+
return excludedBy(config, relative, { includeTests: values["include-tests"] }) !== void 0;
|
|
1242
|
+
};
|
|
1243
|
+
const skip = values["skip-excluded"];
|
|
1244
|
+
const leftOut = unique([...skip ? named : [], ...all]).filter(excluded);
|
|
922
1245
|
const leftOutSet = new Set(leftOut);
|
|
923
1246
|
const kept = all.filter((file) => !leftOutSet.has(file));
|
|
924
1247
|
const ignored = gitIgnored(io.cwd, kept);
|
|
925
1248
|
const left = {
|
|
926
1249
|
tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
|
|
927
1250
|
excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
|
|
928
|
-
gitignored: ignored.size
|
|
1251
|
+
gitignored: ignored.size,
|
|
1252
|
+
notScripts: notScripts(given, globbed)
|
|
1253
|
+
};
|
|
1254
|
+
let files = oncePerFile([...skip ? named.filter((file) => !leftOutSet.has(file)) : named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
1255
|
+
const own = /* @__PURE__ */ new Map();
|
|
1256
|
+
const ownFiles = async (p) => {
|
|
1257
|
+
let mine = own.get(p.root);
|
|
1258
|
+
if (!mine) {
|
|
1259
|
+
mine = new Set(p.config.includeDesignSystem ? [] : (await componentFiles(p.config)).map(realPath));
|
|
1260
|
+
own.set(p.root, mine);
|
|
1261
|
+
}
|
|
1262
|
+
return mine;
|
|
1263
|
+
};
|
|
1264
|
+
const jsxIn = (taken) => jsxLeftOut(scripts, taken, io.cwd, async (file) => {
|
|
1265
|
+
const p = await workspace.projectFor(file);
|
|
1266
|
+
projectOf.set(file, p);
|
|
1267
|
+
return excluded(file) || (await ownFiles(p)).has(realPath(file));
|
|
1268
|
+
});
|
|
1269
|
+
if (!files.length && !all.length && !leftOut.length && !left.notScripts) return {
|
|
1270
|
+
noMatch: true,
|
|
1271
|
+
jsx: await jsxIn([]),
|
|
1272
|
+
jsxGlobs: scripts
|
|
929
1273
|
};
|
|
930
|
-
let files = unique([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
931
|
-
if (!files.length && !all.length) {
|
|
932
|
-
io.stderr(`check: no files match ${patterns.join(" ")}`);
|
|
933
|
-
return 2;
|
|
934
|
-
}
|
|
935
1274
|
const loaded = /* @__PURE__ */ new Map();
|
|
936
1275
|
for (const p of unique(files.map(project))) loaded.set(p.root, await load(p));
|
|
937
1276
|
const designSystemOf = (file) => {
|
|
@@ -958,14 +1297,11 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
958
1297
|
return 2;
|
|
959
1298
|
}
|
|
960
1299
|
}
|
|
961
|
-
const
|
|
962
|
-
for (const p of unique(files.map(project))) {
|
|
963
|
-
if (p.config.includeDesignSystem) continue;
|
|
964
|
-
own.set(p.root, new Set((await componentFiles(p.config)).map(realPath)));
|
|
965
|
-
}
|
|
1300
|
+
for (const p of unique(files.map(project))) await ownFiles(p);
|
|
966
1301
|
const rest = files.filter((file) => !own.get(project(file).root)?.has(realPath(file)));
|
|
967
1302
|
const skipped = files.length - rest.length;
|
|
968
1303
|
files = rest;
|
|
1304
|
+
const jsx = await jsxIn([...named, ...all]);
|
|
969
1305
|
const gaps = /* @__PURE__ */ new Map();
|
|
970
1306
|
for (const p of unique(files.map(project))) {
|
|
971
1307
|
const ds = loaded.get(p.root);
|
|
@@ -979,6 +1315,14 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
979
1315
|
gaps.set(notice, gap);
|
|
980
1316
|
}
|
|
981
1317
|
const notices = [...gaps.values()].map(({ ds, dirs }) => ds.notice(listOf(dirs)) ?? "");
|
|
1318
|
+
const checkedProjects = unique(files.map(project)).flatMap((p) => {
|
|
1319
|
+
const ds = loaded.get(p.root);
|
|
1320
|
+
return ds ? [{
|
|
1321
|
+
ds,
|
|
1322
|
+
dir: p.dir
|
|
1323
|
+
}] : [];
|
|
1324
|
+
});
|
|
1325
|
+
notices.push(...notReadNotices(checkedProjects));
|
|
982
1326
|
const used = unique(files.map((file) => project(file).root)).length;
|
|
983
1327
|
return {
|
|
984
1328
|
files,
|
|
@@ -987,7 +1331,10 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
987
1331
|
notes: [...used > 1 ? [plural(used, "project")] : [], ...scopeNotes(skipped, left)],
|
|
988
1332
|
notices: notices.filter(Boolean),
|
|
989
1333
|
skipped,
|
|
990
|
-
testsLeftOut: left.tests
|
|
1334
|
+
testsLeftOut: left.tests,
|
|
1335
|
+
jsx,
|
|
1336
|
+
jsxGlobs: scripts,
|
|
1337
|
+
searched: globs.length > 0
|
|
991
1338
|
};
|
|
992
1339
|
}
|
|
993
1340
|
/** `apps/web`, `apps/web and packages/ui`, `a, b, c and 4 more`. */
|
|
@@ -1041,6 +1388,8 @@ async function inspect(configOptions, values, io) {
|
|
|
1041
1388
|
}
|
|
1042
1389
|
if (values.explain) lines.push("", ...explainProject(ds, io.cwd));
|
|
1043
1390
|
io.stdout(lines.join("\n"));
|
|
1391
|
+
const missing = values.explain ? void 0 : missingDependencies([{ ds }]);
|
|
1392
|
+
if (missing) io.stderr(missing);
|
|
1044
1393
|
return 0;
|
|
1045
1394
|
}
|
|
1046
1395
|
/**
|
|
@@ -1048,12 +1397,16 @@ async function inspect(configOptions, values, io) {
|
|
|
1048
1397
|
* project depends on, including those the config's `libraries` leaves out.
|
|
1049
1398
|
*/
|
|
1050
1399
|
function describeLibraries(config, model, cwd) {
|
|
1051
|
-
const name = (library) => `${
|
|
1052
|
-
return [
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1400
|
+
const name = (library) => `${libraryTitle(library.package)} (${library.package}${library.version ? ` ${library.version}` : ""})`;
|
|
1401
|
+
return [
|
|
1402
|
+
...(config.libraries ?? []).map((library) => {
|
|
1403
|
+
if (!library.dir) return `${name(library)}: a dependency, but not installed`;
|
|
1404
|
+
const count = model.components.filter((c) => c.library === library.package).length;
|
|
1405
|
+
return `${name(library)}: ${plural(count, "component")} from its type declarations`;
|
|
1406
|
+
}),
|
|
1407
|
+
...(config.librariesOff ?? []).map((library) => `${name(library)}: ${librariesLeftOut(config, cwd)}`),
|
|
1408
|
+
...(config.librariesUnread ?? []).map((library) => `${name(library)}: ${notReadYet()}`)
|
|
1409
|
+
];
|
|
1057
1410
|
}
|
|
1058
1411
|
/**
|
|
1059
1412
|
* `export --format dtcg|design-md`: the project's tokens as a DTCG 2025.10 file or a
|
|
@@ -1149,6 +1502,11 @@ async function inspectWorkspace(workspace, values, io) {
|
|
|
1149
1502
|
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
1503
|
if (values.explain) lines.push("", ...explainWorkspace(workspace, loaded, io.cwd));
|
|
1151
1504
|
io.stdout(lines.join("\n"));
|
|
1505
|
+
const missing = values.explain ? void 0 : missingDependencies(loaded.map(({ project, ds }) => ({
|
|
1506
|
+
ds,
|
|
1507
|
+
dir: project.dir
|
|
1508
|
+
})));
|
|
1509
|
+
if (missing) io.stderr(missing);
|
|
1152
1510
|
return 0;
|
|
1153
1511
|
}
|
|
1154
1512
|
/** Why the root is read as a monorepo, then each project's explanation. */
|
|
@@ -1202,9 +1560,12 @@ function defaultBaseline(root, warn) {
|
|
|
1202
1560
|
}
|
|
1203
1561
|
/** Tests and stories, which `check` leaves out of folders and globs unless asked. */
|
|
1204
1562
|
const TEST_FILE = /\.(?:test|spec|stories)\.[cm]?[jt]sx?$|[\\/]__(?:tests|stories|mocks)__[\\/]/;
|
|
1205
|
-
/**
|
|
1206
|
-
|
|
1207
|
-
|
|
1563
|
+
/**
|
|
1564
|
+
* How many of the files named outright or matched by a glob are not JS or TS (styles, docs,
|
|
1565
|
+
* JSON), which are not checked.
|
|
1566
|
+
*/
|
|
1567
|
+
function notScripts(named, globbed) {
|
|
1568
|
+
return oncePerFile([...named, ...globbed].filter((file) => !SCRIPT_FILE.test(file))).length;
|
|
1208
1569
|
}
|
|
1209
1570
|
/** What a run left out, for the summary line: `5 design-system files skipped`. */
|
|
1210
1571
|
function scopeNotes(skipped, left) {
|
|
@@ -1212,22 +1573,11 @@ function scopeNotes(skipped, left) {
|
|
|
1212
1573
|
skipped ? `${plural(skipped, "design-system file")} skipped` : "",
|
|
1213
1574
|
left.tests ? `${left.tests.toLocaleString("en-US")} ${left.tests === 1 ? "test or story" : "tests and stories"} left out` : "",
|
|
1214
1575
|
left.excluded ? `${plural(left.excluded, "excluded file")} left out` : "",
|
|
1215
|
-
left.gitignored ? `${plural(left.gitignored, "file")} git ignores left out` : ""
|
|
1576
|
+
left.gitignored ? `${plural(left.gitignored, "file")} git ignores left out` : "",
|
|
1577
|
+
left.notScripts ? `${plural(left.notScripts, "file")} that ${left.notScripts === 1 ? "is" : "are"} not JS or TS skipped` : ""
|
|
1216
1578
|
].filter(Boolean);
|
|
1217
1579
|
}
|
|
1218
1580
|
/**
|
|
1219
|
-
* A config `exclude` pattern, relative to the root, as a pattern relative to the
|
|
1220
|
-
* working directory the globs run from; undefined when it only covers files
|
|
1221
|
-
* outside that directory. `**\/` patterns hold anywhere.
|
|
1222
|
-
*/
|
|
1223
|
-
function excludeFromCwd(pattern, root, cwd) {
|
|
1224
|
-
if (pattern.startsWith("**/")) return pattern;
|
|
1225
|
-
const fromRoot = toPosix(path.relative(root, cwd));
|
|
1226
|
-
if (!fromRoot) return pattern;
|
|
1227
|
-
if (fromRoot.startsWith("..")) return toPosix(path.join(path.relative(cwd, root), pattern));
|
|
1228
|
-
return pattern.startsWith(`${fromRoot}/`) ? pattern.slice(fromRoot.length + 1) : void 0;
|
|
1229
|
-
}
|
|
1230
|
-
/**
|
|
1231
1581
|
* Files a glob matches, as Node spells absolute paths (with backslashes on Windows, where
|
|
1232
1582
|
* tinyglobby gives forward slashes), so they compare equal to `path.resolve` and git's.
|
|
1233
1583
|
*/
|
|
@@ -1259,6 +1609,51 @@ function gitIgnored(cwd, files) {
|
|
|
1259
1609
|
function count(n, word) {
|
|
1260
1610
|
return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
|
|
1261
1611
|
}
|
|
1612
|
+
/**
|
|
1613
|
+
* `check` as this run was given, with `flag`: the patterns and the options that decide what it
|
|
1614
|
+
* finds and which baseline it reads (output options left out).
|
|
1615
|
+
*/
|
|
1616
|
+
function checkCommand(patterns, configOptions, values, flag) {
|
|
1617
|
+
const args = [...patterns];
|
|
1618
|
+
const option = (name, value) => {
|
|
1619
|
+
if (value !== void 0) args.push(`--${name}`, value);
|
|
1620
|
+
};
|
|
1621
|
+
option("root", configOptions.root);
|
|
1622
|
+
option("config", configOptions.config);
|
|
1623
|
+
for (const name of [
|
|
1624
|
+
"components",
|
|
1625
|
+
"tokens",
|
|
1626
|
+
"docs"
|
|
1627
|
+
]) for (const value of configOptions[name] ?? []) option(name, value);
|
|
1628
|
+
option("baseline", values.baseline);
|
|
1629
|
+
if (configOptions.includeDesignSystem !== void 0) args.push(`--${configOptions.includeDesignSystem ? "" : "no-"}include-design-system`);
|
|
1630
|
+
if (values["include-tests"]) args.push("--include-tests");
|
|
1631
|
+
if (values["skip-excluded"]) args.push("--skip-excluded");
|
|
1632
|
+
return `check ${args.map(shellQuote).join(" ")} ${flag}`;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Whether a rule has something to compare against in a design system, as `notice()` says when
|
|
1636
|
+
* it has not: no-hardcoded-color needs color tokens, the spacing and radius rules tokens of
|
|
1637
|
+
* their kind, and the component rules components. Keep it in step with `DesignSystem.notice()`
|
|
1638
|
+
* in src/design-system.ts, or the baseline records a rule as run when it was skipped, or the
|
|
1639
|
+
* other way round.
|
|
1640
|
+
*/
|
|
1641
|
+
function canRun(ds, rule) {
|
|
1642
|
+
switch (rule) {
|
|
1643
|
+
case "no-hardcoded-color": return ds.tokenIndex.has("color");
|
|
1644
|
+
case "no-hardcoded-spacing": return ds.tokenIndex.has("spacing");
|
|
1645
|
+
case "no-hardcoded-radius": return ds.tokenIndex.has("radius");
|
|
1646
|
+
case "icon-button-accessible-name": return true;
|
|
1647
|
+
default: return ds.model.components.length > 0;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
/** Why findings of rules that did not run when the baseline was recorded are new to it. */
|
|
1651
|
+
function unrecordedNote(byRule, total, version, update) {
|
|
1652
|
+
const rules = [...byRule.keys()].sort();
|
|
1653
|
+
const n = [...byRule.values()].reduce((sum, k) => sum + k, 0);
|
|
1654
|
+
const others = total - n;
|
|
1655
|
+
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")}` : ""}.`;
|
|
1656
|
+
}
|
|
1262
1657
|
/** Quotes a pattern for the hint when the shell would expand or split it. */
|
|
1263
1658
|
function shellQuote(pattern) {
|
|
1264
1659
|
return /^[\w./@-]+$/.test(pattern) ? pattern : `"${pattern.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
@@ -1271,6 +1666,19 @@ function realPath(file) {
|
|
|
1271
1666
|
return file;
|
|
1272
1667
|
}
|
|
1273
1668
|
}
|
|
1669
|
+
/**
|
|
1670
|
+
* One path per file, links resolved: a folder that links back to its parent reaches the same
|
|
1671
|
+
* files again. The shortest path stays.
|
|
1672
|
+
*/
|
|
1673
|
+
function oncePerFile(files) {
|
|
1674
|
+
const kept = /* @__PURE__ */ new Map();
|
|
1675
|
+
for (const file of files) {
|
|
1676
|
+
const real = realPath(file);
|
|
1677
|
+
const previous = kept.get(real);
|
|
1678
|
+
if (previous === void 0 || file.length < previous.length) kept.set(real, file);
|
|
1679
|
+
}
|
|
1680
|
+
return [...kept.values()];
|
|
1681
|
+
}
|
|
1274
1682
|
/** Relative to cwd when the file is below it, absolute otherwise. */
|
|
1275
1683
|
function displayPath(cwd, file) {
|
|
1276
1684
|
const rel = path.relative(cwd, file);
|