onsystem 0.9.1 → 0.10.1

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 CHANGED
@@ -69,7 +69,7 @@ npx -y onsystem check . # the same check the agent and CI run
69
69
  npx -y onsystem init # write the config, and offer a baseline
70
70
  ```
71
71
 
72
- At a [monorepo root](https://onsystem.vercel.app/docs/configuration#monorepo-roots), `inspect` lists every app and design-system package, and `check` checks each file by its own project's design system. In a [design-system package](https://onsystem.vercel.app/docs/design-system-packages), `init` sets it up for every app that depends on it, with no config in the apps: as a workspace package, or installed from npm when it publishes its component sources (`init` checks its `files` list). From a package that publishes only compiled code, installed apps get the token rules but no component, prop, variant or native-element checks.
72
+ At a [monorepo root](https://onsystem.vercel.app/docs/configuration#monorepo-roots), `inspect` lists every app and design-system package, and `check` checks each file by its own project's design system. In a [design-system package](https://onsystem.vercel.app/docs/design-system-packages), `init` sets it up for every app that depends on it, with no config in the apps: as a workspace package, or installed from npm. Installed apps read the components from the sources the package publishes, or from its type declarations (`.d.ts`) when it publishes only compiled `dist/`, and get every rule either way (`init` checks its `files` list). From a package that publishes compiled code and no type declarations, they get the token rules but no component, prop, variant or native-element checks.
73
73
 
74
74
  In a new create-next-app, the template page's hardcoded colors already make the first `check .` fail; `init` offers to record them as a baseline, so only new findings fail. Folders installed from a shadcn registry, such as `components/ai-elements`, are someone else's code: [Code from shadcn registries](https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries) says how to check them or leave them out.
75
75
 
@@ -128,6 +128,21 @@ steps:
128
128
 
129
129
  That count is onsystem's own, and the MCP server and the hook show the agent the same errors to fix. In tasks that ask for a green status label in a design system with no success token, agents with onsystem often used `chart-*` tokens, which it counts as clean. The blind labels by an AI reviewer, 12 files per arm, are too few to confirm the count either way, and every violation they found with the hook or all three is a palette color in Dub, whose tokens 0.5.0 did not read. The [benchmark page](https://onsystem.vercel.app/benchmark) has every table, and [bench/v2](bench/v2) the method, the per-run data and every generated file.
130
130
 
131
+ ## Compatibility
132
+
133
+ What `package.json` declares and CI runs:
134
+
135
+ | With | Supported |
136
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
137
+ | Node.js | Node.js 20.19 or later (`engines`). CI runs the tests on Node.js 22, 24 and 26, and the built package on 20.19 |
138
+ | Operating system | Linux, macOS and Windows: CI runs the tests and the pack test on each |
139
+ | ESLint | ESLint 8.57, 9 and 10, for the ESLint plugin only: `eslint` is an optional peer, and the CLI, the MCP server, the hook and the Action do not need it |
140
+ | TypeScript | Your project's TypeScript is not used, and a JavaScript project needs none: onsystem reads `.tsx` and `.jsx` with its own TypeScript 6.0, a dependency, not a peer |
141
+ | React | Components written for React 18 (`forwardRef`) or React 19 (`ref` as a prop), read from source or type declarations. onsystem does not depend on React |
142
+ | Tailwind CSS | v4 (`@theme`) and v3 (`tailwind.config`, read without running it). Without Tailwind, tokens come from CSS custom properties, DTCG files or a DESIGN.md |
143
+ | shadcn/ui | Both bases, Radix (`asChild`) and Base UI (`render`), on Tailwind v3 or v4, with no config |
144
+ | MCP clients | Any client that starts a stdio server; CI connects on the 2025-11-25 and 2026-07-28 protocols. [Set up your agent](https://onsystem.vercel.app/docs/clients) has each client's config and support tier |
145
+
131
146
  ## Docs
132
147
 
133
148
  On the [website](https://onsystem.vercel.app/docs), and as Markdown in [docs/](docs):
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
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-DZm2aP1z.js";
3
- import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-BJvjEnKW.js";
4
- import { r as loadTarget } from "./workspace-Dqu3nkYM.js";
2
+ import { $ as DEFAULT_TOKENS, A as unrecordedRules, At as toPosix, B as statusKinds, C as countBaseline, D as pruneBaseline, Dt as matchesGlob, E as onLines, Et as isInside, Ft as unique, J as ConfigError, L as toDesignMd, Lt as readProjectConfig, M as writeBaseline, Nt as plural, O as readBaseline, R as toDtcg, S as applyBaseline, T as goneFiles, Tt as SCRIPT_FILE, Y as DEFAULT_DOCS, _ as formatDiagnostics, at as excludedBy, b as BASELINE_FILE, c as silentLogger, et as FALLBACK_TOKENS, g as escapeData, gt as PACKAGE_FIELD, h as uncheckedNotice, ht as notReadYet, i as componentFiles, j as updateBaseline, k as rulesRun, kt as relativePath, l as stderrLogger, lt as rulesFor, mt as libraryTitle, nt as RULE_IDS, o as loadDesignSystem, q as CONFIG_FILES, s as tokenFiles, tt as LEGACY_CONFIG_FILES, v as NAME, vt as declaredConfig, w as findMoves, x as LEGACY_BASELINE_FILE, xt as isDesignSystemName, y as VERSION, yt as detectProject } from "./design-system-CPH4C7-1.js";
3
+ import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-BK2kMN1x.js";
4
+ import { r as loadTarget } from "./workspace-BINqxi7p.js";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { escapePath, glob } from "tinyglobby";
@@ -26,7 +26,8 @@ const GUIDE_FILE = "ONSYSTEM.md";
26
26
  * (`"onsystem": "./onsystem.config.json"`, and in `files` when the package lists them), so
27
27
  * every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those
28
28
  * apps' teams how to add the agent tools and the CI check. In an app that takes on such a
29
- * package's config, it writes no config of its own unless --force says to.
29
+ * package's config, it writes no config of its own unless --force says to, and then one that
30
+ * builds on the package's, for the app's own rules and overrides.
30
31
  *
31
32
  * In a terminal, it offers to add a short section on onsystem to the project's AGENTS.md or
32
33
  * CLAUDE.md, for coding agents.
@@ -52,20 +53,28 @@ async function init(configOptions, options, io, runCheck) {
52
53
  const notes = [];
53
54
  const relative = (abs) => relativePath(root, abs);
54
55
  const inherited = options.force ? void 0 : config.designSystemPackage;
55
- const replaced = options.force ? config.designSystemPackage : void 0;
56
+ const declaring = options.force && !configOptions.components?.length ? config.designSystemPackage : void 0;
57
+ const replaced = declaring && (config.inherits || !await namesOwnComponents(config, declaring)) ? declaring : void 0;
56
58
  let found = false;
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.`);
59
+ 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 for its own rules and overrides, which builds on ${inherited.name}'s.`);
58
60
  else if (configOptions.components?.length) {
59
61
  file.components = configOptions.components;
60
62
  if (configOptions.tokens?.length) file.tokens = configOptions.tokens;
61
63
  if (configOptions.docs?.length) file.docs = configOptions.docs;
62
- } else if (workspace) notes.push(`This is a monorepo root: ${workspace.projects.length} of ${workspace.projects.length + workspace.others.length} workspace packages have a design system (${workspace.projects.map((p) => p.dir).join(", ") || "none"}). Each keeps zero config; rules and overrides you add here apply to every project without a config of its own.`);
64
+ } else if (replaced) {
65
+ const tokens = existing ? ownTokens(existing, root, replaced) : void 0;
66
+ if (tokens) file.tokens = tokens;
67
+ } else if (workspace) notes.push(`This is a monorepo root: ${workspace.projects.length} of ${workspace.projects.length + workspace.others.length} workspace packages have a design system (${workspace.projects.map((p) => p.dir).join(", ") || "none"}). Each keeps zero config; rules and overrides you add here apply to every project without a config of its own when it is checked from this folder, over the config its design-system package declares.`);
63
68
  else if (config.detected) {
64
69
  notes.push(`Found the design system through ${config.detected}.`);
65
70
  const specifiers = new Set((config.imports ?? []).map((m) => m.specifier));
66
71
  const tokens = (await tokenFiles(config)).map(relative);
67
- if (specifiers.size > 1) notes.push(`"components" is left to zero config: apps import them by ${specifiers.size} specifiers from the package's exports (${[...specifiers].slice(0, 2).join(", ")}, …), which a config cannot spell.`);
72
+ const fromInstalled = installedFields(config.components, tokens);
73
+ if (fromInstalled.length) notes.push(installedNote(fromInstalled, tokens));
74
+ const changed = fromInstalled.includes("tokens") && !fromInstalled.includes("components") ? await tokensWithComponents(config, tokens) : void 0;
75
+ if (fromInstalled.includes("components")) {} else if (specifiers.size > 1) notes.push(`"components" is left to zero config: apps import them by ${specifiers.size} specifiers from the package's exports (${[...specifiers].slice(0, 2).join(", ")}, …), which a config cannot spell.`);
68
76
  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.`);
77
+ else if (changed) notes.push(`"components" is left to zero config as well: a config that names them reads the tokens only from the usual stylesheets (${DEFAULT_TOKENS[0]} and the like)${changed.lost.length ? `, which leave out ${andList(changed.lost)}` : ", not the ones zero config reads"}.`);
69
78
  else {
70
79
  file.components = config.components;
71
80
  found = true;
@@ -74,20 +83,20 @@ async function init(configOptions, options, io, runCheck) {
74
83
  const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d));
75
84
  if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs];
76
85
  }
77
- if (tokens.length) file.tokens = tokens;
86
+ if (tokens.length && !fromInstalled.includes("tokens")) file.tokens = tokens;
78
87
  } else if ((await componentFiles(config)).length) {
79
- const declared = replaced && config.configured;
80
88
  const matching = [];
81
89
  for (const glob of config.components) if ((await componentFiles({
82
90
  ...config,
83
91
  components: [glob]
84
92
  })).length) matching.push(glob);
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(", ")}.`);
93
+ notes.push(`Found the design system in the default folders: ${matching.join(", ")}.`);
86
94
  file.components = matching;
87
95
  found = true;
88
- if (declared && config.importPath) file.importPath = config.importPath;
89
96
  const tokens = (await tokenFiles(config)).map(relative);
90
- if (tokens.length) file.tokens = tokens;
97
+ const fromInstalled = installedFields([], tokens);
98
+ if (fromInstalled.length) notes.push(installedNote(fromInstalled, tokens));
99
+ else if (tokens.length) file.tokens = tokens;
91
100
  } else {
92
101
  if (!ask) {
93
102
  io.stderr(`init: zero config found no design system in ${root}. Pass --components (and --tokens) to say where it is, or run init in a terminal to answer a few questions. \`${NAME} inspect --explain\` shows what it looked at.`);
@@ -124,13 +133,7 @@ async function init(configOptions, options, io, runCheck) {
124
133
  fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
125
134
  written.push(CONFIG_FILE);
126
135
  }
127
- if (replaced) {
128
- const own = (await loadTarget({
129
- ...configOptions,
130
- config: void 0
131
- })).config;
132
- notes.push(await replacedNote(replaced, config, own, io.cwd));
133
- }
136
+ if (replaced) notes.push(replacedNote(replaced, io.cwd, file.tokens !== void 0));
134
137
  let registry;
135
138
  if (designSystem && manifest && name) {
136
139
  written.push(...declarePackage(root, manifest, name, options.force, notes));
@@ -341,7 +344,7 @@ function declarePackage(root, manifest, name, force, notes) {
341
344
  fs.writeFileSync(manifest.file, `${JSON.stringify(json, null, manifest.indent)}${manifest.newline ? "\n" : ""}`);
342
345
  written.push("package.json");
343
346
  }
344
- notes.push(`This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ", also added to \"files\"" : ""}), which every app that depends on ${name} reads when it has no config of its own.`);
347
+ notes.push(`This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ", also added to \"files\"" : ""}), which every app that depends on ${name} reads, under its own config when it has one.`);
345
348
  const guide = path.join(root, GUIDE_FILE);
346
349
  if (force || !fs.existsSync(guide)) {
347
350
  fs.writeFileSync(guide, consumerGuide(name));
@@ -354,7 +357,8 @@ function declarePackage(root, manifest, name, force, notes) {
354
357
  * files its `files` list leaves out of the published package, and those outside the package
355
358
  * (`../tailwind-config/theme.css`), which an installed package's config cannot reach. Undefined
356
359
  * for a private package, or when they find every file. Without a `files` list, npm publishes
357
- * everything in the package.
360
+ * everything in the package. Without any component file, those apps read the components from
361
+ * the type declarations the package publishes, when it names some.
358
362
  */
359
363
  async function unshipped(config, json) {
360
364
  if (json.private === true) return void 0;
@@ -374,13 +378,14 @@ async function unshipped(config, json) {
374
378
  tokens: tokens.filter(outside)
375
379
  };
376
380
  const noComponents = components.length > 0 && missing.components.length + away.components.length === components.length;
381
+ const declarations = noComponents ? publishedDeclarations(config.root, json, files) : [];
377
382
  const lost = (lists, one) => {
378
383
  const c = lists.components.length;
379
384
  const t = lists.tokens.length;
380
385
  const kinds = andList([...c ? [count$1(c, "component file")] : [], ...t ? [count$1(t, "token file")] : []]);
381
- const what = andList([...c ? ["components"] : [], ...t ? ["tokens"] : []], "or");
382
- const checks = c && noComponents ? `: they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` : "";
383
- return `(${kinds}). Those apps do not know the ${what} in ${one ? "it" : "them"}${checks}.`;
386
+ const them = one ? "it" : "them";
387
+ if (c && noComponents && declarations.length) return `(${kinds}). Those apps read the components from the type declarations it publishes instead (${andList(declarations)})${t ? `, and do not know the tokens in ${them}` : ""}.`;
388
+ return `(${kinds}). Those apps do not know the ${andList([...c ? ["components"] : [], ...t ? ["tokens"] : []], "or")} in ${them}${c && noComponents ? `: with no type declarations published either, they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` : ""}.`;
384
389
  };
385
390
  const notes = [];
386
391
  const top = (f) => f.includes("/") ? `${f.slice(0, f.indexOf("/"))}/` : f;
@@ -397,36 +402,121 @@ async function unshipped(config, json) {
397
402
  if (!notes.length) return void 0;
398
403
  return {
399
404
  notes,
400
- next: noComponents && missing.components.length ? `gets no component checks until "files" publishes ${andList([...new Set(missing.components.map(top))])}` : `does not read ${andList([...folders, ...beyond])}`
405
+ next: noComponents && missing.components.length ? declarations.length ? `reads its components from the type declarations it publishes, not from ${andList([...new Set(missing.components.map(top))])}` : `gets no component checks until "files" publishes ${andList([...new Set(missing.components.map(top))])}` : `does not read ${andList([...folders, ...beyond])}`
401
406
  };
402
407
  }
403
408
  /**
404
- * Said when --force writes a config in an app that took on a design-system package's: the new
405
- * one is read instead, and what it does not take from the package. `taken` is the config the
406
- * app took on, `own` the one it reads now.
409
+ * The type declarations a package's package.json names, as apps that install it look for them:
410
+ * `types` or `typings`, a `types` condition in its `exports`, or an `index.d.ts` beside it, each
411
+ * that its `files` list publishes. Relative to the package.
407
412
  */
408
- async function replacedNote(pkg, taken, own, cwd) {
409
- const lacks = async (list) => {
410
- const kept = new Set((await list(own)).map((f) => path.resolve(f)));
411
- return (await list(taken)).some((f) => !kept.has(path.resolve(f)));
413
+ function publishedDeclarations(root, json, files) {
414
+ const named = [];
415
+ for (const field of ["types", "typings"]) {
416
+ const value = json[field];
417
+ if (typeof value === "string") named.push(value);
418
+ }
419
+ const conditions = (value, key) => {
420
+ if (typeof value === "string") {
421
+ if (key === "types") named.push(value);
422
+ } else if (Array.isArray(value)) for (const item of value) conditions(item);
423
+ else if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) conditions(v, k);
412
424
  };
413
- const components = await lacks(componentFiles);
414
- const importPath = taken.importPath !== void 0 && own.importPath === void 0;
415
- const tokens = await lacks(tokenFiles);
425
+ conditions(json.exports);
426
+ if (fs.existsSync(path.join(root, "index.d.ts"))) named.push("index.d.ts");
427
+ return [...new Set(named.map((f) => f.replace(/^\.\//, "")))].filter((f) => !files || ships(files, f));
428
+ }
429
+ /**
430
+ * Said when --force writes a config in an app on a design-system package that declares one: the
431
+ * new config builds on the package's, which still gives it what it leaves out.
432
+ */
433
+ function replacedNote(pkg, cwd, keptTokens) {
416
434
  const declared = relativePath(cwd, pkg.config) || pkg.config;
417
- const replaced = `This project took on the config ${pkg.name} declares (${declared}); with --force it has one of its own, which is read instead, so ${pkg.name}'s rules and later changes to its config no longer reach it.`;
418
- if (!components && !importPath && !tokens) return replaced;
419
- const effects = andList([
420
- ...components ? [`does not know ${pkg.name}'s components`] : [],
421
- ...importPath && !components ? [`does not match imports from ${pkg.name} to its components`] : [],
422
- ...tokens ? [`does not read ${pkg.name}'s tokens`] : []
423
- ]);
424
- const fields = [
425
- ...components ? ["\"components\""] : [],
426
- ...importPath || components && taken.importPath !== void 0 ? ["\"importPath\""] : [],
427
- ...tokens ? ["\"tokens\""] : []
428
- ];
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.`;
435
+ const fields = keptTokens ? "\"components\" and \"importPath\"" : "\"components\", \"importPath\" and \"tokens\"";
436
+ const kept = keptTokens ? " It keeps the \"tokens\" the old one listed, which name a stylesheet of this project's own." : "";
437
+ return `This project reads the config ${pkg.name} declares (${declared}). With --force it has one of its own, ${CONFIG_FILE}, read over that one: what it leaves out, such as ${fields}, still comes from ${pkg.name}'s config, and the rules and overrides you add to it apply over ${pkg.name}'s rules.${kept} "extends": false in it reads it alone.`;
438
+ }
439
+ /**
440
+ * The `tokens` an existing JSON config lists, when one of them is outside the design-system
441
+ * package, links resolved: an app that takes the package's components and reads its own
442
+ * stylesheet too lists both. A list of the package's files only is left to the package's config.
443
+ */
444
+ function ownTokens(file, root, pkg) {
445
+ let tokens;
446
+ try {
447
+ tokens = JSON.parse(fs.readFileSync(file, "utf8")).tokens;
448
+ } catch {
449
+ return;
450
+ }
451
+ if (!Array.isArray(tokens) || !tokens.every((t) => typeof t === "string")) return;
452
+ const real = (abs) => {
453
+ try {
454
+ return fs.realpathSync(abs);
455
+ } catch {
456
+ return abs;
457
+ }
458
+ };
459
+ const dir = real(pkg.dir);
460
+ return tokens.some((t) => !isInside(dir, real(path.resolve(root, t)))) ? tokens : void 0;
461
+ }
462
+ /**
463
+ * Whether the files a config's `components` match include some outside the design-system
464
+ * package, links resolved: a config that names only the package's (copied from its config, as
465
+ * `node_modules/@acme/ui/src/*.tsx`), or none, has no components of its own.
466
+ */
467
+ async function namesOwnComponents(config, pkg) {
468
+ const real = (file) => {
469
+ try {
470
+ return fs.realpathSync(file);
471
+ } catch {
472
+ return path.resolve(file);
473
+ }
474
+ };
475
+ const dir = real(pkg.dir);
476
+ return (await componentFiles(config)).some((file) => !isInside(dir, real(file)));
477
+ }
478
+ /** Whether a path, relative to the root, is in a package installed in `node_modules`. */
479
+ const isInstalledPath = (file) => /(?:^|\/)node_modules\//.test(file);
480
+ /**
481
+ * The fields `init` leaves to zero config because what zero config found for them is in a
482
+ * package installed in `node_modules`: `components` (their globs) and `tokens` (files).
483
+ */
484
+ function installedFields(components, tokens) {
485
+ return [...components.some(isInstalledPath) ? ["components"] : [], ...tokens.some(isInstalledPath) ? ["tokens"] : []];
486
+ }
487
+ /**
488
+ * Why `fields` are left to zero config: a path into an installed package breaks when it is
489
+ * upgraded, since pnpm keeps each version in a folder of its own
490
+ * (`node_modules/.pnpm/@acme+ui@1.2.0_react@19.2.0/node_modules/@acme/ui`), and a list of its
491
+ * files misses the ones a new version adds. Zero config finds it again each time.
492
+ */
493
+ function installedNote(fields, tokens) {
494
+ const one = fields.length === 1;
495
+ const what = fields.includes("tokens") && !tokens.every(isInstalledPath) ? `${fields.includes("components") ? "the components and " : ""}some of the token files come` : one ? "it comes" : "they come";
496
+ return `${andList(fields.map((f) => `"${f}"`))} ${one ? "is" : "are"} left to zero config: ${what} from a package installed in node_modules, which zero config finds again after each upgrade, while a path written here would break once the package's folder or files change (pnpm keeps each version in a folder of its own in node_modules/.pnpm).`;
497
+ }
498
+ /**
499
+ * What changes when a config names components and leaves `tokens` (relative to the root, as
500
+ * zero config finds them) to zero config: such a config turns detection off, so the stylesheets
501
+ * it found (components.json's `app/styles/tailwind.css`, and what they import from an installed
502
+ * package) give way to the usual ones ({@link DEFAULT_TOKENS}), with what those import. Undefined
503
+ * when that reads the same token files; else `lost` holds the ones it leaves out, the app's own
504
+ * first.
505
+ */
506
+ async function tokensWithComponents(config, tokens) {
507
+ const usual = await tokenFiles({
508
+ ...config,
509
+ tokens: [...DEFAULT_TOKENS.map((p) => ({ path: p })), ...FALLBACK_TOKENS.map((p) => ({
510
+ path: p,
511
+ fallback: true
512
+ }))],
513
+ tokensDetected: true
514
+ });
515
+ const read = new Set(usual.map((f) => relativePath(config.root, f)));
516
+ if (read.size === tokens.length && tokens.every((t) => read.has(t))) return void 0;
517
+ const lost = tokens.filter((t) => !read.has(t));
518
+ const own = lost.filter((t) => !isInstalledPath(t));
519
+ return { lost: own.length ? own : lost };
430
520
  }
431
521
  const REGISTRY_DOCS = "https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries";
432
522
  /**
@@ -555,7 +645,7 @@ function consumerGuide(name) {
555
645
  `npx -y ${NAME} inspect`,
556
646
  "```",
557
647
  "",
558
- `An app with an ${CONFIG_FILE} of its own reads that instead.`,
648
+ `An app with an ${CONFIG_FILE} of its own reads it over ${name}'s config: the rules and overrides it sets apply over ${name}'s rules, and, unless it sets components of its own, what it leaves out, such as the components, \`importPath\` and tokens, comes from ${name}'s. \`"extends": false\` in it reads it alone.`,
559
649
  "",
560
650
  "## Coding agents",
561
651
  "",
@@ -854,7 +944,8 @@ async function main(argv, io = defaultIo) {
854
944
  io.stderr(error.message);
855
945
  return 2;
856
946
  }
857
- throw error;
947
+ io.stderr(error.stack ?? String(error));
948
+ return 2;
858
949
  }
859
950
  }
860
951
  async function check(patterns, configOptions, values, io) {
@@ -1427,7 +1518,7 @@ async function inspect(configOptions, values, io) {
1427
1518
  }
1428
1519
  const lines = [
1429
1520
  `root ${model.root}`,
1430
- `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : "(defaults)"}`,
1521
+ `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config, (f) => displayPath(io.cwd, f))}` : "(defaults)"}`,
1431
1522
  ...config.detected ? [`detected ${config.detected}`] : [],
1432
1523
  ...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`),
1433
1524
  `files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`,
@@ -1561,7 +1652,7 @@ async function inspectWorkspace(workspace, values, io) {
1561
1652
  const { model } = ds;
1562
1653
  const roots = ds.roots();
1563
1654
  const tokens = [...tokenCounts(model)].map(([category, n]) => `${category} ${n}`);
1564
- const own = project.config.configFile && project.config.configFile !== workspace.configFile ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}` : void 0;
1655
+ const own = project.config.configFile && (project.config.configFile !== workspace.configFile || project.config.designSystemPackage) ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config, (f) => displayPath(workspace.root, f))}` : void 0;
1565
1656
  lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...own ? [` config ${own}`] : [], ...project.config.detected ? [` detected ${project.config.detected}`] : [], ...describeLibraries(project.config, model, io.cwd).map((line) => ` library ${line}`), ` files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`, ` components ${roots.length} + ${ds.components.length - roots.length} parts${roots.length ? `: ${listNames(roots.map((c) => c.name))}` : ""}`, ` tokens ${tokens.length ? tokens.join(", ") : "none"}`, ...model.warnings.length ? [` warnings ${model.warnings.length}: ${model.warnings[0] ?? ""}`] : []);
1566
1657
  }
1567
1658
  if (!loaded.length) lines.push("", `No workspace package has a design system that zero config finds. See ${DOCS}/troubleshooting`);
@@ -1589,9 +1680,14 @@ function explainWorkspace(workspace, loaded, cwd) {
1589
1680
  for (const { project, ds } of loaded) lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...explainProject(ds, cwd, " "));
1590
1681
  return lines;
1591
1682
  }
1592
- /** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */
1593
- function declaredBy(config) {
1594
- return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : "";
1683
+ /**
1684
+ * ` (declared by @acme/ui)` after a config a design-system package declares, `, over
1685
+ * packages/ui/onsystem.config.json (declared by @acme/ui)` after one read over it; empty otherwise.
1686
+ */
1687
+ function declaredBy(config, display) {
1688
+ const declared = config.designSystemPackage;
1689
+ if (!declared) return "";
1690
+ return `${config.configFile === declared.config ? "" : `, over ${display(declared.config)}`} (declared by ${declared.name})`;
1595
1691
  }
1596
1692
  /** `A, B, C` up to a dozen names, then `and 40 more`. */
1597
1693
  function listNames(names, max = 12) {
@@ -303,6 +303,7 @@ declare const ruleOptionsSchema: z.ZodObject<{
303
303
  type RuleOptions = z.infer<typeof ruleOptionsSchema>;
304
304
  declare const configSchema: z.ZodObject<{
305
305
  $schema: z.ZodOptional<z.ZodString>;
306
+ extends: z.ZodOptional<z.ZodBoolean>;
306
307
  components: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
307
308
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
308
309
  tokens: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
@@ -450,12 +451,21 @@ interface ResolvedConfig {
450
451
  */
451
452
  base?: string | undefined;
452
453
  /**
453
- * The design-system package whose config this is: a dependency whose package.json declares it
454
- * (`"onsystem": "./onsystem.config.json"`), taken on by a project with no config of its own.
454
+ * The design-system package whose config this project reads: a dependency whose package.json
455
+ * declares it (`"onsystem": "./onsystem.config.json"`). A project with no config of its own
456
+ * takes it on (`configFile` is the package's); one with a config of its own, or a workspace
457
+ * root's, reads it under that one (`configFile` is that one), unless it sets `"extends": false`.
455
458
  * Its paths are rebased from the package; its `tsconfig`, `overrides` and
456
459
  * `includeDesignSystem` stay with it.
457
460
  */
458
461
  designSystemPackage?: DesignSystemPackage | undefined;
462
+ /**
463
+ * The project's config sets no `components`, so what it leaves out of `components`,
464
+ * `importPath`, `docs`, `elements`, `tokens` and `libraries` comes from `designSystemPackage`'s.
465
+ */
466
+ inherits?: boolean | undefined;
467
+ /** The project's config sets `"extends": false`, so no design-system package's config is read. */
468
+ extendsOff?: boolean | undefined;
459
469
  /** `components` comes from the config file or `--components`, not from detection or the defaults. */
460
470
  configured?: boolean | undefined;
461
471
  /**