onsystem 0.7.0 → 0.9.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 +19 -3
- package/dist/cli.js +513 -87
- package/dist/{config-1XSTYI44.d.ts → config-B98w8bcg.d.ts} +12 -0
- package/dist/{design-system-C4bhqLLw.js → design-system-Bykei5W0.js} +1838 -184
- package/dist/eslint-worker.d.ts +1 -1
- package/dist/eslint-worker.js +3 -5
- package/dist/eslint.js +1 -1
- package/dist/index.d.ts +27 -4
- package/dist/index.js +2 -2
- package/dist/{stdio-BojAQ6JP.js → stdio-B7u6dqLd.js} +347 -86
- package/dist/{workspace-Z0tZf_Dv.js → workspace-Dp1q8qZ8.js} +1 -1
- package/package.json +6 -4
- package/schema.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-
|
|
4
|
-
import { r as loadTarget } from "./workspace-
|
|
2
|
+
import { $ as LEGACY_CONFIG_FILES, A as unrecordedRules, At as relativePath, B as statusKinds, C as countBaseline, D as pruneBaseline, Dt as SCRIPT_FILE, E as onLines, Et as unique, J as ConfigError, L as toDesignMd, M as writeBaseline, Nt as readProjectConfig, O as readBaseline, Ot as isInside, R as toDtcg, S as applyBaseline, T as goneFiles, Y as DEFAULT_DOCS, _ as formatDiagnostics, _t as detectProject, b as BASELINE_FILE, c as silentLogger, et as RULE_IDS, ft as libraryTitle, g as escapeData, gt as declaredConfig, h as uncheckedNotice, i as componentFiles, j as updateBaseline, jt as toPosix, k as rulesRun, kt as matchesGlob, l as stderrLogger, mt as PACKAGE_FIELD, o as loadDesignSystem, pt as notReadYet, q as CONFIG_FILES, rt as excludedBy, s as tokenFiles, st as rulesFor, v as NAME, w as findMoves, wt as plural, x as LEGACY_BASELINE_FILE, y as VERSION, yt as isDesignSystemName } from "./design-system-Bykei5W0.js";
|
|
3
|
+
import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-B7u6dqLd.js";
|
|
4
|
+
import { r as loadTarget } from "./workspace-Dp1q8qZ8.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";
|
|
@@ -26,6 +27,9 @@ const GUIDE_FILE = "ONSYSTEM.md";
|
|
|
26
27
|
* every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those
|
|
27
28
|
* apps' teams how to add the agent tools and the CI check. In an app that takes on such a
|
|
28
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.
|
|
29
33
|
*/
|
|
30
34
|
async function init(configOptions, options, io, runCheck) {
|
|
31
35
|
const { config, workspace } = await loadTarget({
|
|
@@ -49,6 +53,7 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
49
53
|
const relative = (abs) => relativePath(root, abs);
|
|
50
54
|
const inherited = options.force ? void 0 : config.designSystemPackage;
|
|
51
55
|
const replaced = options.force ? config.designSystemPackage : void 0;
|
|
56
|
+
let found = false;
|
|
52
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.`);
|
|
53
58
|
else if (configOptions.components?.length) {
|
|
54
59
|
file.components = configOptions.components;
|
|
@@ -63,6 +68,7 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
63
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.`);
|
|
64
69
|
else {
|
|
65
70
|
file.components = config.components;
|
|
71
|
+
found = true;
|
|
66
72
|
const [specifier] = specifiers;
|
|
67
73
|
if (specifier && !specifier.includes("*")) file.importPath = specifier;
|
|
68
74
|
const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d));
|
|
@@ -78,6 +84,7 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
78
84
|
})).length) matching.push(glob);
|
|
79
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(", ")}.`);
|
|
80
86
|
file.components = matching;
|
|
87
|
+
found = true;
|
|
81
88
|
if (declared && config.importPath) file.importPath = config.importPath;
|
|
82
89
|
const tokens = (await tokenFiles(config)).map(relative);
|
|
83
90
|
if (tokens.length) file.tokens = tokens;
|
|
@@ -110,6 +117,7 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
110
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();
|
|
111
118
|
if (importPath) file.importPath = importPath;
|
|
112
119
|
}
|
|
120
|
+
if (!inherited && !workspace) notes.push(...await registryCode(config, file, found));
|
|
113
121
|
const written = [];
|
|
114
122
|
if (!inherited) {
|
|
115
123
|
const out = path.join(root, CONFIG_FILE);
|
|
@@ -161,6 +169,14 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
161
169
|
const total = errors + warnings;
|
|
162
170
|
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")}.`);
|
|
163
171
|
for (const line of dry.stderr.split("\n").filter(Boolean)) io.stdout(line);
|
|
172
|
+
if (!workspace) {
|
|
173
|
+
const own = (await loadTarget({
|
|
174
|
+
...configOptions,
|
|
175
|
+
config: void 0
|
|
176
|
+
})).config;
|
|
177
|
+
const note = statusNote(await loadDesignSystem(own, { cache: options.cache }));
|
|
178
|
+
if (note) io.stdout(note);
|
|
179
|
+
}
|
|
164
180
|
const hasBaseline = [BASELINE_FILE, LEGACY_BASELINE_FILE].some((name) => fs.existsSync(path.join(root, name)));
|
|
165
181
|
let baselineWritten = false;
|
|
166
182
|
if (total && !hasBaseline) {
|
|
@@ -177,12 +193,115 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
177
193
|
baselineWritten = fs.existsSync(path.join(root, BASELINE_FILE));
|
|
178
194
|
} else io.stdout(`No baseline written; \`${NAME} check . --update-baseline\` records one later.`);
|
|
179
195
|
}
|
|
180
|
-
const
|
|
196
|
+
const instructed = await agentRules(root, ask, io);
|
|
197
|
+
const commit = [
|
|
198
|
+
...written,
|
|
199
|
+
...baselineWritten ? [BASELINE_FILE] : [],
|
|
200
|
+
...instructed
|
|
201
|
+
];
|
|
181
202
|
const files = commit.length > 1 ? `${commit.slice(0, -1).join(", ")} and ${commit.at(-1) ?? ""}` : commit[0];
|
|
182
203
|
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.`);
|
|
183
204
|
return 0;
|
|
184
205
|
}
|
|
185
206
|
const CONFIG_FILE = "onsystem.config.json";
|
|
207
|
+
/** The status colors docs/tokens.md#status-colors adds, and the hue each one is. */
|
|
208
|
+
const RECIPE = [
|
|
209
|
+
["success", "green"],
|
|
210
|
+
["warning", "yellow"],
|
|
211
|
+
["info", "blue"]
|
|
212
|
+
];
|
|
213
|
+
/**
|
|
214
|
+
* Says which of success, warning and info the theme has no color for, and where the CSS that adds
|
|
215
|
+
* them is: without one, an agent asked for a green "Active" label tends to reach for a chart or a
|
|
216
|
+
* gray token, which loses what the color says. Nothing when the theme has no colors, or all three.
|
|
217
|
+
*/
|
|
218
|
+
function statusNote(ds) {
|
|
219
|
+
if (!ds.tokens.some((token) => token.category === "color")) return void 0;
|
|
220
|
+
const has = statusKinds(ds.tokens);
|
|
221
|
+
const missing = RECIPE.filter(([kind]) => !has.has(kind));
|
|
222
|
+
if (!missing.length) return void 0;
|
|
223
|
+
return `The theme has no ${orList(missing.map(([kind]) => kind))} token, so a ${orList(missing.map(([, hue]) => hue))} status color has no token to use, and agents tend to reach for a chart or gray token instead. To add ${missing.length === 1 ? "it" : "them"}: https://onsystem.vercel.app/docs/tokens#status-colors.`;
|
|
224
|
+
}
|
|
225
|
+
/** `success, warning or info`. */
|
|
226
|
+
function orList(items) {
|
|
227
|
+
return items.length > 1 ? `${items.slice(0, -1).join(", ")} or ${items.at(-1) ?? ""}` : items[0] ?? "";
|
|
228
|
+
}
|
|
229
|
+
/** The instruction files agents read, which `init` offers to add onsystem's section to. */
|
|
230
|
+
const AGENT_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
231
|
+
const RULES_BEGIN = "<!-- BEGIN:onsystem-agent-rules -->";
|
|
232
|
+
const RULES_END = "<!-- END:onsystem-agent-rules -->";
|
|
233
|
+
/** onsystem's section of AGENTS.md or CLAUDE.md: the MCP tools, and the CLI for agents without them. */
|
|
234
|
+
const AGENT_RULES = [
|
|
235
|
+
RULES_BEGIN,
|
|
236
|
+
"# Design system",
|
|
237
|
+
"",
|
|
238
|
+
`This project's UI is checked against its design system by ${NAME}.`,
|
|
239
|
+
"",
|
|
240
|
+
`- Before writing UI, look up its components, props, variants and tokens with the ${NAME} tools (\`list_components\`, \`get_component\`, \`get_tokens\`).`,
|
|
241
|
+
`- 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.`,
|
|
242
|
+
RULES_END
|
|
243
|
+
].join("\n");
|
|
244
|
+
/**
|
|
245
|
+
* Offers to add onsystem's section to the AGENTS.md and CLAUDE.md in the root, between markers a
|
|
246
|
+
* later run updates in place, so agents that read them check the UI they write, through the CLI
|
|
247
|
+
* when they have no MCP config. A CLAUDE.md that imports AGENTS.md (an `@AGENTS.md` line) is left
|
|
248
|
+
* alone: Claude Code reads the section there. Asked only in a terminal, never with --yes; nothing
|
|
249
|
+
* is asked when neither file exists or the section is current. Returns the files it wrote.
|
|
250
|
+
*/
|
|
251
|
+
async function agentRules(root, ask, io) {
|
|
252
|
+
if (!ask) return [];
|
|
253
|
+
const found = AGENT_FILES.flatMap((name) => {
|
|
254
|
+
const file = path.join(root, name);
|
|
255
|
+
try {
|
|
256
|
+
return [{
|
|
257
|
+
name,
|
|
258
|
+
file,
|
|
259
|
+
text: fs.readFileSync(file, "utf8")
|
|
260
|
+
}];
|
|
261
|
+
} catch {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
const imports = (text) => /^\s*@(?:\.\/)?AGENTS\.md\s*$/m.test(text);
|
|
266
|
+
const changes = found.filter((f) => !(f.name === "CLAUDE.md" && imports(f.text) && found.length > 1)).map((f) => ({
|
|
267
|
+
...f,
|
|
268
|
+
next: withAgentRules(f.text)
|
|
269
|
+
})).filter((f) => f.next !== f.text);
|
|
270
|
+
if (!changes.length) return [];
|
|
271
|
+
const names = andList(changes.map((f) => f.name));
|
|
272
|
+
const update = changes.every((f) => rulesSection(f.text) !== void 0);
|
|
273
|
+
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();
|
|
274
|
+
if (answer !== "" && !answer.startsWith("y")) {
|
|
275
|
+
io.stdout(`${names} ${changes.length === 1 ? "was" : "were"} left as ${changes.length === 1 ? "it is" : "they are"}.`);
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
for (const f of changes) fs.writeFileSync(f.file, f.next);
|
|
279
|
+
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.`);
|
|
280
|
+
return changes.map((f) => f.name);
|
|
281
|
+
}
|
|
282
|
+
/** `text` with onsystem's section: replaced between its markers, or added at the end. */
|
|
283
|
+
function withAgentRules(text) {
|
|
284
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
285
|
+
const rules = AGENT_RULES.replaceAll("\n", eol);
|
|
286
|
+
const found = rulesSection(text);
|
|
287
|
+
if (found) return `${text.slice(0, found.start)}${rules}${text.slice(found.end)}`;
|
|
288
|
+
if (!text.trim()) return `${rules}${eol}`;
|
|
289
|
+
return `${text.endsWith("\n") ? text : `${text}${eol}`}${eol}${rules}${eol}`;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Where onsystem's section is in `text`, from its BEGIN marker to the end of its END marker: the
|
|
293
|
+
* first END with a BEGIN before it, and the BEGIN nearest that END. A BEGIN whose END was deleted
|
|
294
|
+
* is then not a section, so the text after it, which may be the user's, is never replaced.
|
|
295
|
+
*/
|
|
296
|
+
function rulesSection(text) {
|
|
297
|
+
for (let end = text.indexOf(RULES_END); end !== -1; end = text.indexOf(RULES_END, end + 1)) {
|
|
298
|
+
const start = text.lastIndexOf(RULES_BEGIN, end);
|
|
299
|
+
if (start !== -1) return {
|
|
300
|
+
start,
|
|
301
|
+
end: end + 33
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
186
305
|
function readManifest(root) {
|
|
187
306
|
const file = path.join(root, "package.json");
|
|
188
307
|
try {
|
|
@@ -309,6 +428,113 @@ async function replacedNote(pkg, taken, own, cwd) {
|
|
|
309
428
|
];
|
|
310
429
|
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
430
|
}
|
|
431
|
+
const REGISTRY_DOCS = "https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries";
|
|
432
|
+
/**
|
|
433
|
+
* Sets apart the code `npx shadcn add` copies from the registries components.json names
|
|
434
|
+
* (`"registries": { "@signoff-ui": … }`) into a folder of its own under the components alias
|
|
435
|
+
* (`components/signoff-ui`). When `found` says "components" holds what zero config found, and
|
|
436
|
+
* no `importPath` names a package for them, each such folder joins it: `check` and the hook skip
|
|
437
|
+
* that code, as they skip components/ui, and the app's code is checked against its components.
|
|
438
|
+
* A folder with no .tsx or .jsx file yet joins it too, and so does one that does not exist yet:
|
|
439
|
+
* a registry is often named in components.json before its first `shadcn add`, and an item of
|
|
440
|
+
* only .ts files (signoff-ui's ag-ui) makes the folder before a component comes. Otherwise the
|
|
441
|
+
* notes name the folders `check` reads as the app's own code, and what to add to set them apart.
|
|
442
|
+
* Folders the components already take in are left alone. Returns what to tell the user.
|
|
443
|
+
*/
|
|
444
|
+
async function registryCode(config, file, found) {
|
|
445
|
+
const root = config.root;
|
|
446
|
+
const folders = registryFolders(root, config.tsconfig);
|
|
447
|
+
if (!folders.length) return [];
|
|
448
|
+
const globs = Array.isArray(file.components) ? file.components : config.components;
|
|
449
|
+
const taken = new Set(await componentFiles({
|
|
450
|
+
...config,
|
|
451
|
+
components: globs
|
|
452
|
+
}));
|
|
453
|
+
const copied = [];
|
|
454
|
+
const pending = [];
|
|
455
|
+
const added = [];
|
|
456
|
+
for (const { namespace, dir } of folders) {
|
|
457
|
+
const glob = `${escapePath(dir)}/**/*.{tsx,jsx}`;
|
|
458
|
+
const files = await componentFiles({
|
|
459
|
+
...config,
|
|
460
|
+
components: [glob]
|
|
461
|
+
});
|
|
462
|
+
const folder = {
|
|
463
|
+
namespace,
|
|
464
|
+
dir,
|
|
465
|
+
glob,
|
|
466
|
+
files: files.length
|
|
467
|
+
};
|
|
468
|
+
if (files.some((f) => !taken.has(f))) copied.push(folder);
|
|
469
|
+
else if (files.length || globs.some((g) => matchesGlob(`${dir}/index.tsx`, g))) continue;
|
|
470
|
+
else pending.push(folder);
|
|
471
|
+
added.push(glob);
|
|
472
|
+
}
|
|
473
|
+
const add = found && file.importPath === void 0;
|
|
474
|
+
if (add && added.length) file.components = [...globs, ...added];
|
|
475
|
+
const listed = Array.isArray(file.components) && file.importPath === void 0;
|
|
476
|
+
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})`;
|
|
477
|
+
const notes = [];
|
|
478
|
+
if (copied.length) {
|
|
479
|
+
const one = copied.length === 1;
|
|
480
|
+
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)}.`);
|
|
481
|
+
}
|
|
482
|
+
if (pending.length) {
|
|
483
|
+
const one = pending.length === 1;
|
|
484
|
+
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`;
|
|
485
|
+
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)}.`);
|
|
486
|
+
}
|
|
487
|
+
return notes;
|
|
488
|
+
}
|
|
489
|
+
function isDirectory(dir) {
|
|
490
|
+
try {
|
|
491
|
+
return fs.statSync(dir).isDirectory();
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The folders, relative to the root, that `npx shadcn add` copies the code of the registries
|
|
498
|
+
* components.json names into: the components alias, resolved through tsconfig `paths`, and the
|
|
499
|
+
* namespace without its `@` (`@/components` and `@signoff-ui` → `components/signoff-ui`).
|
|
500
|
+
*/
|
|
501
|
+
function registryFolders(root, tsconfig) {
|
|
502
|
+
let json;
|
|
503
|
+
try {
|
|
504
|
+
json = JSON.parse(fs.readFileSync(path.join(root, "components.json"), "utf8"));
|
|
505
|
+
} catch {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
const alias = json?.aliases?.components;
|
|
509
|
+
const registries = json?.registries;
|
|
510
|
+
if (typeof alias !== "string" || !registries || typeof registries !== "object") return [];
|
|
511
|
+
const namespaces = Object.keys(registries).filter((n) => /^@[a-z0-9][\w-]*$/i.test(n)).sort();
|
|
512
|
+
const base = namespaces.length ? aliasFolder(alias, readProjectConfig(root, tsconfig)) : void 0;
|
|
513
|
+
if (!base || !isInside(root, base)) return [];
|
|
514
|
+
return namespaces.map((namespace) => ({
|
|
515
|
+
namespace,
|
|
516
|
+
dir: relativePath(root, path.join(base, namespace.slice(1)))
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* A components.json alias (`@/components`) through tsconfig `paths` (`"@/*": ["./*"]`), as zero
|
|
521
|
+
* config resolves the ui alias, when the folder exists.
|
|
522
|
+
*/
|
|
523
|
+
function aliasFolder(alias, project) {
|
|
524
|
+
for (const [pattern, targets] of Object.entries(project.paths)) {
|
|
525
|
+
const star = pattern.indexOf("*");
|
|
526
|
+
const prefix = star === -1 ? pattern : pattern.slice(0, star);
|
|
527
|
+
const suffix = star === -1 ? "" : pattern.slice(star + 1);
|
|
528
|
+
if (!(star === -1 ? alias === pattern : alias.startsWith(prefix) && alias.endsWith(suffix) && alias.length >= prefix.length + suffix.length)) continue;
|
|
529
|
+
const middle = star === -1 ? "" : alias.slice(prefix.length, alias.length - suffix.length);
|
|
530
|
+
for (const target of targets) {
|
|
531
|
+
const dir = path.resolve(project.pathsBase, target.replace("*", middle));
|
|
532
|
+
try {
|
|
533
|
+
if (fs.statSync(dir).isDirectory()) return dir;
|
|
534
|
+
} catch {}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
312
538
|
/** Whether npm's `files` list publishes `file`, a path relative to the package. */
|
|
313
539
|
function ships(files, file) {
|
|
314
540
|
return files.some((entry) => {
|
|
@@ -419,6 +645,9 @@ Options
|
|
|
419
645
|
files, which are skipped by default
|
|
420
646
|
--include-tests check: also lint tests and stories (*.test.tsx,
|
|
421
647
|
*.spec.tsx, *.stories.tsx) in folders and globs
|
|
648
|
+
--skip-excluded check: leave out files named outright that the config's
|
|
649
|
+
exclude covers, as folders and globs leave them out
|
|
650
|
+
(the Claude Code hook passes it)
|
|
422
651
|
--quiet-without-design-system
|
|
423
652
|
check: print nothing and exit 0 when the project has no
|
|
424
653
|
design-system components and no config (tokens alone
|
|
@@ -426,7 +655,8 @@ Options
|
|
|
426
655
|
many projects
|
|
427
656
|
--require-design-system
|
|
428
657
|
check: exit 2 when no components or no color tokens
|
|
429
|
-
are found,
|
|
658
|
+
are found, or when the folders and globs given leave
|
|
659
|
+
no file to check, so CI cannot pass by checking nothing
|
|
430
660
|
--explain inspect: every candidate zero config looked at and why
|
|
431
661
|
it was taken or not, the resolved config, unresolved
|
|
432
662
|
tokens, and components whose props did not resolve
|
|
@@ -519,6 +749,10 @@ async function main(argv, io = defaultIo) {
|
|
|
519
749
|
type: "boolean",
|
|
520
750
|
default: false
|
|
521
751
|
},
|
|
752
|
+
"skip-excluded": {
|
|
753
|
+
type: "boolean",
|
|
754
|
+
default: false
|
|
755
|
+
},
|
|
522
756
|
"require-design-system": {
|
|
523
757
|
type: "boolean",
|
|
524
758
|
default: false
|
|
@@ -656,12 +890,30 @@ async function check(patterns, configOptions, values, io) {
|
|
|
656
890
|
warnDeprecated(configWarnings(target), format, io);
|
|
657
891
|
const selection = target.workspace ? await selectInWorkspace(target.workspace, patterns, values, format, io) : await selectInProject(target.config, patterns, values, format, io);
|
|
658
892
|
if (typeof selection === "number") return selection;
|
|
893
|
+
const command = (flag) => checkCommand(patterns, configOptions, values, flag);
|
|
894
|
+
if ("noMatch" in selection) {
|
|
895
|
+
const jsx = jsxNotice(selection, io.cwd, command);
|
|
896
|
+
io.stderr(jsx ? `check: nothing to check in ${patterns.join(" ")}. ${jsx}` : `check: no files match ${patterns.join(" ")}`);
|
|
897
|
+
return 2;
|
|
898
|
+
}
|
|
659
899
|
const { files, notes } = selection;
|
|
900
|
+
const notify = (text) => {
|
|
901
|
+
if (format === "pretty") io.stdout(text);
|
|
902
|
+
else if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
|
|
903
|
+
else io.stderr(text);
|
|
904
|
+
};
|
|
905
|
+
const jsx = jsxNotice(selection, io.cwd, command);
|
|
660
906
|
if (!files.length) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
io.
|
|
664
|
-
|
|
907
|
+
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);
|
|
908
|
+
if (values["require-design-system"] && selection.searched) {
|
|
909
|
+
io.stderr(`check --require-design-system: nothing was checked${notes.length ? ` (${notes.join("; ")})` : ""}. ${hints.join(" ")}`.trimEnd());
|
|
910
|
+
return 2;
|
|
911
|
+
}
|
|
912
|
+
if (format === "pretty") io.stdout(`Nothing to check${notes.length ? `: ${notes.join("; ")}` : ""}. ${hints.join(" ")}`.trimEnd());
|
|
913
|
+
else {
|
|
914
|
+
printNoResults(format, io);
|
|
915
|
+
if (jsx) notify(jsx);
|
|
916
|
+
}
|
|
665
917
|
return 0;
|
|
666
918
|
}
|
|
667
919
|
const update = values["update-baseline"];
|
|
@@ -684,17 +936,20 @@ async function check(patterns, configOptions, values, io) {
|
|
|
684
936
|
};
|
|
685
937
|
const disabledByKey = /* @__PURE__ */ new Map();
|
|
686
938
|
const realRoot = fs.realpathSync.native(selection.root);
|
|
687
|
-
const
|
|
939
|
+
const folders = patterns.map((pattern) => path.resolve(io.cwd, pattern)).filter((dir) => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()).map(realPath);
|
|
940
|
+
const changedLines = baseline && !update ? readChangedLines(io.env ?? process.env, io.cwd) : void 0;
|
|
688
941
|
const results = [];
|
|
689
942
|
const checked = /* @__PURE__ */ new Map();
|
|
690
943
|
const ran = /* @__PURE__ */ new Set();
|
|
691
944
|
let baselined = 0;
|
|
692
945
|
let fixed = 0;
|
|
946
|
+
const unrecorded = [];
|
|
693
947
|
for (const file of files) {
|
|
694
948
|
const code = await fsp.readFile(file, "utf8");
|
|
695
949
|
const ds = selection.designSystemOf(file);
|
|
696
950
|
let result = ds.check(code, relativePath(ds.root, file));
|
|
697
|
-
const
|
|
951
|
+
const real = fs.realpathSync.native(file);
|
|
952
|
+
const key = relativePath(realRoot, real).normalize("NFC");
|
|
698
953
|
const disabled = disabledFor(file);
|
|
699
954
|
disabledByKey.set(key, disabled);
|
|
700
955
|
if (update || prune) checked.set(key, result.diagnostics);
|
|
@@ -702,28 +957,59 @@ async function check(patterns, configOptions, values, io) {
|
|
|
702
957
|
for (const rule of RULE_IDS) if (!disabled.has(rule) && canRun(ds, rule)) ran.add(rule);
|
|
703
958
|
}
|
|
704
959
|
if (baseline && !update) {
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
960
|
+
const changed = changedLines?.get(real);
|
|
961
|
+
if (baseline.entries.has(key)) {
|
|
962
|
+
const match = applyBaseline(baseline.entries, key, result, disabled, changed);
|
|
963
|
+
result = {
|
|
964
|
+
...match.result,
|
|
965
|
+
baselined: match.baselined
|
|
966
|
+
};
|
|
967
|
+
baselined += match.baselined;
|
|
968
|
+
fixed += match.fixed;
|
|
969
|
+
} else unrecorded.push({
|
|
970
|
+
index: results.length,
|
|
971
|
+
key,
|
|
972
|
+
result,
|
|
973
|
+
disabled,
|
|
974
|
+
changed
|
|
975
|
+
});
|
|
712
976
|
}
|
|
713
977
|
results.push({
|
|
714
978
|
...result,
|
|
715
979
|
file: relativePath(io.cwd, file)
|
|
716
980
|
});
|
|
717
981
|
}
|
|
982
|
+
let moves = /* @__PURE__ */ new Map();
|
|
983
|
+
if (baseline && !update) {
|
|
984
|
+
const covered = (key) => folders.some((dir) => isInside(dir, path.join(realRoot, key)));
|
|
985
|
+
const matching = unrecorded.some((u) => u.result.diagnostics.length);
|
|
986
|
+
const gone = goneFiles(baseline.entries, realRoot, new Set(disabledByKey.keys()), (key) => matching || covered(key));
|
|
987
|
+
const changedIn = new Map(unrecorded.map((u) => [u.key, u.changed]));
|
|
988
|
+
moves = findMoves(baseline.entries, new Map(unrecorded.map((u) => [u.key, u.result.diagnostics])), gone, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set(), (key, diagnostic) => onLines(changedIn.get(key), diagnostic));
|
|
989
|
+
for (const { index, key, result, disabled, changed } of unrecorded) {
|
|
990
|
+
const from = moves.get(key) ?? key;
|
|
991
|
+
const match = applyBaseline(baseline.entries, from, result, disabled, changed);
|
|
992
|
+
const file = results[index]?.file ?? result.file;
|
|
993
|
+
results[index] = {
|
|
994
|
+
...match.result,
|
|
995
|
+
baselined: match.baselined,
|
|
996
|
+
file
|
|
997
|
+
};
|
|
998
|
+
baselined += match.baselined;
|
|
999
|
+
fixed += match.fixed;
|
|
1000
|
+
}
|
|
1001
|
+
const moved = new Set(moves.values());
|
|
1002
|
+
for (const key of gone) {
|
|
1003
|
+
const rules = baseline.entries.get(key);
|
|
1004
|
+
if (rules && !moved.has(key) && covered(key)) fixed += countBaseline(/* @__PURE__ */ new Map([[key, rules]])).findings;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
718
1007
|
const elsewhere = results.filter((r) => r.skipped).length;
|
|
719
1008
|
if (elsewhere) notes.push(`${plural(elsewhere, "image or email file")} not checked`);
|
|
720
1009
|
const unchecked = uncheckedNotice(results);
|
|
721
1010
|
if (update) {
|
|
722
1011
|
const next = updateBaseline(baseline?.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
|
|
723
|
-
const wholeRoot =
|
|
724
|
-
const dir = path.resolve(io.cwd, pattern);
|
|
725
|
-
return Boolean(fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() && isInside(realPath(dir), realRoot));
|
|
726
|
-
});
|
|
1012
|
+
const wholeRoot = folders.some((dir) => isInside(dir, realRoot));
|
|
727
1013
|
writeBaseline(baselineFile, next, {
|
|
728
1014
|
onsystem: VERSION,
|
|
729
1015
|
rules: rulesRun(baseline, next, checked, ran, wholeRoot)
|
|
@@ -734,9 +1020,9 @@ async function check(patterns, configOptions, values, io) {
|
|
|
734
1020
|
else io.stderr(summary);
|
|
735
1021
|
for (const text of selection.notices) io.stderr(text);
|
|
736
1022
|
if (unchecked) io.stderr(unchecked);
|
|
1023
|
+
if (jsx) io.stderr(jsx);
|
|
737
1024
|
return 0;
|
|
738
1025
|
}
|
|
739
|
-
const command = (flag) => checkCommand(patterns, configOptions, values, flag);
|
|
740
1026
|
const baselineNotes = [];
|
|
741
1027
|
if (baseline) {
|
|
742
1028
|
const fresh = results.flatMap((r) => r.diagnostics);
|
|
@@ -744,15 +1030,16 @@ async function check(patterns, configOptions, values, io) {
|
|
|
744
1030
|
if (unrecorded.size) baselineNotes.push(unrecordedNote(unrecorded, fresh.length, baseline.onsystem, command("--update-baseline")));
|
|
745
1031
|
}
|
|
746
1032
|
if (prune && baseline) {
|
|
747
|
-
const next = pruneBaseline(baseline.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
|
|
1033
|
+
const next = pruneBaseline(baseline.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set(), moves);
|
|
748
1034
|
const left = countBaseline(next);
|
|
749
1035
|
const dropped = countBaseline(baseline.entries).findings - left.findings;
|
|
750
|
-
if (dropped) writeBaseline(baselineFile, next, {
|
|
1036
|
+
if (dropped || moves.size) writeBaseline(baselineFile, next, {
|
|
751
1037
|
onsystem: baseline.onsystem,
|
|
752
1038
|
rules: baseline.rules
|
|
753
1039
|
});
|
|
754
|
-
|
|
755
|
-
|
|
1040
|
+
const done = [moves.size ? `moved the findings of ${count(moves.size, "file")} to ${moves.size === 1 ? "its new path" : "their new paths"}` : "", dropped ? `dropped ${count(dropped, "finding")} that no longer ${dropped === 1 ? "occurs" : "occur"}` : ""].filter(Boolean);
|
|
1041
|
+
baselineNotes.push(`Baseline: ${done.join("; ") || "nothing to drop"}; ${count(left.findings, "finding")} in ${count(left.files, "file")} → ${displayPath(io.cwd, baselineFile)}`);
|
|
1042
|
+
} else if (moves.size) baselineNotes.push(movedNote(moves, command("--prune-baseline")));
|
|
756
1043
|
const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
|
|
757
1044
|
color: io.color,
|
|
758
1045
|
uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
|
|
@@ -764,12 +1051,11 @@ async function check(patterns, configOptions, values, io) {
|
|
|
764
1051
|
for (const text of baselineNotes) if (format === "pretty") io.stdout(text);
|
|
765
1052
|
else if (format === "github") io.stdout(`::notice title=${NAME} baseline::${escapeData(text)}`);
|
|
766
1053
|
else io.stderr(text);
|
|
767
|
-
for (const text of [
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
}
|
|
1054
|
+
for (const text of [
|
|
1055
|
+
...selection.notices,
|
|
1056
|
+
unchecked,
|
|
1057
|
+
jsx
|
|
1058
|
+
]) if (text) notify(text);
|
|
773
1059
|
const errors = results.reduce((n, r) => n + r.errorCount, 0);
|
|
774
1060
|
const warnings = results.reduce((n, r) => n + r.warningCount, 0);
|
|
775
1061
|
if (errors > 0) return 1;
|
|
@@ -808,10 +1094,15 @@ function repositoryPath(cwd, env) {
|
|
|
808
1094
|
return root ? relativePath(root, absolute) : toPosix(file);
|
|
809
1095
|
};
|
|
810
1096
|
}
|
|
811
|
-
/**
|
|
1097
|
+
/**
|
|
1098
|
+
* Splits the patterns into files named outright and globs (a folder: every TSX/JSX file under
|
|
1099
|
+
* it). `scripts` globs the `.js` files under the folders, which are not checked, to say which
|
|
1100
|
+
* of them have JSX.
|
|
1101
|
+
*/
|
|
812
1102
|
function expandPatterns(patterns, cwd) {
|
|
813
1103
|
const named = [];
|
|
814
1104
|
const globs = [];
|
|
1105
|
+
const scripts = [];
|
|
815
1106
|
for (const pattern of patterns) {
|
|
816
1107
|
const stat = fs.statSync(path.resolve(cwd, pattern), { throwIfNoEntry: false });
|
|
817
1108
|
if (stat && !stat.isDirectory()) {
|
|
@@ -819,47 +1110,131 @@ function expandPatterns(patterns, cwd) {
|
|
|
819
1110
|
continue;
|
|
820
1111
|
}
|
|
821
1112
|
const literal = stat ? escapePath(toPosix(path.join(pattern, "."))) : toPosix(pattern);
|
|
822
|
-
|
|
1113
|
+
if (!stat) {
|
|
1114
|
+
globs.push(literal);
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const under = literal === "." ? "**/*" : `${literal}/**/*`;
|
|
1118
|
+
globs.push(`${under}.{tsx,jsx}`);
|
|
1119
|
+
scripts.push(`${under}.js`);
|
|
823
1120
|
}
|
|
824
1121
|
return {
|
|
825
1122
|
named,
|
|
826
|
-
globs
|
|
1123
|
+
globs,
|
|
1124
|
+
scripts
|
|
827
1125
|
};
|
|
828
1126
|
}
|
|
1127
|
+
/** A `.js` file larger than this is a bundle or generated, not a page: not read for JSX. */
|
|
1128
|
+
const MAX_PAGE_BYTES = 524288;
|
|
1129
|
+
/**
|
|
1130
|
+
* `.js` files the folders hold that have JSX, which are not checked: folders expand to TSX and
|
|
1131
|
+
* JSX files, and a JavaScript app writes its pages as `.js` (Next.js's JavaScript template has
|
|
1132
|
+
* `app/page.js`). Files the run checks anyway (a glob names them), what git ignores and what
|
|
1133
|
+
* `leftOut` says (the config's `exclude`, the design system's own files) are not counted, nor
|
|
1134
|
+
* are minified files and files over 512 KB, which are bundles rather than pages.
|
|
1135
|
+
*/
|
|
1136
|
+
async function jsxLeftOut(scripts, taken, cwd, leftOut) {
|
|
1137
|
+
if (!scripts.length) return [];
|
|
1138
|
+
const checked = new Set(taken.map(realPath));
|
|
1139
|
+
const found = oncePerFile((await findFiles(scripts, {
|
|
1140
|
+
cwd,
|
|
1141
|
+
absolute: true,
|
|
1142
|
+
expandDirectories: false,
|
|
1143
|
+
ignore: ["**/node_modules/**", "**/*.min.js"]
|
|
1144
|
+
})).filter((file) => !checked.has(realPath(file)))).sort();
|
|
1145
|
+
const ignored = gitIgnored(cwd, found);
|
|
1146
|
+
const files = [];
|
|
1147
|
+
for (const file of found) {
|
|
1148
|
+
if (ignored.has(file) || await leftOut(file)) continue;
|
|
1149
|
+
let code;
|
|
1150
|
+
try {
|
|
1151
|
+
if (fs.statSync(file).size > MAX_PAGE_BYTES) continue;
|
|
1152
|
+
code = fs.readFileSync(file, "utf8");
|
|
1153
|
+
} catch {
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
if (hasJsx(code)) files.push(file);
|
|
1157
|
+
}
|
|
1158
|
+
return files;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Where a JSX element can start: after `(`, `,`, `=`, `=>`, `?` (not a regex's `(?<name>`), `:`,
|
|
1162
|
+
* `[`, `{`, `&`, `|`, `return`, `yield`, `await` or `default`, with spaces and short comments
|
|
1163
|
+
* between. Plain JavaScript has a `<` and a name there only in strings and comments.
|
|
1164
|
+
*/
|
|
1165
|
+
const JSX_START = /(?:(?<!\()\?|=>|[(,=:[{&|]|\b(?:return|yield|await|default))(?:\s|\/\/[^\n]{0,200}\n|\/\*(?:[^*]|\*(?!\/)){0,500}\*\/)*<([A-Za-z][\w.:-]*|>(?=[\s<{]))/g;
|
|
1166
|
+
/**
|
|
1167
|
+
* Whether the code may have JSX: an element starts where only JSX can start one, and it is a
|
|
1168
|
+
* fragment, its closing tag is there, or a `/>` follows it. A library bundle whose strings hold
|
|
1169
|
+
* HTML rarely passes, so most `.js` files are never parsed.
|
|
1170
|
+
*/
|
|
1171
|
+
function mayHaveJsx(code) {
|
|
1172
|
+
for (const match of code.matchAll(JSX_START)) {
|
|
1173
|
+
const name = match[1] ?? "";
|
|
1174
|
+
if (name === ">" || code.includes(`</${name}>`)) return true;
|
|
1175
|
+
const from = match.index + match[0].length;
|
|
1176
|
+
const close = code.indexOf("/>", from);
|
|
1177
|
+
if (close >= 0 && close - from < 2e3) return true;
|
|
1178
|
+
}
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
/** Whether the code has a JSX element or fragment. */
|
|
1182
|
+
function hasJsx(code) {
|
|
1183
|
+
if (!code.includes("/>") && !code.includes("</")) return false;
|
|
1184
|
+
if (!mayHaveJsx(code)) return false;
|
|
1185
|
+
const source = ts.createSourceFile("file.js", code, ts.ScriptTarget.Latest, false, ts.ScriptKind.JSX);
|
|
1186
|
+
const visit = (node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node) || ts.forEachChild(node, visit) === true;
|
|
1187
|
+
try {
|
|
1188
|
+
return visit(source);
|
|
1189
|
+
} catch {
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/** Which `.js` files with JSX the folders left out, and the command that checks them. */
|
|
1194
|
+
function jsxNotice(selection, cwd, command) {
|
|
1195
|
+
const files = selection.jsx.map((file) => relativePath(cwd, file));
|
|
1196
|
+
if (!files.length) return void 0;
|
|
1197
|
+
const check = command(selection.jsxGlobs.map(shellQuote).join(" "));
|
|
1198
|
+
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}\`.`;
|
|
1199
|
+
}
|
|
829
1200
|
/** One project: the root's config and design system for every file. */
|
|
830
1201
|
async function selectInProject(config, patterns, values, format, io) {
|
|
831
1202
|
const ds = await loadDesignSystem(config, {
|
|
832
1203
|
cache: values.cache,
|
|
833
1204
|
logger: silentLogger
|
|
834
1205
|
});
|
|
835
|
-
const { named, globs } = expandPatterns(patterns, io.cwd);
|
|
1206
|
+
const { named: given, globs, scripts } = expandPatterns(patterns, io.cwd);
|
|
836
1207
|
const options = {
|
|
837
1208
|
cwd: io.cwd,
|
|
838
1209
|
absolute: true,
|
|
839
1210
|
expandDirectories: false
|
|
840
1211
|
};
|
|
841
|
-
const
|
|
1212
|
+
const globbed = globs.length ? await findFiles(globs, {
|
|
842
1213
|
...options,
|
|
843
1214
|
ignore: ["**/node_modules/**"]
|
|
844
1215
|
}) : [];
|
|
845
|
-
const
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1216
|
+
const named = given.filter((file) => SCRIPT_FILE.test(file));
|
|
1217
|
+
const all = globbed.filter((file) => SCRIPT_FILE.test(file));
|
|
1218
|
+
const excluded = (file) => excludedBy(config, relativePath(config.root, file), { includeTests: values["include-tests"] }) !== void 0;
|
|
1219
|
+
const ownFiles = async () => new Set(config.includeDesignSystem ? [] : (await componentFiles(config)).map(realPath));
|
|
1220
|
+
const jsxIn = (taken, own) => jsxLeftOut(scripts, taken, io.cwd, (file) => excluded(file) || own.has(realPath(file)));
|
|
1221
|
+
const skip = values["skip-excluded"];
|
|
1222
|
+
const leftOut = unique([...skip ? named : [], ...all]).filter(excluded);
|
|
1223
|
+
const leftOutSet = new Set(leftOut);
|
|
1224
|
+
const kept = all.filter((file) => !leftOutSet.has(file));
|
|
850
1225
|
const ignored = gitIgnored(io.cwd, kept);
|
|
851
|
-
const keptSet = new Set(kept);
|
|
852
|
-
const leftOut = all.filter((file) => !keptSet.has(file));
|
|
853
1226
|
const left = {
|
|
854
1227
|
tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
|
|
855
1228
|
excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
|
|
856
|
-
gitignored: ignored.size
|
|
1229
|
+
gitignored: ignored.size,
|
|
1230
|
+
notScripts: notScripts(given, globbed)
|
|
1231
|
+
};
|
|
1232
|
+
let files = oncePerFile([...skip ? named.filter((file) => !leftOutSet.has(file)) : named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
1233
|
+
if (!files.length && !all.length && !leftOut.length && !left.notScripts) return {
|
|
1234
|
+
noMatch: true,
|
|
1235
|
+
jsx: scripts.length ? await jsxIn([], await ownFiles()) : [],
|
|
1236
|
+
jsxGlobs: scripts
|
|
857
1237
|
};
|
|
858
|
-
let files = oncePerFile([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
859
|
-
if (!files.length && !all.length) {
|
|
860
|
-
io.stderr(`check: no files match ${patterns.join(" ")}`);
|
|
861
|
-
return 2;
|
|
862
|
-
}
|
|
863
1238
|
if (values["quiet-without-design-system"] && !config.configFile && !ds.components.length) {
|
|
864
1239
|
printNoResults(format, io);
|
|
865
1240
|
return 0;
|
|
@@ -871,12 +1246,13 @@ async function selectInProject(config, patterns, values, format, io) {
|
|
|
871
1246
|
return 2;
|
|
872
1247
|
}
|
|
873
1248
|
let skipped = 0;
|
|
874
|
-
|
|
875
|
-
|
|
1249
|
+
const own = await ownFiles();
|
|
1250
|
+
if (own.size) {
|
|
876
1251
|
const rest = files.filter((file) => !own.has(realPath(file)));
|
|
877
1252
|
skipped = files.length - rest.length;
|
|
878
1253
|
files = rest;
|
|
879
1254
|
}
|
|
1255
|
+
const jsx = await jsxIn([...named, ...all], own);
|
|
880
1256
|
return {
|
|
881
1257
|
files,
|
|
882
1258
|
designSystemOf: () => ds,
|
|
@@ -884,7 +1260,10 @@ async function selectInProject(config, patterns, values, format, io) {
|
|
|
884
1260
|
notes: scopeNotes(skipped, left),
|
|
885
1261
|
notices: [...notice ? [notice] : [], ...notReadNotices([{ ds }])],
|
|
886
1262
|
skipped,
|
|
887
|
-
testsLeftOut: left.tests
|
|
1263
|
+
testsLeftOut: left.tests,
|
|
1264
|
+
jsx,
|
|
1265
|
+
jsxGlobs: scripts,
|
|
1266
|
+
searched: globs.length > 0
|
|
888
1267
|
};
|
|
889
1268
|
}
|
|
890
1269
|
/**
|
|
@@ -893,13 +1272,15 @@ async function selectInProject(config, patterns, values, format, io) {
|
|
|
893
1272
|
* config, exactly as `check` from that folder (and the Claude Code hook) would check it.
|
|
894
1273
|
*/
|
|
895
1274
|
async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
896
|
-
const { named, globs } = expandPatterns(patterns, io.cwd);
|
|
897
|
-
const
|
|
1275
|
+
const { named: given, globs, scripts } = expandPatterns(patterns, io.cwd);
|
|
1276
|
+
const globbed = globs.length ? await findFiles(globs, {
|
|
898
1277
|
cwd: io.cwd,
|
|
899
1278
|
absolute: true,
|
|
900
1279
|
expandDirectories: false,
|
|
901
1280
|
ignore: ["**/node_modules/**"]
|
|
902
1281
|
}) : [];
|
|
1282
|
+
const named = given.filter((file) => SCRIPT_FILE.test(file));
|
|
1283
|
+
const all = globbed.filter((file) => SCRIPT_FILE.test(file));
|
|
903
1284
|
const projectOf = /* @__PURE__ */ new Map();
|
|
904
1285
|
for (const file of unique([...named, ...all])) projectOf.set(file, await workspace.projectFor(file));
|
|
905
1286
|
const project = (file) => {
|
|
@@ -919,24 +1300,42 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
919
1300
|
}
|
|
920
1301
|
return ds;
|
|
921
1302
|
};
|
|
922
|
-
const
|
|
1303
|
+
const excluded = (file) => {
|
|
923
1304
|
const { root, config } = project(file);
|
|
924
1305
|
const relative = relativePath(root, file);
|
|
925
|
-
return config
|
|
926
|
-
}
|
|
1306
|
+
return excludedBy(config, relative, { includeTests: values["include-tests"] }) !== void 0;
|
|
1307
|
+
};
|
|
1308
|
+
const skip = values["skip-excluded"];
|
|
1309
|
+
const leftOut = unique([...skip ? named : [], ...all]).filter(excluded);
|
|
927
1310
|
const leftOutSet = new Set(leftOut);
|
|
928
1311
|
const kept = all.filter((file) => !leftOutSet.has(file));
|
|
929
1312
|
const ignored = gitIgnored(io.cwd, kept);
|
|
930
1313
|
const left = {
|
|
931
1314
|
tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
|
|
932
1315
|
excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
|
|
933
|
-
gitignored: ignored.size
|
|
1316
|
+
gitignored: ignored.size,
|
|
1317
|
+
notScripts: notScripts(given, globbed)
|
|
1318
|
+
};
|
|
1319
|
+
let files = oncePerFile([...skip ? named.filter((file) => !leftOutSet.has(file)) : named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
1320
|
+
const own = /* @__PURE__ */ new Map();
|
|
1321
|
+
const ownFiles = async (p) => {
|
|
1322
|
+
let mine = own.get(p.root);
|
|
1323
|
+
if (!mine) {
|
|
1324
|
+
mine = new Set(p.config.includeDesignSystem ? [] : (await componentFiles(p.config)).map(realPath));
|
|
1325
|
+
own.set(p.root, mine);
|
|
1326
|
+
}
|
|
1327
|
+
return mine;
|
|
1328
|
+
};
|
|
1329
|
+
const jsxIn = (taken) => jsxLeftOut(scripts, taken, io.cwd, async (file) => {
|
|
1330
|
+
const p = await workspace.projectFor(file);
|
|
1331
|
+
projectOf.set(file, p);
|
|
1332
|
+
return excluded(file) || (await ownFiles(p)).has(realPath(file));
|
|
1333
|
+
});
|
|
1334
|
+
if (!files.length && !all.length && !leftOut.length && !left.notScripts) return {
|
|
1335
|
+
noMatch: true,
|
|
1336
|
+
jsx: await jsxIn([]),
|
|
1337
|
+
jsxGlobs: scripts
|
|
934
1338
|
};
|
|
935
|
-
let files = oncePerFile([...named, ...kept.filter((file) => !ignored.has(file))]).sort();
|
|
936
|
-
if (!files.length && !all.length) {
|
|
937
|
-
io.stderr(`check: no files match ${patterns.join(" ")}`);
|
|
938
|
-
return 2;
|
|
939
|
-
}
|
|
940
1339
|
const loaded = /* @__PURE__ */ new Map();
|
|
941
1340
|
for (const p of unique(files.map(project))) loaded.set(p.root, await load(p));
|
|
942
1341
|
const designSystemOf = (file) => {
|
|
@@ -963,14 +1362,11 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
963
1362
|
return 2;
|
|
964
1363
|
}
|
|
965
1364
|
}
|
|
966
|
-
const
|
|
967
|
-
for (const p of unique(files.map(project))) {
|
|
968
|
-
if (p.config.includeDesignSystem) continue;
|
|
969
|
-
own.set(p.root, new Set((await componentFiles(p.config)).map(realPath)));
|
|
970
|
-
}
|
|
1365
|
+
for (const p of unique(files.map(project))) await ownFiles(p);
|
|
971
1366
|
const rest = files.filter((file) => !own.get(project(file).root)?.has(realPath(file)));
|
|
972
1367
|
const skipped = files.length - rest.length;
|
|
973
1368
|
files = rest;
|
|
1369
|
+
const jsx = await jsxIn([...named, ...all]);
|
|
974
1370
|
const gaps = /* @__PURE__ */ new Map();
|
|
975
1371
|
for (const p of unique(files.map(project))) {
|
|
976
1372
|
const ds = loaded.get(p.root);
|
|
@@ -1000,7 +1396,10 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
1000
1396
|
notes: [...used > 1 ? [plural(used, "project")] : [], ...scopeNotes(skipped, left)],
|
|
1001
1397
|
notices: notices.filter(Boolean),
|
|
1002
1398
|
skipped,
|
|
1003
|
-
testsLeftOut: left.tests
|
|
1399
|
+
testsLeftOut: left.tests,
|
|
1400
|
+
jsx,
|
|
1401
|
+
jsxGlobs: scripts,
|
|
1402
|
+
searched: globs.length > 0
|
|
1004
1403
|
};
|
|
1005
1404
|
}
|
|
1006
1405
|
/** `apps/web`, `apps/web and packages/ui`, `a, b, c and 4 more`. */
|
|
@@ -1226,9 +1625,12 @@ function defaultBaseline(root, warn) {
|
|
|
1226
1625
|
}
|
|
1227
1626
|
/** Tests and stories, which `check` leaves out of folders and globs unless asked. */
|
|
1228
1627
|
const TEST_FILE = /\.(?:test|spec|stories)\.[cm]?[jt]sx?$|[\\/]__(?:tests|stories|mocks)__[\\/]/;
|
|
1229
|
-
/**
|
|
1230
|
-
|
|
1231
|
-
|
|
1628
|
+
/**
|
|
1629
|
+
* How many of the files named outright or matched by a glob are not JS or TS (styles, docs,
|
|
1630
|
+
* JSON), which are not checked.
|
|
1631
|
+
*/
|
|
1632
|
+
function notScripts(named, globbed) {
|
|
1633
|
+
return oncePerFile([...named, ...globbed].filter((file) => !SCRIPT_FILE.test(file))).length;
|
|
1232
1634
|
}
|
|
1233
1635
|
/** What a run left out, for the summary line: `5 design-system files skipped`. */
|
|
1234
1636
|
function scopeNotes(skipped, left) {
|
|
@@ -1236,22 +1638,11 @@ function scopeNotes(skipped, left) {
|
|
|
1236
1638
|
skipped ? `${plural(skipped, "design-system file")} skipped` : "",
|
|
1237
1639
|
left.tests ? `${left.tests.toLocaleString("en-US")} ${left.tests === 1 ? "test or story" : "tests and stories"} left out` : "",
|
|
1238
1640
|
left.excluded ? `${plural(left.excluded, "excluded file")} left out` : "",
|
|
1239
|
-
left.gitignored ? `${plural(left.gitignored, "file")} git ignores left out` : ""
|
|
1641
|
+
left.gitignored ? `${plural(left.gitignored, "file")} git ignores left out` : "",
|
|
1642
|
+
left.notScripts ? `${plural(left.notScripts, "file")} that ${left.notScripts === 1 ? "is" : "are"} not JS or TS skipped` : ""
|
|
1240
1643
|
].filter(Boolean);
|
|
1241
1644
|
}
|
|
1242
1645
|
/**
|
|
1243
|
-
* A config `exclude` pattern, relative to the root, as a pattern relative to the
|
|
1244
|
-
* working directory the globs run from; undefined when it only covers files
|
|
1245
|
-
* outside that directory. `**\/` patterns hold anywhere.
|
|
1246
|
-
*/
|
|
1247
|
-
function excludeFromCwd(pattern, root, cwd) {
|
|
1248
|
-
if (pattern.startsWith("**/")) return pattern;
|
|
1249
|
-
const fromRoot = toPosix(path.relative(root, cwd));
|
|
1250
|
-
if (!fromRoot) return pattern;
|
|
1251
|
-
if (fromRoot.startsWith("..")) return toPosix(path.join(path.relative(cwd, root), pattern));
|
|
1252
|
-
return pattern.startsWith(`${fromRoot}/`) ? pattern.slice(fromRoot.length + 1) : void 0;
|
|
1253
|
-
}
|
|
1254
|
-
/**
|
|
1255
1646
|
* Files a glob matches, as Node spells absolute paths (with backslashes on Windows, where
|
|
1256
1647
|
* tinyglobby gives forward slashes), so they compare equal to `path.resolve` and git's.
|
|
1257
1648
|
*/
|
|
@@ -1302,6 +1693,7 @@ function checkCommand(patterns, configOptions, values, flag) {
|
|
|
1302
1693
|
option("baseline", values.baseline);
|
|
1303
1694
|
if (configOptions.includeDesignSystem !== void 0) args.push(`--${configOptions.includeDesignSystem ? "" : "no-"}include-design-system`);
|
|
1304
1695
|
if (values["include-tests"]) args.push("--include-tests");
|
|
1696
|
+
if (values["skip-excluded"]) args.push("--skip-excluded");
|
|
1305
1697
|
return `check ${args.map(shellQuote).join(" ")} ${flag}`;
|
|
1306
1698
|
}
|
|
1307
1699
|
/**
|
|
@@ -1320,6 +1712,40 @@ function canRun(ds, rule) {
|
|
|
1320
1712
|
default: return ds.model.components.length > 0;
|
|
1321
1713
|
}
|
|
1322
1714
|
}
|
|
1715
|
+
/** Says which files the baseline takes for moved, and how to record their new paths. */
|
|
1716
|
+
function movedNote(moves, prune) {
|
|
1717
|
+
const pairs = [...moves].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1718
|
+
const [first] = pairs;
|
|
1719
|
+
if (pairs.length === 1 && first) return `Baseline: ${first[0]} has the findings recorded for ${first[1]}, which no longer exists, so they are accepted as moved. Run \`${prune}\` to record the new path.`;
|
|
1720
|
+
const list = listOf(pairs.map(([to, from]) => `${from} → ${to}`));
|
|
1721
|
+
return `Baseline: ${pairs.length} files have the findings recorded for files that no longer exist, so they are accepted as moved (${list}). Run \`${prune}\` to record the new paths.`;
|
|
1722
|
+
}
|
|
1723
|
+
/** The variable the GitHub Action names the pull request's changed lines in. */
|
|
1724
|
+
const CHANGED_LINES = "ONSYSTEM_CHANGED_LINES";
|
|
1725
|
+
/**
|
|
1726
|
+
* The lines a pull request changed, by the real path of each file: the JSON file that
|
|
1727
|
+
* ONSYSTEM_CHANGED_LINES names maps paths to 1-based ranges, `{ "/repo/app/page.tsx": [[4, 4]] }`.
|
|
1728
|
+
* The GitHub Action writes it. Undefined when the variable is not set.
|
|
1729
|
+
*/
|
|
1730
|
+
function readChangedLines(env, cwd) {
|
|
1731
|
+
const file = env[CHANGED_LINES]?.trim();
|
|
1732
|
+
if (!file) return void 0;
|
|
1733
|
+
const fail = (why) => new ConfigError(`${CHANGED_LINES}: ${why}. The GitHub Action sets this variable to a file of the lines a pull request changed; unset it when you run check yourself.`);
|
|
1734
|
+
let parsed;
|
|
1735
|
+
try {
|
|
1736
|
+
parsed = JSON.parse(fs.readFileSync(path.resolve(cwd, file), "utf8"));
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
throw fail(`could not read ${file}: ${error.message}`);
|
|
1739
|
+
}
|
|
1740
|
+
const range = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => Number.isInteger(n));
|
|
1741
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw fail(`${file} must map files to line ranges`);
|
|
1742
|
+
const lines = /* @__PURE__ */ new Map();
|
|
1743
|
+
for (const [name, ranges] of Object.entries(parsed)) {
|
|
1744
|
+
if (!Array.isArray(ranges) || !ranges.every(range)) throw fail(`${file} › "${name}" must be a list of [start, end] lines`);
|
|
1745
|
+
lines.set(realPath(path.resolve(cwd, name)), ranges);
|
|
1746
|
+
}
|
|
1747
|
+
return lines;
|
|
1748
|
+
}
|
|
1323
1749
|
/** Why findings of rules that did not run when the baseline was recorded are new to it. */
|
|
1324
1750
|
function unrecordedNote(byRule, total, version, update) {
|
|
1325
1751
|
const rules = [...byRule.keys()].sort();
|