onsystem 0.8.0 → 0.9.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
@@ -20,6 +20,7 @@ Using the GitHub Action? `uses: dgesteves/onsystem@v0` checks the lines a pull r
20
20
  **Also works with**
21
21
  [![Codex CLI](https://img.shields.io/badge/Codex_CLI-0d0f12?style=flat-square)](https://onsystem.vercel.app/docs/clients#codex-cli)
22
22
  [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-0d0f12?style=flat-square&logo=googlegemini&logoColor=white)](https://onsystem.vercel.app/docs/clients#gemini-cli)
23
+ [![Antigravity](https://img.shields.io/badge/Antigravity-0d0f12?style=flat-square)](https://onsystem.vercel.app/docs/clients#antigravity-cli-and-ide)
23
24
  [![Grok Build](https://img.shields.io/badge/Grok_Build-0d0f12?style=flat-square)](https://onsystem.vercel.app/docs/clients#grok-build)
24
25
  [![GitHub Copilot CLI](https://img.shields.io/badge/GitHub_Copilot_CLI-0d0f12?style=flat-square&logo=githubcopilot&logoColor=white)](https://onsystem.vercel.app/docs/clients#github-copilot-cli)
25
26
  [![Claude Desktop](https://img.shields.io/badge/Claude_Desktop-0d0f12?style=flat-square&logo=claude&logoColor=white)](https://onsystem.vercel.app/docs/clients#claude-desktop)
@@ -29,6 +30,8 @@ Using the GitHub Action? `uses: dgesteves/onsystem@v0` checks the lines a pull r
29
30
 
30
31
  **Website and docs:** [onsystem.vercel.app](https://onsystem.vercel.app), with a [playground](https://onsystem.vercel.app/playground) that checks your own code.
31
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
+
32
35
  <p align="center">
33
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>
34
37
  </p>
@@ -83,7 +86,7 @@ After each Write or Edit of a `.tsx` or `.jsx` file, the hook runs `onsystem che
83
86
 
84
87
  **Other agents.** `npx skills add dgesteves/onsystem` adds the skill to any agent with Agent Skills, and there are [plugins for Codex, GitHub Copilot CLI, VS Code and Cursor](https://onsystem.vercel.app/docs/clients#plugins-for-other-agents).
85
88
 
86
- **Any MCP client.** Run `npx -y onsystem` as a stdio server. There are [configs for Claude Desktop, Codex CLI, GitHub Copilot CLI, Windsurf, JetBrains IDEs, Zed, Gemini CLI and Grok Build](https://onsystem.vercel.app/docs/clients#other-clients).
89
+ **Any MCP client.** Run `npx -y onsystem` as a stdio server. There are [configs for Claude Desktop, Codex CLI, GitHub Copilot CLI, Windsurf, JetBrains IDEs, Zed, Gemini CLI, Antigravity and Grok Build](https://onsystem.vercel.app/docs/clients#other-clients).
87
90
 
88
91
  **CI.** Install it as a dev dependency, accept the findings the codebase already has once, and fail pull requests only on new ones:
89
92
 
@@ -96,7 +99,22 @@ npx onsystem check . --update-baseline # writes onsystem.baseline.json: commit
96
99
  - run: npx onsystem check . --format github --require-design-system
97
100
  ```
98
101
 
99
- Findings show up as annotations on the pull request, and `--require-design-system` fails the job when the design system is no longer found. [CI and baselines](https://onsystem.vercel.app/docs/ci) has the whole workflow. The GitHub Action, `uses: dgesteves/onsystem@v0`, annotates only the lines a pull request changed: [make it a required check on agent pull requests](https://onsystem.vercel.app/docs/required-check).
102
+ Findings show up as annotations on the pull request, and `--require-design-system` fails the job when the design system is no longer found. [CI and baselines](https://onsystem.vercel.app/docs/ci) has the whole workflow.
103
+
104
+ **GitHub Action.** The [action](https://github.com/marketplace/actions/onsystem-design-system-check) runs the same check, honours the baseline and annotates only the lines a pull request changed. It goes after the checkout and the install, so it can read your design system:
105
+
106
+ ```yaml
107
+ steps:
108
+ - uses: actions/checkout@v7
109
+ - uses: actions/setup-node@v7
110
+ with:
111
+ node-version: 24
112
+ cache: npm
113
+ - run: npm ci
114
+ - uses: dgesteves/onsystem@v0
115
+ ```
116
+
117
+ [Make it a required check on agent pull requests](https://onsystem.vercel.app/docs/required-check) has the whole workflow and its inputs.
100
118
 
101
119
  **ESLint.** The same rules for ESLint 8.57, 9 and 10: add `onsystem.configs.recommended` from `onsystem/eslint` to `eslint.config.mjs` ([ESLint plugin](https://onsystem.vercel.app/docs/eslint), with @shadcn/lint).
102
120
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { C as pruneBaseline, Ct as isInside, D as updateBaseline, E as unrecordedRules, Et as toPosix, H as CONFIG_FILES, J as LEGACY_CONFIG_FILES, N as toDesignMd, O as writeBaseline, Ot as readProjectConfig, P as toDtcg, Q as excludedBy, S as countBaseline, St as SCRIPT_FILE, T as rulesRun, Tt as relativePath, U as ConfigError, W as DEFAULT_DOCS, Y as RULE_IDS, _ as NAME, b as LEGACY_BASELINE_FILE, c as silentLogger, ct as notReadYet, dt as declaredConfig, ft as detectProject, g as formatDiagnostics, h as escapeData, i as componentFiles, l as stderrLogger, lt as PACKAGE_FIELD, m as uncheckedNotice, mt as isDesignSystemName, nt as rulesFor, o as loadDesignSystem, s as tokenFiles, st as libraryTitle, v as VERSION, w as readBaseline, wt as matchesGlob, x as applyBaseline, xt as unique, y as BASELINE_FILE, yt as plural } from "./design-system-Dmcv7eHR.js";
3
- import { a as notReadNotices, i as missingDependencies, o as explainProject, s as librariesLeftOut, t as serveStdio } from "./stdio-MTS6QAgo.js";
4
- import { r as loadTarget } from "./workspace-CM1RyqZA.js";
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";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { escapePath, glob } from "tinyglobby";
@@ -169,6 +169,14 @@ async function init(configOptions, options, io, runCheck) {
169
169
  const total = errors + warnings;
170
170
  io.stdout(total ? `Dry run of \`${NAME} check .\`: ${count$1(errors, "error")} and ${count$1(warnings, "warning")} in ${flagged} of ${count$1(results.length, "file")}. By rule: ${[...byRule].sort((a, b) => b[1] - a[1]).map(([rule, n]) => `${rule} ${n.toLocaleString("en-US")}`).join(", ")}.` : `Dry run of \`${NAME} check .\`: no problems in ${count$1(results.length, "file")}.`);
171
171
  for (const line of dry.stderr.split("\n").filter(Boolean)) io.stdout(line);
172
+ if (!workspace) {
173
+ const own = (await loadTarget({
174
+ ...configOptions,
175
+ config: void 0
176
+ })).config;
177
+ const note = statusNote(await loadDesignSystem(own, { cache: options.cache }));
178
+ if (note) io.stdout(note);
179
+ }
172
180
  const hasBaseline = [BASELINE_FILE, LEGACY_BASELINE_FILE].some((name) => fs.existsSync(path.join(root, name)));
173
181
  let baselineWritten = false;
174
182
  if (total && !hasBaseline) {
@@ -196,6 +204,28 @@ async function init(configOptions, options, io, runCheck) {
196
204
  return 0;
197
205
  }
198
206
  const CONFIG_FILE = "onsystem.config.json";
207
+ /** The status colors docs/tokens.md#status-colors adds, and the hue each one is. */
208
+ const RECIPE = [
209
+ ["success", "green"],
210
+ ["warning", "yellow"],
211
+ ["info", "blue"]
212
+ ];
213
+ /**
214
+ * Says which of success, warning and info the theme has no color for, and where the CSS that adds
215
+ * them is: without one, an agent asked for a green "Active" label tends to reach for a chart or a
216
+ * gray token, which loses what the color says. Nothing when the theme has no colors, or all three.
217
+ */
218
+ function statusNote(ds) {
219
+ if (!ds.tokens.some((token) => token.category === "color")) return void 0;
220
+ const has = statusKinds(ds.tokens);
221
+ const missing = RECIPE.filter(([kind]) => !has.has(kind));
222
+ if (!missing.length) return void 0;
223
+ return `The theme has no ${orList(missing.map(([kind]) => kind))} token, so a ${orList(missing.map(([, hue]) => hue))} status color has no token to use, and agents tend to reach for a chart or gray token instead. To add ${missing.length === 1 ? "it" : "them"}: https://onsystem.vercel.app/docs/tokens#status-colors.`;
224
+ }
225
+ /** `success, warning or info`. */
226
+ function orList(items) {
227
+ return items.length > 1 ? `${items.slice(0, -1).join(", ")} or ${items.at(-1) ?? ""}` : items[0] ?? "";
228
+ }
199
229
  /** The instruction files agents read, which `init` offers to add onsystem's section to. */
200
230
  const AGENT_FILES = ["AGENTS.md", "CLAUDE.md"];
201
231
  const RULES_BEGIN = "<!-- BEGIN:onsystem-agent-rules -->";
@@ -906,17 +936,20 @@ async function check(patterns, configOptions, values, io) {
906
936
  };
907
937
  const disabledByKey = /* @__PURE__ */ new Map();
908
938
  const realRoot = fs.realpathSync.native(selection.root);
909
- const baselineKey = (file) => relativePath(realRoot, fs.realpathSync.native(file)).normalize("NFC");
939
+ const folders = patterns.map((pattern) => path.resolve(io.cwd, pattern)).filter((dir) => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()).map(realPath);
940
+ const changedLines = baseline && !update ? readChangedLines(io.env ?? process.env, io.cwd) : void 0;
910
941
  const results = [];
911
942
  const checked = /* @__PURE__ */ new Map();
912
943
  const ran = /* @__PURE__ */ new Set();
913
944
  let baselined = 0;
914
945
  let fixed = 0;
946
+ const unrecorded = [];
915
947
  for (const file of files) {
916
948
  const code = await fsp.readFile(file, "utf8");
917
949
  const ds = selection.designSystemOf(file);
918
950
  let result = ds.check(code, relativePath(ds.root, file));
919
- const key = baselineKey(file);
951
+ const real = fs.realpathSync.native(file);
952
+ const key = relativePath(realRoot, real).normalize("NFC");
920
953
  const disabled = disabledFor(file);
921
954
  disabledByKey.set(key, disabled);
922
955
  if (update || prune) checked.set(key, result.diagnostics);
@@ -924,28 +957,59 @@ async function check(patterns, configOptions, values, io) {
924
957
  for (const rule of RULE_IDS) if (!disabled.has(rule) && canRun(ds, rule)) ran.add(rule);
925
958
  }
926
959
  if (baseline && !update) {
927
- const match = applyBaseline(baseline.entries, key, result, disabled);
928
- result = {
929
- ...match.result,
930
- baselined: match.baselined
931
- };
932
- baselined += match.baselined;
933
- fixed += match.fixed;
960
+ const changed = changedLines?.get(real);
961
+ if (baseline.entries.has(key)) {
962
+ const match = applyBaseline(baseline.entries, key, result, disabled, changed);
963
+ result = {
964
+ ...match.result,
965
+ baselined: match.baselined
966
+ };
967
+ baselined += match.baselined;
968
+ fixed += match.fixed;
969
+ } else unrecorded.push({
970
+ index: results.length,
971
+ key,
972
+ result,
973
+ disabled,
974
+ changed
975
+ });
934
976
  }
935
977
  results.push({
936
978
  ...result,
937
979
  file: relativePath(io.cwd, file)
938
980
  });
939
981
  }
982
+ let moves = /* @__PURE__ */ new Map();
983
+ if (baseline && !update) {
984
+ const covered = (key) => folders.some((dir) => isInside(dir, path.join(realRoot, key)));
985
+ const matching = unrecorded.some((u) => u.result.diagnostics.length);
986
+ const gone = goneFiles(baseline.entries, realRoot, new Set(disabledByKey.keys()), (key) => matching || covered(key));
987
+ const changedIn = new Map(unrecorded.map((u) => [u.key, u.changed]));
988
+ moves = findMoves(baseline.entries, new Map(unrecorded.map((u) => [u.key, u.result.diagnostics])), gone, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set(), (key, diagnostic) => onLines(changedIn.get(key), diagnostic));
989
+ for (const { index, key, result, disabled, changed } of unrecorded) {
990
+ const from = moves.get(key) ?? key;
991
+ const match = applyBaseline(baseline.entries, from, result, disabled, changed);
992
+ const file = results[index]?.file ?? result.file;
993
+ results[index] = {
994
+ ...match.result,
995
+ baselined: match.baselined,
996
+ file
997
+ };
998
+ baselined += match.baselined;
999
+ fixed += match.fixed;
1000
+ }
1001
+ const moved = new Set(moves.values());
1002
+ for (const key of gone) {
1003
+ const rules = baseline.entries.get(key);
1004
+ if (rules && !moved.has(key) && covered(key)) fixed += countBaseline(/* @__PURE__ */ new Map([[key, rules]])).findings;
1005
+ }
1006
+ }
940
1007
  const elsewhere = results.filter((r) => r.skipped).length;
941
1008
  if (elsewhere) notes.push(`${plural(elsewhere, "image or email file")} not checked`);
942
1009
  const unchecked = uncheckedNotice(results);
943
1010
  if (update) {
944
1011
  const next = updateBaseline(baseline?.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
945
- const wholeRoot = patterns.some((pattern) => {
946
- const dir = path.resolve(io.cwd, pattern);
947
- return Boolean(fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() && isInside(realPath(dir), realRoot));
948
- });
1012
+ const wholeRoot = folders.some((dir) => isInside(dir, realRoot));
949
1013
  writeBaseline(baselineFile, next, {
950
1014
  onsystem: VERSION,
951
1015
  rules: rulesRun(baseline, next, checked, ran, wholeRoot)
@@ -966,15 +1030,16 @@ async function check(patterns, configOptions, values, io) {
966
1030
  if (unrecorded.size) baselineNotes.push(unrecordedNote(unrecorded, fresh.length, baseline.onsystem, command("--update-baseline")));
967
1031
  }
968
1032
  if (prune && baseline) {
969
- const next = pruneBaseline(baseline.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set());
1033
+ const next = pruneBaseline(baseline.entries, checked, realRoot, (key) => disabledByKey.get(key) ?? /* @__PURE__ */ new Set(), moves);
970
1034
  const left = countBaseline(next);
971
1035
  const dropped = countBaseline(baseline.entries).findings - left.findings;
972
- if (dropped) writeBaseline(baselineFile, next, {
1036
+ if (dropped || moves.size) writeBaseline(baselineFile, next, {
973
1037
  onsystem: baseline.onsystem,
974
1038
  rules: baseline.rules
975
1039
  });
976
- baselineNotes.push(`Baseline: ${dropped ? `dropped ${count(dropped, "finding")} that no longer ${dropped === 1 ? "occurs" : "occur"}` : "nothing to drop"}; ${count(left.findings, "finding")} in ${count(left.files, "file")} → ${displayPath(io.cwd, baselineFile)}`);
977
- }
1040
+ const done = [moves.size ? `moved the findings of ${count(moves.size, "file")} to ${moves.size === 1 ? "its new path" : "their new paths"}` : "", dropped ? `dropped ${count(dropped, "finding")} that no longer ${dropped === 1 ? "occurs" : "occur"}` : ""].filter(Boolean);
1041
+ baselineNotes.push(`Baseline: ${done.join("; ") || "nothing to drop"}; ${count(left.findings, "finding")} in ${count(left.files, "file")} → ${displayPath(io.cwd, baselineFile)}`);
1042
+ } else if (moves.size) baselineNotes.push(movedNote(moves, command("--prune-baseline")));
978
1043
  const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
979
1044
  color: io.color,
980
1045
  uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
@@ -995,7 +1060,9 @@ async function check(patterns, configOptions, values, io) {
995
1060
  const warnings = results.reduce((n, r) => n + r.warningCount, 0);
996
1061
  if (errors > 0) return 1;
997
1062
  if (maxWarnings !== void 0 && warnings > maxWarnings) {
998
- if (format === "pretty") io.stderr(`Too many warnings (${warnings}, max ${maxWarnings}).`);
1063
+ const message = `Too many warnings (${warnings}, max ${maxWarnings}).`;
1064
+ if (format === "github") io.stdout(`::error title=${NAME}::${message}`);
1065
+ else io.stderr(message);
999
1066
  return 1;
1000
1067
  }
1001
1068
  return 0;
@@ -1647,6 +1714,40 @@ function canRun(ds, rule) {
1647
1714
  default: return ds.model.components.length > 0;
1648
1715
  }
1649
1716
  }
1717
+ /** Says which files the baseline takes for moved, and how to record their new paths. */
1718
+ function movedNote(moves, prune) {
1719
+ const pairs = [...moves].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1720
+ const [first] = pairs;
1721
+ if (pairs.length === 1 && first) return `Baseline: ${first[0]} has the findings recorded for ${first[1]}, which no longer exists, so they are accepted as moved. Run \`${prune}\` to record the new path.`;
1722
+ const list = listOf(pairs.map(([to, from]) => `${from} → ${to}`));
1723
+ return `Baseline: ${pairs.length} files have the findings recorded for files that no longer exist, so they are accepted as moved (${list}). Run \`${prune}\` to record the new paths.`;
1724
+ }
1725
+ /** The variable the GitHub Action names the pull request's changed lines in. */
1726
+ const CHANGED_LINES = "ONSYSTEM_CHANGED_LINES";
1727
+ /**
1728
+ * The lines a pull request changed, by the real path of each file: the JSON file that
1729
+ * ONSYSTEM_CHANGED_LINES names maps paths to 1-based ranges, `{ "/repo/app/page.tsx": [[4, 4]] }`.
1730
+ * The GitHub Action writes it. Undefined when the variable is not set.
1731
+ */
1732
+ function readChangedLines(env, cwd) {
1733
+ const file = env[CHANGED_LINES]?.trim();
1734
+ if (!file) return void 0;
1735
+ const fail = (why) => new ConfigError(`${CHANGED_LINES}: ${why}. The GitHub Action sets this variable to a file of the lines a pull request changed; unset it when you run check yourself.`);
1736
+ let parsed;
1737
+ try {
1738
+ parsed = JSON.parse(fs.readFileSync(path.resolve(cwd, file), "utf8"));
1739
+ } catch (error) {
1740
+ throw fail(`could not read ${file}: ${error.message}`);
1741
+ }
1742
+ const range = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => Number.isInteger(n));
1743
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw fail(`${file} must map files to line ranges`);
1744
+ const lines = /* @__PURE__ */ new Map();
1745
+ for (const [name, ranges] of Object.entries(parsed)) {
1746
+ if (!Array.isArray(ranges) || !ranges.every(range)) throw fail(`${file} › "${name}" must be a list of [start, end] lines`);
1747
+ lines.set(realPath(path.resolve(cwd, name)), ranges);
1748
+ }
1749
+ return lines;
1750
+ }
1650
1751
  /** Why findings of rules that did not run when the baseline was recorded are new to it. */
1651
1752
  function unrecordedNote(byRule, total, version, update) {
1652
1753
  const rules = [...byRule.keys()].sort();
@@ -281,6 +281,11 @@ interface LibrarySource {
281
281
  dir?: string | undefined;
282
282
  /** The installed version. */
283
283
  version?: string | undefined;
284
+ /**
285
+ * A design-system package installed without its component sources, read from its type
286
+ * declarations ({@link declarationsPackages}) rather than as a library onsystem knows.
287
+ */
288
+ designSystem?: true | undefined;
284
289
  }
285
290
  //#endregion
286
291
  //#region src/config.d.ts