onsystem 0.9.0 → 0.10.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 CHANGED
@@ -30,6 +30,8 @@ Using the GitHub Action? `uses: dgesteves/onsystem@v0` checks the lines a pull r
30
30
 
31
31
  **Website and docs:** [onsystem.vercel.app](https://onsystem.vercel.app), with a [playground](https://onsystem.vercel.app/playground) that checks your own code.
32
32
 
33
+ **Feedback wanted:** say where it breaks in the [launch feedback thread](https://github.com/dgesteves/onsystem/discussions/227), see what's next in the [roadmap](https://github.com/dgesteves/onsystem/blob/main/ROADMAP.md), or pick up a [good first issue](https://github.com/dgesteves/onsystem/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22). If onsystem is useful to you, a star helps other people find it.
34
+
33
35
  <p align="center">
34
36
  <a href="https://onsystem.vercel.app"><img src=".github/assets/demo.webp" width="100%" alt="On the website, an agent drafts a settings card, check_ui finds 11 problems with a fix each and the agent applies them. Then a real Claude Code session with the plugin, where Claude looks the components up before writing and check_ui passes. Last, the benchmark."></a>
35
37
  </p>
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-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";
2
+ import { $ as DEFAULT_TOKENS, A as unrecordedRules, At as isInside, B as statusKinds, C as countBaseline, D as pruneBaseline, E as onLines, Et as plural, Ft as readProjectConfig, J as ConfigError, L as toDesignMd, M as writeBaseline, Mt as relativePath, Nt as toPosix, O as readBaseline, Ot as unique, R as toDtcg, S as applyBaseline, T as goneFiles, 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, jt as matchesGlob, k as rulesRun, kt as SCRIPT_FILE, 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-D53Jxybo.js";
3
+ import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-ha4IF0CU.js";
4
+ import { r as loadTarget } from "./workspace-DAa_08uB.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
  "",
@@ -1060,7 +1150,9 @@ async function check(patterns, configOptions, values, io) {
1060
1150
  const warnings = results.reduce((n, r) => n + r.warningCount, 0);
1061
1151
  if (errors > 0) return 1;
1062
1152
  if (maxWarnings !== void 0 && warnings > maxWarnings) {
1063
- if (format === "pretty") io.stderr(`Too many warnings (${warnings}, max ${maxWarnings}).`);
1153
+ const message = `Too many warnings (${warnings}, max ${maxWarnings}).`;
1154
+ if (format === "github") io.stdout(`::error title=${NAME}::${message}`);
1155
+ else io.stderr(message);
1064
1156
  return 1;
1065
1157
  }
1066
1158
  return 0;
@@ -1425,7 +1517,7 @@ async function inspect(configOptions, values, io) {
1425
1517
  }
1426
1518
  const lines = [
1427
1519
  `root ${model.root}`,
1428
- `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : "(defaults)"}`,
1520
+ `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config, (f) => displayPath(io.cwd, f))}` : "(defaults)"}`,
1429
1521
  ...config.detected ? [`detected ${config.detected}`] : [],
1430
1522
  ...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`),
1431
1523
  `files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`,
@@ -1559,7 +1651,7 @@ async function inspectWorkspace(workspace, values, io) {
1559
1651
  const { model } = ds;
1560
1652
  const roots = ds.roots();
1561
1653
  const tokens = [...tokenCounts(model)].map(([category, n]) => `${category} ${n}`);
1562
- const own = project.config.configFile && project.config.configFile !== workspace.configFile ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}` : void 0;
1654
+ 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;
1563
1655
  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] ?? ""}`] : []);
1564
1656
  }
1565
1657
  if (!loaded.length) lines.push("", `No workspace package has a design system that zero config finds. See ${DOCS}/troubleshooting`);
@@ -1587,9 +1679,14 @@ function explainWorkspace(workspace, loaded, cwd) {
1587
1679
  for (const { project, ds } of loaded) lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...explainProject(ds, cwd, " "));
1588
1680
  return lines;
1589
1681
  }
1590
- /** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */
1591
- function declaredBy(config) {
1592
- return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : "";
1682
+ /**
1683
+ * ` (declared by @acme/ui)` after a config a design-system package declares, `, over
1684
+ * packages/ui/onsystem.config.json (declared by @acme/ui)` after one read over it; empty otherwise.
1685
+ */
1686
+ function declaredBy(config, display) {
1687
+ const declared = config.designSystemPackage;
1688
+ if (!declared) return "";
1689
+ return `${config.configFile === declared.config ? "" : `, over ${display(declared.config)}`} (declared by ${declared.name})`;
1593
1690
  }
1594
1691
  /** `A, B, C` up to a dozen names, then `and 40 more`. */
1595
1692
  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
  /**