onsystem 0.7.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/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { C as pruneBaseline, Ct as relativePath, D as updateBaseline, E as unrecordedRules, H as CONFIG_FILES, J as LEGACY_CONFIG_FILES, N as toDesignMd, O as writeBaseline, P as toDtcg, S as countBaseline, St as matchesGlob, T as rulesRun, U as ConfigError, W as DEFAULT_DOCS, Y as RULE_IDS, _ as NAME, b as LEGACY_BASELINE_FILE, bt as unique, c as silentLogger, ct as PACKAGE_FIELD, dt as detectProject, g as formatDiagnostics, h as escapeData, i as componentFiles, l as stderrLogger, m as uncheckedNotice, o as loadDesignSystem, ot as libraryTitle, pt as isDesignSystemName, s as tokenFiles, st as notReadYet, tt as rulesFor, ut as declaredConfig, v as VERSION, vt as plural, w as readBaseline, wt as toPosix, x as applyBaseline, xt as isInside, y as BASELINE_FILE } from "./design-system-C4bhqLLw.js";
3
- import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-BojAQ6JP.js";
4
- import { r as loadTarget } from "./workspace-Z0tZf_Dv.js";
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";
@@ -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);
@@ -177,12 +185,93 @@ async function init(configOptions, options, io, runCheck) {
177
185
  baselineWritten = fs.existsSync(path.join(root, BASELINE_FILE));
178
186
  } else io.stdout(`No baseline written; \`${NAME} check . --update-baseline\` records one later.`);
179
187
  }
180
- const commit = [...written, ...baselineWritten ? [BASELINE_FILE] : []];
188
+ const instructed = await agentRules(root, ask, io);
189
+ const commit = [
190
+ ...written,
191
+ ...baselineWritten ? [BASELINE_FILE] : [],
192
+ ...instructed
193
+ ];
181
194
  const files = commit.length > 1 ? `${commit.slice(0, -1).join(", ")} and ${commit.at(-1) ?? ""}` : commit[0];
182
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.`);
183
196
  return 0;
184
197
  }
185
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
+ }
186
275
  function readManifest(root) {
187
276
  const file = path.join(root, "package.json");
188
277
  try {
@@ -309,6 +398,113 @@ async function replacedNote(pkg, taken, own, cwd) {
309
398
  ];
310
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.`;
311
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
+ }
507
+ }
312
508
  /** Whether npm's `files` list publishes `file`, a path relative to the package. */
313
509
  function ships(files, file) {
314
510
  return files.some((entry) => {
@@ -419,6 +615,9 @@ Options
419
615
  files, which are skipped by default
420
616
  --include-tests check: also lint tests and stories (*.test.tsx,
421
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)
422
621
  --quiet-without-design-system
423
622
  check: print nothing and exit 0 when the project has no
424
623
  design-system components and no config (tokens alone
@@ -426,7 +625,8 @@ Options
426
625
  many projects
427
626
  --require-design-system
428
627
  check: exit 2 when no components or no color tokens
429
- are found, so CI cannot pass by checking nothing
628
+ are found, or when the folders and globs given leave
629
+ no file to check, so CI cannot pass by checking nothing
430
630
  --explain inspect: every candidate zero config looked at and why
431
631
  it was taken or not, the resolved config, unresolved
432
632
  tokens, and components whose props did not resolve
@@ -519,6 +719,10 @@ async function main(argv, io = defaultIo) {
519
719
  type: "boolean",
520
720
  default: false
521
721
  },
722
+ "skip-excluded": {
723
+ type: "boolean",
724
+ default: false
725
+ },
522
726
  "require-design-system": {
523
727
  type: "boolean",
524
728
  default: false
@@ -656,12 +860,30 @@ async function check(patterns, configOptions, values, io) {
656
860
  warnDeprecated(configWarnings(target), format, io);
657
861
  const selection = target.workspace ? await selectInWorkspace(target.workspace, patterns, values, format, io) : await selectInProject(target.config, patterns, values, format, io);
658
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
+ }
659
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);
660
876
  if (!files.length) {
661
- if (format === "pretty") {
662
- const hints = [selection.skipped ? `Pass --include-design-system to check ${selection.skipped === 1 ? "it" : "them"}.` : "", selection.testsLeftOut ? "Pass --include-tests to check tests and stories." : ""].filter(Boolean);
663
- io.stdout(`Nothing to check: ${notes.join("; ")}. ${hints.join(" ")}`.trimEnd());
664
- } else printNoResults(format, io);
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
+ }
665
887
  return 0;
666
888
  }
667
889
  const update = values["update-baseline"];
@@ -734,9 +956,9 @@ async function check(patterns, configOptions, values, io) {
734
956
  else io.stderr(summary);
735
957
  for (const text of selection.notices) io.stderr(text);
736
958
  if (unchecked) io.stderr(unchecked);
959
+ if (jsx) io.stderr(jsx);
737
960
  return 0;
738
961
  }
739
- const command = (flag) => checkCommand(patterns, configOptions, values, flag);
740
962
  const baselineNotes = [];
741
963
  if (baseline) {
742
964
  const fresh = results.flatMap((r) => r.diagnostics);
@@ -764,12 +986,11 @@ async function check(patterns, configOptions, values, io) {
764
986
  for (const text of baselineNotes) if (format === "pretty") io.stdout(text);
765
987
  else if (format === "github") io.stdout(`::notice title=${NAME} baseline::${escapeData(text)}`);
766
988
  else io.stderr(text);
767
- for (const text of [...selection.notices, unchecked]) {
768
- if (!text) continue;
769
- if (format === "pretty") io.stdout(text);
770
- else if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
771
- else io.stderr(text);
772
- }
989
+ for (const text of [
990
+ ...selection.notices,
991
+ unchecked,
992
+ jsx
993
+ ]) if (text) notify(text);
773
994
  const errors = results.reduce((n, r) => n + r.errorCount, 0);
774
995
  const warnings = results.reduce((n, r) => n + r.warningCount, 0);
775
996
  if (errors > 0) return 1;
@@ -808,10 +1029,15 @@ function repositoryPath(cwd, env) {
808
1029
  return root ? relativePath(root, absolute) : toPosix(file);
809
1030
  };
810
1031
  }
811
- /** Splits the patterns into files named outright and globs (a folder: every TSX/JSX file under it). */
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
+ */
812
1037
  function expandPatterns(patterns, cwd) {
813
1038
  const named = [];
814
1039
  const globs = [];
1040
+ const scripts = [];
815
1041
  for (const pattern of patterns) {
816
1042
  const stat = fs.statSync(path.resolve(cwd, pattern), { throwIfNoEntry: false });
817
1043
  if (stat && !stat.isDirectory()) {
@@ -819,47 +1045,131 @@ function expandPatterns(patterns, cwd) {
819
1045
  continue;
820
1046
  }
821
1047
  const literal = stat ? escapePath(toPosix(path.join(pattern, "."))) : toPosix(pattern);
822
- globs.push(!stat ? literal : literal === "." ? "**/*.{tsx,jsx}" : `${literal}/**/*.{tsx,jsx}`);
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`);
823
1055
  }
824
1056
  return {
825
1057
  named,
826
- globs
1058
+ globs,
1059
+ scripts
827
1060
  };
828
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
+ }
829
1135
  /** One project: the root's config and design system for every file. */
830
1136
  async function selectInProject(config, patterns, values, format, io) {
831
1137
  const ds = await loadDesignSystem(config, {
832
1138
  cache: values.cache,
833
1139
  logger: silentLogger
834
1140
  });
835
- const { named, globs } = expandPatterns(patterns, io.cwd);
1141
+ const { named: given, globs, scripts } = expandPatterns(patterns, io.cwd);
836
1142
  const options = {
837
1143
  cwd: io.cwd,
838
1144
  absolute: true,
839
1145
  expandDirectories: false
840
1146
  };
841
- const all = globs.length ? await findFiles(globs, {
1147
+ const globbed = globs.length ? await findFiles(globs, {
842
1148
  ...options,
843
1149
  ignore: ["**/node_modules/**"]
844
1150
  }) : [];
845
- const excludes = config.exclude.filter((e) => !(values["include-tests"] && isTestPattern(e))).map((e) => excludeFromCwd(e, config.root, io.cwd)).filter((e) => e !== void 0);
846
- const kept = globs.length ? await findFiles(globs, {
847
- ...options,
848
- ignore: unique(["**/node_modules/**", ...excludes])
849
- }) : [];
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));
850
1160
  const ignored = gitIgnored(io.cwd, kept);
851
- const keptSet = new Set(kept);
852
- const leftOut = all.filter((file) => !keptSet.has(file));
853
1161
  const left = {
854
1162
  tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
855
1163
  excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
856
- 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
857
1172
  };
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
1173
  if (values["quiet-without-design-system"] && !config.configFile && !ds.components.length) {
864
1174
  printNoResults(format, io);
865
1175
  return 0;
@@ -871,12 +1181,13 @@ async function selectInProject(config, patterns, values, format, io) {
871
1181
  return 2;
872
1182
  }
873
1183
  let skipped = 0;
874
- if (!config.includeDesignSystem) {
875
- const own = new Set((await componentFiles(config)).map(realPath));
1184
+ const own = await ownFiles();
1185
+ if (own.size) {
876
1186
  const rest = files.filter((file) => !own.has(realPath(file)));
877
1187
  skipped = files.length - rest.length;
878
1188
  files = rest;
879
1189
  }
1190
+ const jsx = await jsxIn([...named, ...all], own);
880
1191
  return {
881
1192
  files,
882
1193
  designSystemOf: () => ds,
@@ -884,7 +1195,10 @@ async function selectInProject(config, patterns, values, format, io) {
884
1195
  notes: scopeNotes(skipped, left),
885
1196
  notices: [...notice ? [notice] : [], ...notReadNotices([{ ds }])],
886
1197
  skipped,
887
- testsLeftOut: left.tests
1198
+ testsLeftOut: left.tests,
1199
+ jsx,
1200
+ jsxGlobs: scripts,
1201
+ searched: globs.length > 0
888
1202
  };
889
1203
  }
890
1204
  /**
@@ -893,13 +1207,15 @@ async function selectInProject(config, patterns, values, format, io) {
893
1207
  * config, exactly as `check` from that folder (and the Claude Code hook) would check it.
894
1208
  */
895
1209
  async function selectInWorkspace(workspace, patterns, values, format, io) {
896
- const { named, globs } = expandPatterns(patterns, io.cwd);
897
- const all = globs.length ? await findFiles(globs, {
1210
+ const { named: given, globs, scripts } = expandPatterns(patterns, io.cwd);
1211
+ const globbed = globs.length ? await findFiles(globs, {
898
1212
  cwd: io.cwd,
899
1213
  absolute: true,
900
1214
  expandDirectories: false,
901
1215
  ignore: ["**/node_modules/**"]
902
1216
  }) : [];
1217
+ const named = given.filter((file) => SCRIPT_FILE.test(file));
1218
+ const all = globbed.filter((file) => SCRIPT_FILE.test(file));
903
1219
  const projectOf = /* @__PURE__ */ new Map();
904
1220
  for (const file of unique([...named, ...all])) projectOf.set(file, await workspace.projectFor(file));
905
1221
  const project = (file) => {
@@ -919,24 +1235,42 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
919
1235
  }
920
1236
  return ds;
921
1237
  };
922
- const leftOut = all.filter((file) => {
1238
+ const excluded = (file) => {
923
1239
  const { root, config } = project(file);
924
1240
  const relative = relativePath(root, file);
925
- return config.exclude.some((e) => !(values["include-tests"] && isTestPattern(e)) && matchesGlob(relative, e));
926
- });
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);
927
1245
  const leftOutSet = new Set(leftOut);
928
1246
  const kept = all.filter((file) => !leftOutSet.has(file));
929
1247
  const ignored = gitIgnored(io.cwd, kept);
930
1248
  const left = {
931
1249
  tests: leftOut.filter((file) => TEST_FILE.test(file)).length,
932
1250
  excluded: leftOut.filter((file) => !TEST_FILE.test(file)).length,
933
- 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
934
1273
  };
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
1274
  const loaded = /* @__PURE__ */ new Map();
941
1275
  for (const p of unique(files.map(project))) loaded.set(p.root, await load(p));
942
1276
  const designSystemOf = (file) => {
@@ -963,14 +1297,11 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
963
1297
  return 2;
964
1298
  }
965
1299
  }
966
- const own = /* @__PURE__ */ new Map();
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
- }
1300
+ for (const p of unique(files.map(project))) await ownFiles(p);
971
1301
  const rest = files.filter((file) => !own.get(project(file).root)?.has(realPath(file)));
972
1302
  const skipped = files.length - rest.length;
973
1303
  files = rest;
1304
+ const jsx = await jsxIn([...named, ...all]);
974
1305
  const gaps = /* @__PURE__ */ new Map();
975
1306
  for (const p of unique(files.map(project))) {
976
1307
  const ds = loaded.get(p.root);
@@ -1000,7 +1331,10 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
1000
1331
  notes: [...used > 1 ? [plural(used, "project")] : [], ...scopeNotes(skipped, left)],
1001
1332
  notices: notices.filter(Boolean),
1002
1333
  skipped,
1003
- testsLeftOut: left.tests
1334
+ testsLeftOut: left.tests,
1335
+ jsx,
1336
+ jsxGlobs: scripts,
1337
+ searched: globs.length > 0
1004
1338
  };
1005
1339
  }
1006
1340
  /** `apps/web`, `apps/web and packages/ui`, `a, b, c and 4 more`. */
@@ -1226,9 +1560,12 @@ function defaultBaseline(root, warn) {
1226
1560
  }
1227
1561
  /** Tests and stories, which `check` leaves out of folders and globs unless asked. */
1228
1562
  const TEST_FILE = /\.(?:test|spec|stories)\.[cm]?[jt]sx?$|[\\/]__(?:tests|stories|mocks)__[\\/]/;
1229
- /** The default patterns `--include-tests` lifts. */
1230
- function isTestPattern(pattern) {
1231
- return pattern === "**/*.{test,spec,stories}.{ts,tsx,js,jsx}" || pattern === "**/__{tests,stories,mocks}__/**";
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;
1232
1569
  }
1233
1570
  /** What a run left out, for the summary line: `5 design-system files skipped`. */
1234
1571
  function scopeNotes(skipped, left) {
@@ -1236,22 +1573,11 @@ function scopeNotes(skipped, left) {
1236
1573
  skipped ? `${plural(skipped, "design-system file")} skipped` : "",
1237
1574
  left.tests ? `${left.tests.toLocaleString("en-US")} ${left.tests === 1 ? "test or story" : "tests and stories"} left out` : "",
1238
1575
  left.excluded ? `${plural(left.excluded, "excluded file")} left out` : "",
1239
- 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` : ""
1240
1578
  ].filter(Boolean);
1241
1579
  }
1242
1580
  /**
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
1581
  * Files a glob matches, as Node spells absolute paths (with backslashes on Windows, where
1256
1582
  * tinyglobby gives forward slashes), so they compare equal to `path.resolve` and git's.
1257
1583
  */
@@ -1302,6 +1628,7 @@ function checkCommand(patterns, configOptions, values, flag) {
1302
1628
  option("baseline", values.baseline);
1303
1629
  if (configOptions.includeDesignSystem !== void 0) args.push(`--${configOptions.includeDesignSystem ? "" : "no-"}include-design-system`);
1304
1630
  if (values["include-tests"]) args.push("--include-tests");
1631
+ if (values["skip-excluded"]) args.push("--skip-excluded");
1305
1632
  return `check ${args.map(shellQuote).join(" ")} ${flag}`;
1306
1633
  }
1307
1634
  /**