onsystem 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { h as DesignSystemModel, i as ResolvedConfig } from "./config-lbfW91qB.js";
1
+ import { h as DesignSystemModel, i as ResolvedConfig } from "./config-kYGiD5oW.js";
2
2
  import { MessagePort } from "node:worker_threads";
3
3
  //#region src/eslint/worker.d.ts
4
4
  export interface LoadRequest {
@@ -1,5 +1,5 @@
1
- import { H as DEFAULT_TEST_EXCLUDE, U as DEFAULT_TEST_FOLDERS, _t as relativePath, gt as matchesGlob, i as componentFiles, r as buildModel } from "./design-system-A3pRX3Ka.js";
2
- import { r as loadTarget } from "./workspace-CHvUwgz9.js";
1
+ import { Q as excludedBy, Tt as relativePath, i as componentFiles, r as buildModel } from "./design-system-Dmcv7eHR.js";
2
+ import { r as loadTarget } from "./workspace-CM1RyqZA.js";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { parentPort } from "node:worker_threads";
@@ -14,8 +14,6 @@ const warned = /* @__PURE__ */ new Set();
14
14
  /** How long a loaded design system is trusted before its files are looked at again. */
15
15
  const FRESH_MS = 2e3;
16
16
  const targets = /* @__PURE__ */ new Map();
17
- /** The default test and story patterns: the recommended config ignores those files itself. */
18
- const TEST_PATTERNS = /* @__PURE__ */ new Set([DEFAULT_TEST_EXCLUDE, DEFAULT_TEST_FOLDERS]);
19
17
  const projects = /* @__PURE__ */ new Map();
20
18
  function realPath(file) {
21
19
  try {
@@ -65,7 +63,7 @@ async function load(request) {
65
63
  projects.set(config.root, project);
66
64
  }
67
65
  const relative = relativePath(config.root, request.file);
68
- const excluded = config.exclude.find((pattern) => !TEST_PATTERNS.has(pattern) && matchesGlob(relative, pattern));
66
+ const excluded = excludedBy(config, relative, { includeTests: true });
69
67
  if (excluded) return { skip: `excluded by "${excluded}"` };
70
68
  if (!config.includeDesignSystem && project.own.has(realPath(request.file))) return { skip: "one of the design system’s own component files" };
71
69
  const { version } = project;
package/dist/eslint.js CHANGED
@@ -1,4 +1,4 @@
1
- import { H as DEFAULT_TEST_EXCLUDE, T as RULES, U as DEFAULT_TEST_FOLDERS, V as DEFAULT_SEVERITY, _ as VERSION, _t as relativePath, g as NAME, t as DesignSystem } from "./design-system-A3pRX3Ka.js";
1
+ import { G as DEFAULT_SEVERITY, K as DEFAULT_TEST_EXCLUDE, Tt as relativePath, _ as NAME, k as RULES, q as DEFAULT_TEST_FOLDERS, t as DesignSystem, v as VERSION } from "./design-system-Dmcv7eHR.js";
2
2
  import path from "node:path";
3
3
  import { MessageChannel, Worker, receiveMessageOnPort } from "node:worker_threads";
4
4
  //#region src/eslint/bridge.ts
@@ -96,8 +96,63 @@ function run(cwd, file, text, settings) {
96
96
  designSystems.set(response.root, entry);
97
97
  }
98
98
  if (!entry) return { error: `no design system loaded for ${response.root}` };
99
- const result = entry.ds.check(text, relativePath(entry.ds.root, file));
100
- return result.skipped ? { skipped: true } : { result };
99
+ const { ds } = entry;
100
+ const relative = relativePath(ds.root, file);
101
+ const result = ds.check(text, relative);
102
+ if (result.skipped) return { skipped: true };
103
+ return {
104
+ result,
105
+ fromSystem: (specifier) => ds.lint.isDesignSystemImport(specifier, relative)
106
+ };
107
+ }
108
+ /**
109
+ * The name an edit gives a JSX tag when that name does not mean the component there: `Button`
110
+ * for `<button>` → `<Button>` in a file that does not import it, imports it as a type only, or
111
+ * imports another `Button` from outside the design system. Applied, such a rename leaves a file
112
+ * that does not compile.
113
+ */
114
+ function unboundRename(sourceCode, edit, fromSystem) {
115
+ if (!edit.text.includes(".") && /^[a-z]/.test(edit.text)) return;
116
+ const [start, end] = edit.range;
117
+ let node = sourceCode.getNodeByRangeIndex(start);
118
+ while (node && (node.range[0] !== start || node.range[1] !== end)) node = node.parent ?? null;
119
+ const tag = node?.parent;
120
+ if (!node || !tag || !["JSXOpeningElement", "JSXClosingElement"].includes(tag.type)) return;
121
+ if (tag.name !== node) return;
122
+ const name = edit.text.split(".")[0] ?? edit.text;
123
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
124
+ const values = scope.set.get(name)?.defs.filter((def) => !typeOnly(def)) ?? [];
125
+ if (!values.length) continue;
126
+ return values.some((def) => isComponent(def, fromSystem)) ? void 0 : edit.text;
127
+ }
128
+ return edit.text;
129
+ }
130
+ /**
131
+ * Whether a value binding can be the component a rename names: an import from the design
132
+ * system, or a function, class, variable or parameter of the file. Not an import of the same
133
+ * name from elsewhere (`Table<TData>` from @tanstack/react-table without `type`, an app's own
134
+ * `Label`), nor an enum or a namespace.
135
+ */
136
+ function isComponent(def, fromSystem) {
137
+ if (def.type === "ImportBinding") {
138
+ const source = def.parent?.source?.value;
139
+ return typeof source === "string" && fromSystem(source);
140
+ }
141
+ return [
142
+ "FunctionName",
143
+ "ClassName",
144
+ "Variable",
145
+ "Parameter"
146
+ ].includes(def.type);
147
+ }
148
+ /**
149
+ * A binding that is only a type: a type alias, interface or type parameter (typescript-eslint's
150
+ * `Type` definition), or `import type { Button }` and `import { type Button }` (its `importKind`).
151
+ */
152
+ function typeOnly(def) {
153
+ if (def.type === "Type") return true;
154
+ const kind = (node) => node?.importKind;
155
+ return def.type === "ImportBinding" && (kind(def.node) === "type" || kind(def.parent) === "type");
101
156
  }
102
157
  function createRule(id, description) {
103
158
  return {
@@ -108,6 +163,7 @@ function createRule(id, description) {
108
163
  url: `${RULES_PAGE}#${id}`
109
164
  },
110
165
  fixable: "code",
166
+ hasSuggestions: true,
111
167
  schema: []
112
168
  },
113
169
  create(context) {
@@ -128,7 +184,9 @@ function createRule(id, description) {
128
184
  }
129
185
  for (const d of outcome.result.diagnostics) {
130
186
  if (d.ruleId !== id) continue;
131
- const edits = d.fix;
187
+ const edits = d.fix ?? [];
188
+ const fix = (fixer) => edits.map((e) => fixer.replaceTextRange(e.range, e.text));
189
+ const unbound = edits.map((e) => unboundRename(context.sourceCode, e, outcome.fromSystem)).find((name) => name !== void 0);
132
190
  context.report({
133
191
  loc: {
134
192
  start: {
@@ -141,7 +199,10 @@ function createRule(id, description) {
141
199
  }
142
200
  },
143
201
  message: d.message,
144
- ...edits?.length ? { fix: (fixer) => edits.map((e) => fixer.replaceTextRange(e.range, e.text)) } : {}
202
+ ...!edits.length ? {} : unbound ? { suggest: [{
203
+ desc: `Rename to <${unbound}>, then import it`,
204
+ fix
205
+ }] } : { fix }
145
206
  });
146
207
  }
147
208
  } };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as TOKEN_CATEGORIES, D as VariantInfo, E as TokenCategory, O as ImportMapping, S as SourceLocation, T as Token, _ as DocSection, a as ResolvedRule, b as PropInfo, c as configJsonSchema, d as CheckResult, f as ComponentDocs, g as Diagnostic, h as DesignSystemModel, i as ResolvedConfig, l as defineConfig, m as CompoundVariantInfo, n as LoadConfigOptions, o as RuleId, p as ComponentInfo, r as RULE_IDS, s as RuleOptions, t as Config, u as loadConfig, v as ExampleInfo, w as TextEdit, x as Severity, y as InheritedProps } from "./config-lbfW91qB.js";
1
+ import { C as TOKEN_CATEGORIES, D as VariantInfo, E as TokenCategory, O as ImportMapping, S as SourceLocation, T as Token, _ as DocSection, a as ResolvedRule, b as PropInfo, c as configJsonSchema, d as CheckResult, f as ComponentDocs, g as Diagnostic, h as DesignSystemModel, i as ResolvedConfig, l as defineConfig, m as CompoundVariantInfo, n as LoadConfigOptions, o as RuleId, p as ComponentInfo, r as RULE_IDS, s as RuleOptions, t as Config, u as loadConfig, v as ExampleInfo, w as TextEdit, x as Severity, y as InheritedProps } from "./config-kYGiD5oW.js";
2
2
  import ts from "typescript";
3
3
  import { Oklch } from "culori";
4
4
  import { McpServer, Transport } from "@modelcontextprotocol/server";
@@ -214,8 +214,19 @@ interface ColorSuggestion {
214
214
  reason?: 'far' | 'hue' | 'role' | 'status' | 'scoped' | undefined;
215
215
  /** With reason `status`: how far apart the hues are, in degrees. */
216
216
  hueOff?: number | undefined;
217
+ /**
218
+ * With reason `role`: a token that is close and of the same hue but made for
219
+ * another role (`content-muted`, a text color, for a border), when it is not the nearest.
220
+ */
221
+ otherRole?: Nearest<ColorCandidate> | undefined;
217
222
  /** Other tokens with the nearest one's value. */
218
223
  sameValue: Token[];
224
+ /**
225
+ * With a match that is no status color: a close status color of the color's hue that is
226
+ * about as near (within STATUS_MARGIN), so the color may mean that status (a red error
227
+ * message next to a rose brand), and the match is no sure fix.
228
+ */
229
+ closeStatus?: Nearest<ColorCandidate> | undefined;
219
230
  }
220
231
  interface NearestLength extends Nearest<LengthCandidate> {
221
232
  /** A radius so far above the scale that it means "fully rounded" (`rounded-full`). */
@@ -311,6 +322,14 @@ declare class LintTarget {
311
322
  * directory itself (a barrel: `../components/ui` and its index).
312
323
  */
313
324
  isDesignSystemImport(specifier: string, fromFile?: string): boolean;
325
+ /**
326
+ * Whether the component rules judge the components imported from `specifier`: it points at
327
+ * the design system, and the model has components to judge them by. A model with none (an
328
+ * app whose design-system package publishes only compiled `dist/`) still matches the
329
+ * configured `importPath`, but the component rules do not run there, as `check` says, so
330
+ * what comes from it is not called invented.
331
+ */
332
+ checksImport(specifier: string, fromFile?: string): boolean;
314
333
  /**
315
334
  * Whether `name`, imported from the design system, may be a real export the
316
335
  * model left out rather than an invented one: its module exists outside the
@@ -522,11 +541,24 @@ export declare class DesignSystem {
522
541
  private readonly searchIndex;
523
542
  /**
524
543
  * The components the tools offer: all of them, except a library's (MUI's) where the project
525
- * has its own of that name, a wrapper the team means to be used. The rules still check code
526
- * that imports the library's directly against the library's.
544
+ * has its own of that name, a wrapper the team means to be used, and the helpers in a
545
+ * registry folder's `lib/` (signoff-ui's `Img` and icons). The rules still check code that
546
+ * imports either directly against them.
527
547
  */
528
548
  private readonly offered;
549
+ /** The design systems the rules rank, with the registry folders a config lists. */
550
+ private readonly sources;
529
551
  constructor(model: DesignSystemModel, config: ResolvedConfig);
552
+ /**
553
+ * The shadcn registry a component was copied from (`@signoff-ui`), when its folder is one
554
+ * components.json names: a read-only design system, whose code is the registry's.
555
+ */
556
+ registryOf(component: ComponentInfo): {
557
+ registry: string;
558
+ folder: string;
559
+ } | undefined;
560
+ /** A component in a registry folder's `lib/`: an internal of its items, not one of them. */
561
+ private registryHelper;
530
562
  get root(): string;
531
563
  get components(): ComponentInfo[];
532
564
  get tokens(): Token[];
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { $ as findVariantDefinitions, A as toDtcg, D as loadTokens, E as TokenIndex, F as attachDocs, G as RULE_IDS, I as parseDoc, K as configJsonSchema, L as extractComponents, M as parseDtcgTokens, N as parseDesignMd, O as mergeTokens, P as parseCssTokens, T as RULES, Y as loadConfig, _ as VERSION, a as fingerprint, d as searchComponents, f as applyFixes, h as formatDiagnostics, k as toDesignMd, n as DesignSystemHost, o as loadDesignSystem, p as checkSource, q as defineConfig, r as buildModel, t as DesignSystem } from "./design-system-A3pRX3Ka.js";
2
- import { n as INSTRUCTIONS, r as createServer, t as serveStdio } from "./stdio-CEChNFLR.js";
1
+ import { A as TokenIndex, B as parseDoc, I as parseDtcgTokens, L as parseDesignMd, M as mergeTokens, N as toDesignMd, P as toDtcg, R as parseCssTokens, V as extractComponents, X as configJsonSchema, Y as RULE_IDS, Z as defineConfig, a as fingerprint, d as searchComponents, et as loadConfig, f as applyFixes, g as formatDiagnostics, it as findVariantDefinitions, j as loadTokens, k as RULES, n as DesignSystemHost, o as loadDesignSystem, p as checkSource, r as buildModel, t as DesignSystem, v as VERSION, z as attachDocs } from "./design-system-Dmcv7eHR.js";
2
+ import { n as INSTRUCTIONS, r as createServer, t as serveStdio } from "./stdio-MTS6QAgo.js";
3
3
  export { DesignSystem, DesignSystemHost, INSTRUCTIONS, RULES, RULE_IDS, TokenIndex, VERSION, applyFixes, attachDocs, buildModel, checkSource, configJsonSchema, createServer, defineConfig, extractComponents, findVariantDefinitions, fingerprint, formatDiagnostics, loadConfig, loadDesignSystem, loadTokens, mergeTokens, parseCssTokens, parseDesignMd, parseDoc, parseDtcgTokens, searchComponents, serveStdio, toDesignMd, toDtcg };
@@ -1,5 +1,5 @@
1
- import { Q as formatPx, R as CONFIG_FILES, W as LEGACY_CONFIG_FILES, Y as loadConfig, _ as VERSION, _t as relativePath, d as searchComponents, dt as closest, ft as plural, g as NAME, ht as isInside, j as TOKEN_CATEGORIES, m as uncheckedNotice, mt as unique, n as DesignSystemHost, pt as truncate, u as buildSearchIndex } from "./design-system-A3pRX3Ka.js";
2
- import { n as hasProjectMarker, r as loadTarget, t as findProjectRoot } from "./workspace-CHvUwgz9.js";
1
+ import { Ct as isInside, Dt as defaultProjectConfig, F as TOKEN_CATEGORIES, G as DEFAULT_SEVERITY, H as CONFIG_FILES, J as LEGACY_CONFIG_FILES, Q as excludedBy, St as SCRIPT_FILE, Tt as relativePath, Y as RULE_IDS, _ as NAME, at as LIBRARIES, bt as truncate, ct as notReadYet, d as searchComponents, et as loadConfig, ft as detectProject, m as uncheckedNotice, n as DesignSystemHost, ot as LIBRARY_IDS, pt as findDesignSystemPackage, rt as formatPx, st as libraryTitle, u as buildSearchIndex, v as VERSION, vt as closest, xt as unique, yt as plural } from "./design-system-Dmcv7eHR.js";
2
+ import { n as hasProjectMarker, r as loadTarget, t as findProjectRoot } from "./workspace-CM1RyqZA.js";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -7,6 +7,214 @@ import * as z from "zod";
7
7
  import fsp from "node:fs/promises";
8
8
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
9
9
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
10
+ //#region src/explain.ts
11
+ /**
12
+ * What `inspect --explain` says about one project: every candidate zero config looked
13
+ * at and why each was taken or not, the config it resolved to, tokens whose value did
14
+ * not resolve, and components without props, with the likely reason.
15
+ */
16
+ function explainProject(ds, cwd, indent = "") {
17
+ const { config, model } = ds;
18
+ const lines = [];
19
+ const out = (line = "") => lines.push(line ? `${indent}${line}` : "");
20
+ out("How the design system was found");
21
+ for (const step of packageSteps(config, cwd)) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
22
+ if (config.configured) out(` components come from ${config.configFile ? display(cwd, config.configFile) : "--components"}, so zero config did not look`);
23
+ else {
24
+ const steps = [];
25
+ detectProject(config.root, config.tsconfig, steps, config.designSystemPackage);
26
+ for (const step of steps) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
27
+ if (!config.detected) out(` So the defaults apply: ${config.components.join(", ")}${model.stats.files.components ? "" : ", which match nothing"}.`);
28
+ }
29
+ const named = (library) => `${library.package}${library.version ? ` ${library.version}` : ""}`;
30
+ for (const library of config.libraries ?? []) {
31
+ const count = model.components.filter((c) => c.library === library.package).length;
32
+ out(library.dir ? ` ${"found".padEnd(6)} ${named(library)}: a dependency, ${count} components from its type declarations in ${display(cwd, library.dir)}` : ` ${"no".padEnd(6)} ${named(library)}: a dependency, but not installed`);
33
+ }
34
+ for (const library of config.librariesOff ?? []) out(` ${"no".padEnd(6)} ${named(library)}: a dependency, ${librariesLeftOut(config, cwd)}`);
35
+ for (const library of config.librariesUnread ?? []) out(` ${"no".padEnd(6)} ${named(library)}: ${notReadYet()}`);
36
+ out();
37
+ out("Resolved config");
38
+ const field = (name, value) => out(` ${name.padEnd(12)} ${value}`);
39
+ field("root", config.root);
40
+ field("config", config.configFile ? `${display(cwd, config.configFile)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ""}` : "(none)");
41
+ field("components", listOf$3(config.components) || "(none)");
42
+ field("exclude", listOf$3(config.exclude));
43
+ field("tokens", listOf$3(config.tokens.map((t) => `${t.path}${t.prefix ? ` (prefix ${t.prefix})` : ""}${t.fallback ? " (when nothing else is found)" : ""}`)));
44
+ field("docs", listOf$3(config.docs));
45
+ const projectConfig = defaultProjectConfig(config.root);
46
+ field("tsconfig", config.tsconfig ?? (projectConfig ? display(config.root, projectConfig) : "(none)"));
47
+ field("importPath", importPaths(ds));
48
+ field("libraries", config.libraryIds === void 0 ? `every library onsystem reads (${LIBRARY_IDS.join(", ")})` : config.libraryIds !== false && config.libraryIds.length ? config.libraryIds.join(", ") : `none ("libraries": ${JSON.stringify(config.libraryIds)})`);
49
+ if (config.tailwindConfig) field("tailwind", display(config.root, config.tailwindConfig));
50
+ if (config.designSystems) {
51
+ const ranked = config.designSystems.filter((d) => !d.registry).length > 1;
52
+ field("primary", config.designSystems.map((d, i) => `${i ? "then " : ""}${d.source}${d.registry ? ", read-only" : ranked ? ` (${d.imports} imports)` : ""}`).join("; "));
53
+ }
54
+ const changed = RULE_IDS.filter((id) => config.rules[id].severity !== DEFAULT_SEVERITY[id]);
55
+ field("rules", `${RULE_IDS.map((id) => `${id} ${config.rules[id].severity}`).join(", ")}${changed.length ? ` (changed: ${changed.join(", ")})` : ""}`);
56
+ if (config.overrides?.length) for (const override of config.overrides) field("override", `${override.files.join(", ")}: ${Object.entries(override.rules).map(([id, rule]) => `${id} ${rule.severity}`).join(", ")}`);
57
+ if (config.base) field("relative to", `${config.base} (a config at the workspace root)`);
58
+ out();
59
+ out("Tokens");
60
+ const found = ds.tokens;
61
+ const files = model.stats.files.tokens;
62
+ const themed = found.filter((t) => t.origin === "library");
63
+ out(` ${found.length - themed.length} tokens from ${files} ${files === 1 ? "file" : "files"}${files ? "" : `: no file matches ${config.tokens.map((t) => t.path).join(", ")}`}`);
64
+ if (themed.length) {
65
+ const sources = [...new Set(themed.map((t) => t.source.file))];
66
+ out(` ${themed.length} tokens from a library's theme in ${sources.join(", ")}`);
67
+ }
68
+ const defined = new Set(found.map((t) => t.cssVar).filter((v) => Boolean(v)));
69
+ const missing = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] ?? "").filter((v) => !defined.has(v));
70
+ const unresolved = found.filter((t) => missing(t.value).length);
71
+ if (unresolved.length) out(` ${unresolved.length} unresolved, reading a custom property no token file defines: ${unresolved.slice(0, 8).map((t) => `${t.cssVar ?? t.name}: ${t.value}`).join(", ")}${unresolved.length > 8 ? `, and ${unresolved.length - 8} more` : ""}`);
72
+ else if (found.length) out(" every value resolves");
73
+ for (const warning of model.warnings.filter((w) => /alias|token/i.test(w))) out(` ${warning}`);
74
+ out();
75
+ out("Props");
76
+ const empty = ds.roots().filter((c) => c.openProps && !c.props.length && !c.inherits.length);
77
+ const open = ds.components.filter((c) => c.openProps);
78
+ if (!open.length) out(` every component's props resolved (${ds.components.length} components)`);
79
+ else {
80
+ const installed = nodeModulesFor(config.root);
81
+ const names = (list) => `${list.slice(0, 6).map((c) => c.name).join(", ")}${list.length > 6 ? ` and ${list.length - 6} more` : ""}`;
82
+ if (empty.length) out(` ${empty.length} ${empty.length === 1 ? "component has" : "components have"} 0 props: ${names(empty)}.`);
83
+ out(` ${open.length} ${open.length === 1 ? "component has" : "components have"} props that did not fully resolve${empty.length === open.length ? "" : `: ${names(open)}`}, so no-unknown-prop skips ${open.length === 1 ? "it" : "them"}.`);
84
+ out(installed ? ` Dependencies are installed (${display(cwd, installed)}): a package without types, a type error, or a props type that depends on a type argument leaves them open.` : ` The dependencies are not installed: no node_modules in ${config.root} or a folder above it. Their props come from the packages they wrap (Radix, React Aria), so install them (npm install, pnpm install) and run again.`);
85
+ }
86
+ return lines;
87
+ }
88
+ /** Why a library the project depends on is not read: `left out by "libraries" in onsystem.config.json`. */
89
+ function librariesLeftOut(config, cwd) {
90
+ return `left out by "libraries" in ${config.configFile ? display(cwd, config.configFile) : "the config"}`;
91
+ }
92
+ /** The first few, then how many more: a package's exports can name hundreds of files. */
93
+ function listOf$3(items, max = 8) {
94
+ const shown = items.slice(0, max).join(", ");
95
+ return items.length > max ? `${shown}, and ${items.length - max} more` : shown;
96
+ }
97
+ /** How components are suggested to be imported. */
98
+ function importPaths(ds) {
99
+ if (ds.config.importPath) return `${ds.config.importPath} (from the config)`;
100
+ const counts = /* @__PURE__ */ new Map();
101
+ for (const c of ds.roots()) counts.set(c.importPath, (counts.get(c.importPath) ?? 0) + 1);
102
+ const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3);
103
+ const from = ds.config.imports?.length ? "package exports" : "tsconfig paths or relative paths";
104
+ return top.length ? `inferred from ${from}: ${top.map(([p, n]) => `${p} (${n})`).join(", ")}${counts.size > 3 ? `, and ${counts.size - 3} more` : ""}` : `inferred from ${from}`;
105
+ }
106
+ /** The nearest node_modules at or above `dir`, if any. */
107
+ function nodeModulesFor(dir) {
108
+ for (let current = path.resolve(dir);; current = path.dirname(current)) {
109
+ const candidate = path.join(current, "node_modules");
110
+ if (fs.existsSync(candidate)) return candidate;
111
+ if (current === path.dirname(current)) return void 0;
112
+ }
113
+ }
114
+ function display(cwd, file) {
115
+ const rel = relativePath(cwd, file);
116
+ return rel && !rel.startsWith("..") ? rel : file;
117
+ }
118
+ /**
119
+ * Whether a dependency's declared config was taken on, for a project without a config of its
120
+ * own: the package it came from and the others that declare one, or that none does.
121
+ */
122
+ function packageSteps(config, cwd) {
123
+ const declared = config.designSystemPackage;
124
+ if (config.configFile && !declared) return [];
125
+ const lookup = findDesignSystemPackage(config.root);
126
+ if (!lookup) return [];
127
+ const candidate = "a design-system package's config";
128
+ const skipped = lookup.skipped.length ? `; ${lookup.skipped.map((p) => p.name).join(", ")} ${lookup.skipped.length === 1 ? "declares one" : "declare one"} that cannot be read, skipped since installed` : "";
129
+ if (!declared) return [{
130
+ candidate,
131
+ accepted: false,
132
+ reason: `no dependency declares one ("onsystem" in its package.json) among ${lookup.dependencies} ${lookup.dependencies === 1 ? "dependency" : "dependencies"}${skipped}`
133
+ }];
134
+ return [{
135
+ candidate,
136
+ accepted: true,
137
+ reason: `${declared.name} (${declared.installed ? "installed" : "a workspace package"}) declares ${display(cwd, declared.config)}, which this project takes on, with its paths from ${display(cwd, declared.dir)}${lookup.others.length ? `; ${lookup.others.join(", ")} also declare one, not read: one config is taken on, from the package the code imports most` : ""}${skipped}`
138
+ }];
139
+ }
140
+ //#endregion
141
+ //#region src/not-read.ts
142
+ const LIMITS_DOCS = "https://onsystem.vercel.app/docs/how-it-works#limits";
143
+ /**
144
+ * What `check` and `check_ui` could not read, said after the results as the notice for missing
145
+ * components and tokens is, so a clean result is not mistaken for a checked one: a component
146
+ * library the project depends on that onsystem does not read yet, and dependencies that are not
147
+ * installed. Projects with the same gap share one notice that names them.
148
+ */
149
+ function notReadNotices(projects) {
150
+ return [...unreadLibraries(projects), missingDependencies(projects)].filter((notice) => notice !== void 0);
151
+ }
152
+ /**
153
+ * A component library onsystem does not read yet (Chakra UI, Mantine), in a project without
154
+ * components of its own, whose code is then checked by the accessibility rule alone. One notice
155
+ * per set of libraries.
156
+ */
157
+ function unreadLibraries(projects) {
158
+ const groups = /* @__PURE__ */ new Map();
159
+ for (const { ds, dir } of projects) {
160
+ const packages = (ds.config.librariesUnread ?? []).map((library) => library.package);
161
+ if (!packages.length || ds.components.length) continue;
162
+ const key = packages.join(" ");
163
+ const group = groups.get(key) ?? {
164
+ packages,
165
+ dirs: []
166
+ };
167
+ if (dir !== void 0) group.dirs.push(dir);
168
+ groups.set(key, group);
169
+ }
170
+ return [...groups.values()].map(({ packages, dirs }) => {
171
+ const one = packages.length === 1;
172
+ const names = listOf$2(packages.map((name) => `${libraryTitle(name)} (${name})`), packages.length);
173
+ const of = dirs.length ? ` of ${listOf$2(dirs)}` : "";
174
+ return `${names} ${one ? "is a dependency" : "are dependencies"}${of}, but onsystem does not read ${one ? "it" : "them"} yet: of the published component libraries, it reads only ${LIBRARIES.map((l) => l.title).join(", ")} so far. Wrap ${one ? "its" : "their"} components in your own and point "components" in the config at those. See ${LIMITS_DOCS}`;
175
+ });
176
+ }
177
+ /**
178
+ * Dependencies that are not installed: no `node_modules` at or above a project that declares
179
+ * some, so the props of components that come from them (Radix, React's own) do not resolve and
180
+ * `no-unknown-prop` skips those components. For plain `inspect` too, on stderr.
181
+ */
182
+ function missingDependencies(projects) {
183
+ const dirs = [];
184
+ let skipped = 0;
185
+ for (const { ds, dir } of projects) {
186
+ if (ds.config.rules["no-unknown-prop"].severity === "off") continue;
187
+ const open = ds.components.filter((c) => c.openProps).length;
188
+ if (!open || nodeModulesFor(ds.config.root) || !declaresDependencies(ds.config.root)) continue;
189
+ skipped += open;
190
+ if (dir !== void 0) dirs.push(dir);
191
+ }
192
+ if (!skipped) return void 0;
193
+ return `The dependencies${dirs.length ? ` of ${listOf$2(dirs)}` : ""} are not installed (no node_modules): no-unknown-prop skips ${plural(skipped, "component")} whose props come from them. Install them (npm install, pnpm install) and run again.`;
194
+ }
195
+ /** Whether `root`'s package.json lists dependencies, devDependencies or peerDependencies. */
196
+ function declaresDependencies(root) {
197
+ try {
198
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
199
+ return [
200
+ "dependencies",
201
+ "devDependencies",
202
+ "peerDependencies"
203
+ ].some((field) => {
204
+ const value = pkg[field];
205
+ return Boolean(value && typeof value === "object" && Object.keys(value).length);
206
+ });
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+ /** `apps/web`, `apps/web and packages/ui`, `a, b, c and 4 more`. */
212
+ function listOf$2(items, max = 3) {
213
+ if (items.length <= 1) return items[0] ?? "";
214
+ if (items.length <= max) return `${items.slice(0, -1).join(", ")} and ${items.at(-1) ?? ""}`;
215
+ return `${items.slice(0, max).join(", ")} and ${items.length - max} more`;
216
+ }
217
+ //#endregion
10
218
  //#region src/server/render.ts
11
219
  /**
12
220
  * Tool results are read by models, so they are rendered as compact Markdown:
@@ -31,7 +239,7 @@ function renderComponentList(ds, project) {
31
239
  const parts = ds.components.length - roots.length;
32
240
  const lines = [`${plural(roots.length, "component")}${parts ? ` (+${plural(parts, "part")})` : ""}${project ? ` in ${project}` : ""}. Call get_component before using one.`, ""];
33
241
  const own = roots.filter((c) => !c.library);
34
- for (const component of own) lines.push(...listItem(component));
242
+ for (const component of own) lines.push(...listItem(component, registryNote(ds, component)));
35
243
  for (const library of new Set(roots.flatMap((c) => c.library ? [c.library] : []))) {
36
244
  if (own.length) lines.push("");
37
245
  lines.push(`From ${library} (import { Name } from "${library}"):`, "");
@@ -39,6 +247,11 @@ function renderComponentList(ds, project) {
39
247
  }
40
248
  return lines.join("\n");
41
249
  }
250
+ /** ` (from @signoff-ui, read-only)` for a component copied from a shadcn registry. */
251
+ function registryNote(ds, component) {
252
+ const from = ds.registryOf(component);
253
+ return from ? ` (from ${from.registry}, read-only)` : "";
254
+ }
42
255
  function listItem(component, note = "", withImport = true) {
43
256
  const element = component.element ? ` <${component.element}>` : "";
44
257
  const lines = [`${component.name}${element}${note} — ${firstSentence(component.description) || "No description."}`];
@@ -171,6 +384,8 @@ function renderComponent(ds, component, origin) {
171
384
  ].filter(Boolean);
172
385
  lines.push(facts.join(" · "));
173
386
  if (origin) lines.push(`From ${origin.package}, used by ${listOf$1(origin.projects)}.`);
387
+ const registry = ds.registryOf(component);
388
+ if (registry) lines.push(`Installed from the shadcn registry ${registry.registry} into ${registry.folder}: use it as it is. Its code is the registry's, so change it upstream, not here; \`npx shadcn add --overwrite\` replaces the file.`);
174
389
  lines.push("", "## Props");
175
390
  if (!component.props.length && !component.inherits.length) lines.push("No props.");
176
391
  for (const prop of component.props) lines.push(renderProp(prop));
@@ -360,6 +575,10 @@ const MAX_CODE = 1e6;
360
575
  /** Diagnostics check_ui returns by default: a 3,000-line file can have thousands, more than a context holds. */
361
576
  const DEFAULT_LIMIT = 50;
362
577
  const MAX_LIMIT = 1e3;
578
+ /** check_ui's answer for a path or filename that is not JS or TS. */
579
+ function notScript(name) {
580
+ return errorResult(`check_ui checks .tsx, .jsx, .ts and .js files; ${name} is not one, so it was not checked.`);
581
+ }
363
582
  /**
364
583
  * An integer, without the ±9007199254740991 bounds zod adds to the JSON Schema,
365
584
  * which every client would otherwise read in each tool definition.
@@ -794,7 +1013,7 @@ function createServer({ getDesignSystem, getProjects }) {
794
1013
  description: "Lint TSX/JSX against the design system. Reports hardcoded colors, spacing and radius (with the nearest token), native elements that have a design-system component, unknown components, props and variant values, and icon-only buttons without an accessible name. Each diagnostic has line/column, a rule id, a message and a suggested fix. Run it on every snippet or file you write and fix all errors. Pass `code` for unsaved code, or `path` for a file in the project.",
795
1014
  inputSchema: z.object({
796
1015
  code: z.string().max(MAX_CODE).optional().describe(`TSX/JSX source to check (up to ${MAX_CODE.toLocaleString("en")} characters).`),
797
- path: z.string().max(MAX_PATH).optional().describe("Path of a file to check: relative to the project root (the monorepo root, when served from there), or absolute."),
1016
+ path: z.string().max(MAX_PATH).optional().describe("Path of a .tsx, .jsx, .ts or .js file to check: relative to the project root (the monorepo root, when served from there), or absolute."),
798
1017
  filename: z.string().max(MAX_PATH).optional().describe("Name to report `code` under; a .jsx extension parses it as JSX. At a monorepo root, the path the code will be saved under, which picks the project whose design system checks it. When the server was started outside your project, pass it as an absolute path: the project is found from it."),
799
1018
  limit: z.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT).describe(`Most diagnostics to return, errors first (default ${DEFAULT_LIMIT}). The counts and byRule cover all of them.`)
800
1019
  }),
@@ -809,16 +1028,17 @@ function createServer({ getDesignSystem, getProjects }) {
809
1028
  errors: int(),
810
1029
  warnings: int()
811
1030
  })).describe("Every finding per rule, shown or not."),
812
- skipped: z.string().optional().describe("Why the file was not checked: it renders an image (next/og) or an email."),
1031
+ skipped: z.string().optional().describe("Why the file was not checked: it renders an image (next/og) or an email, or the config's `exclude` leaves it out."),
813
1032
  unchecked: z.object({
814
1033
  names: z.array(z.string()),
815
1034
  modules: z.array(z.string())
816
1035
  }).optional().describe("Components imported from modules the model does not include, not checked."),
817
- notice: z.string().optional().describe("Set when no components or no color tokens were found, so rules did not run, or when components came from modules the model does not include.")
1036
+ notice: z.string().optional().describe("Set when no components or no color tokens were found, so rules did not run, when components came from modules the model does not include, when the project depends on a component library onsystem does not read yet, or when its dependencies are not installed.")
818
1037
  }),
819
1038
  annotations: READ_ONLY
820
1039
  }, async ({ code, path: filePath, filename, limit }) => {
821
1040
  if (code === void 0 && !filePath) return errorResult("Pass either `code` or `path`.");
1041
+ if (code !== void 0 && filename !== void 0 && !SCRIPT_FILE.test(filename)) return notScript(filename);
822
1042
  const routeBy = code === void 0 ? filePath : filename ?? void 0;
823
1043
  const set = await getProjects?.(routeBy);
824
1044
  let ds;
@@ -843,6 +1063,7 @@ function createServer({ getDesignSystem, getProjects }) {
843
1063
  const absolute = path.resolve(base, filePath);
844
1064
  const outside = errorResult(`${filePath} is outside the project root (${base}).`);
845
1065
  if (!isInside(base, absolute)) return outside;
1066
+ if (!SCRIPT_FILE.test(absolute)) return notScript(filePath);
846
1067
  try {
847
1068
  const [root, real] = await Promise.all([fsp.realpath(base), fsp.realpath(absolute)]);
848
1069
  if (!isInside(root, real)) return outside;
@@ -866,13 +1087,24 @@ function createServer({ getDesignSystem, getProjects }) {
866
1087
  file = relativePath(ds.root, absolute);
867
1088
  shown = relativePath(base, absolute);
868
1089
  }
869
- const checked = ds.check(source, file);
1090
+ const excluded = (filename !== void 0 || code === void 0) && !path.isAbsolute(file) ? excludedBy(ds.config, file) : void 0;
1091
+ const checked = excluded ? {
1092
+ file,
1093
+ diagnostics: [],
1094
+ errorCount: 0,
1095
+ warningCount: 0,
1096
+ skipped: `It is left out by "${excluded}" in the config's exclude, as in check, the ESLint plugin and the Claude Code hook.`
1097
+ } : ds.check(source, file);
870
1098
  const result = file === shown ? checked : {
871
1099
  ...checked,
872
1100
  file: shown
873
1101
  };
874
1102
  const capped = capDiagnostics(result, limit);
875
- const notice = result.skipped ? void 0 : [ds.notice(), uncheckedNotice([result])].filter(Boolean).join("\n\n") || void 0;
1103
+ const notice = result.skipped ? void 0 : [
1104
+ ds.notice(),
1105
+ ...notReadNotices([{ ds }]),
1106
+ uncheckedNotice([result])
1107
+ ].filter(Boolean).join("\n\n") || void 0;
876
1108
  return {
877
1109
  content: [{
878
1110
  type: "text",
@@ -1393,4 +1625,4 @@ async function clientRoot(server) {
1393
1625
  }
1394
1626
  }
1395
1627
  //#endregion
1396
- export { INSTRUCTIONS as n, createServer as r, serveStdio$1 as t };
1628
+ export { notReadNotices as a, missingDependencies as i, INSTRUCTIONS as n, explainProject as o, createServer as r, librariesLeftOut as s, serveStdio$1 as t };
@@ -1,4 +1,4 @@
1
- import { J as hasOwnConfig, R as CONFIG_FILES, W as LEGACY_CONFIG_FILES, X as loadConfigWith, Y as loadConfig, _t as relativePath, ct as isWorkspaceRoot, ht as isInside, i as componentFiles, lt as workspacePackageDirs, ot as findDesignSystemPackage, rt as PackageFinder, ut as workspacePatterns } from "./design-system-A3pRX3Ka.js";
1
+ import { $ as hasOwnConfig, Ct as isInside, H as CONFIG_FILES, J as LEGACY_CONFIG_FILES, Tt as relativePath, _t as workspacePatterns, et as loadConfig, gt as workspacePackageDirs, ht as isWorkspaceRoot, i as componentFiles, pt as findDesignSystemPackage, tt as loadConfigWith, ut as PackageFinder } from "./design-system-Dmcv7eHR.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "onsystem",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.dgesteves/onsystem",
5
- "description": "Keeps coding agents on your design system: it knows your real components, props, variants and tokens, catches the moment an agent invents one and has it fix it, and the same check gates your PRs. Local, zero config, works alongside @shadcn/lint.",
5
+ "description": "Keeps coding agents on your design system: catches palette colors, native elements and APIs your system lacks, at every edit and pull request, with the fix. Set it up once in your design-system package; every app in your monorepo gets it.",
6
6
  "keywords": [
7
7
  "design-system",
8
8
  "design-system-lint",
@@ -69,10 +69,10 @@
69
69
  "tinyglobby": "^0.2.17",
70
70
  "typescript": "~6.0.3",
71
71
  "yaml": "^2.9.1",
72
- "zod": "^4.6.5"
72
+ "zod": "^4.6.6"
73
73
  },
74
74
  "peerDependencies": {
75
- "eslint": ">=9.0.0"
75
+ "eslint": ">=8.57.0"
76
76
  },
77
77
  "peerDependenciesMeta": {
78
78
  "eslint": {
@@ -86,7 +86,7 @@
86
86
  "@modelcontextprotocol/client": "^2.3.1",
87
87
  "@modelcontextprotocol/sdk": "^1.32.1",
88
88
  "@types/culori": "^4.0.1",
89
- "@types/node": "^24.19.1",
89
+ "@types/node": "^24.19.2",
90
90
  "ajv": "^8.20.0",
91
91
  "ajv-draft-04": "^1.0.0",
92
92
  "ajv-formats": "^3.0.1",
@@ -96,11 +96,11 @@
96
96
  "geist": "^1.7.2",
97
97
  "globals": "^17.13.0",
98
98
  "opentype.js": "^2.0.0",
99
- "prettier": "^3.9.9",
99
+ "prettier": "^3.9.10",
100
100
  "publint": "^0.3.25",
101
- "react-aria-components": "^1.21.1",
101
+ "react-aria-components": "^1.22.1",
102
102
  "tailwind-variants": "^3.3.1",
103
- "tsdown": "^0.23.0",
103
+ "tsdown": "^0.23.1",
104
104
  "typescript-eslint": "^8.71.1",
105
105
  "vitest": "^5.0.3"
106
106
  },
@@ -114,7 +114,7 @@
114
114
  "test": "vitest run",
115
115
  "test:watch": "vitest",
116
116
  "smoke": "node scripts/smoke.mjs",
117
- "pack-test": "node scripts/pack-test.mjs",
117
+ "pack-test": "node scripts/pack-test.mjs && node scripts/pack-test-eslint8.mjs",
118
118
  "assets": "node scripts/build-assets.mjs",
119
119
  "palette": "node scripts/build-palette.mjs",
120
120
  "sync-plugins": "node scripts/sync-plugins.mjs",
package/schema.json CHANGED
@@ -22,7 +22,7 @@
22
22
  ]
23
23
  },
24
24
  "exclude": {
25
- "description": "Globs to ignore when finding components and when `check` expands folders and globs (stories, tests). Replaces the defaults; node_modules is skipped either way unless a pattern names it.",
25
+ "description": "Globs for files to leave out, relative to the root: `check` in folders and globs, the ESLint plugin, the Claude Code hook and check_ui leave them out, and finding components skips them. A folder (`app/legacy`) covers everything in it. Adds to the defaults (node_modules, .d.ts files, tests and stories); a pattern that starts with `!` takes out the default it names, such as `!**/*.d.ts`.",
26
26
  "type": "array",
27
27
  "items": {
28
28
  "type": "string"