Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ npx -y onsystem check . # the same check the agent and CI run
npx -y onsystem init # write the config, and offer a baseline
```

At a [monorepo root](https://onsystem.vercel.app/docs/configuration#monorepo-roots), `inspect` lists every app and design-system package, and `check` checks each file by its own project's design system. In a [design-system package](https://onsystem.vercel.app/docs/design-system-packages), `init` sets it up for every app that depends on it, with no config in the apps: as a workspace package, or installed from npm when it publishes its component sources (`init` checks its `files` list). From a package that publishes only compiled code, installed apps get the token rules but no component, prop, variant or native-element checks.
At a [monorepo root](https://onsystem.vercel.app/docs/configuration#monorepo-roots), `inspect` lists every app and design-system package, and `check` checks each file by its own project's design system. In a [design-system package](https://onsystem.vercel.app/docs/design-system-packages), `init` sets it up for every app that depends on it, with no config in the apps: as a workspace package, or installed from npm. Installed apps read the components from the sources the package publishes, or from its type declarations (`.d.ts`) when it publishes only compiled `dist/`, and get every rule either way (`init` checks its `files` list). From a package that publishes compiled code and no type declarations, they get the token rules but no component, prop, variant or native-element checks.

In a new create-next-app, the template page's hardcoded colors already make the first `check .` fail; `init` offers to record them as a baseline, so only new findings fail. Folders installed from a shadcn registry, such as `components/ai-elements`, are someone else's code: [Code from shadcn registries](https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries) says how to check them or leave them out.

Expand Down Expand Up @@ -136,6 +136,21 @@ steps:

That count is onsystem's own, and the MCP server and the hook show the agent the same errors to fix. In tasks that ask for a green status label in a design system with no success token, agents with onsystem often used `chart-*` tokens, which it counts as clean. The blind labels by an AI reviewer, 12 files per arm, are too few to confirm the count either way, and every violation they found with the hook or all three is a palette color in Dub, whose tokens 0.5.0 did not read. The [benchmark page](https://onsystem.vercel.app/benchmark) has every table, and [bench/v2](bench/v2) the method, the per-run data and every generated file.

## Compatibility

What `package.json` declares and CI runs:

| With | Supported |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Node.js | Node.js 20.19 or later (`engines`). CI runs the tests on Node.js 22, 24 and 26, and the built package on 20.19 |
| Operating system | Linux, macOS and Windows: CI runs the tests and the pack test on each |
| ESLint | ESLint 8.57, 9 and 10, for the ESLint plugin only: `eslint` is an optional peer, and the CLI, the MCP server, the hook and the Action do not need it |
| TypeScript | Your project's TypeScript is not used, and a JavaScript project needs none: onsystem reads `.tsx` and `.jsx` with its own TypeScript 6.0, a dependency, not a peer |
| React | Components written for React 18 (`forwardRef`) or React 19 (`ref` as a prop), read from source or type declarations. onsystem does not depend on React |
| Tailwind CSS | v4 (`@theme`) and v3 (`tailwind.config`, read without running it). Without Tailwind, tokens come from CSS custom properties, DTCG files or a DESIGN.md |
| shadcn/ui | Both bases, Radix (`asChild`) and Base UI (`render`), on Tailwind v3 or v4, with no config |
| MCP clients | Any client that starts a stdio server; CI connects on the 2025-11-25 and 2026-07-28 protocols. [Set up your agent](https://onsystem.vercel.app/docs/clients) has each client's config and support tier |

## Docs

On the [website](https://onsystem.vercel.app/docs), and as Markdown in [docs/](docs):
Expand Down
135 changes: 135 additions & 0 deletions test/compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import fs from 'node:fs';
import path from 'node:path';

import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';

// The README's Compatibility section says what package.json declares and what CI runs, and
// these tests read both: a new Node floor, Node version in CI, ESLint or TypeScript major or MCP
// protocol means an update to the README in the same pull request.

interface Step {
uses?: string;
with?: Record<string, string | number>;
}

interface Job {
'runs-on'?: string;
strategy?: { matrix?: Record<string, unknown[]> };
steps: Step[];
}

const root = path.resolve(import.meta.dirname, '..');
const read = (file: string) => fs.readFileSync(path.join(root, file), 'utf8');
const readme = read('README.md');
const pkg = JSON.parse(read('package.json')) as {
engines: { node: string };
dependencies: Record<string, string>;
peerDependencies: Record<string, string>;
devDependencies: Record<string, string>;
};
const jobs = (
parse(read('.github/workflows/ci.yml')) as {
jobs: Record<string, Job | undefined>;
}
).jobs;

/** The README's `## Compatibility` section, up to the next one, on one line. */
function section(): string {
const start = readme.indexOf('\n## Compatibility\n');
expect(start, 'a "## Compatibility" section in README.md').toBeGreaterThan(-1);
const end = readme.indexOf('\n## ', start + 1);
return readme.slice(start, end === -1 ? undefined : end).replace(/\s+/g, ' ');
}

/** `22, 24 and 26`. */
const andList = (items: string[]) =>
items.length < 2
? items.join('')
: `${items.slice(0, -1).join(', ')} and ${String(items.at(-1))}`;

/** The version a `>=x.y` or `>=x.y.z` range starts at, as the docs write it: `20.19`. */
function minimum(range: string): string {
const match = /^>=(\d+\.\d+)(?:\.(\d+))?$/.exec(range);
if (!match?.[1]) throw new Error(`expected a ">=x.y" range, got "${range}"`);
return match[2] && match[2] !== '0' ? `${match[1]}.${match[2]}` : match[1];
}

/** The major of a dependency spec: `10` for `^10.12.0`, `9` for `npm:eslint@9.39.5`. */
const major = (spec: string | undefined) => /(\d+)\.\d+/.exec(spec ?? '')?.[1];

/** The Node version a job's setup-node step (the first, or the one at `index`) installs. */
function nodeVersion(job: Job | undefined, index = 0): string | undefined {
const setup = job?.steps.filter((step) => step.uses?.startsWith('actions/setup-node@'))[index];
const version = setup?.with?.['node-version'];
return version === undefined ? undefined : String(version);
}

describe('the compatibility statement', () => {
it('names the Node.js floor package.json declares, wherever the README names one', () => {
const floor = minimum(pkg.engines.node);
expect(section()).toContain(`Node.js ${floor} or later`);
// The Quickstart names it too: every "Node.js x or later" in the README is the same floor.
const named = [...readme.replace(/\s+/g, ' ').matchAll(/Node\.js ([\d.]+) or later/g)].map(
(m) => m[1],
);
expect(named.length).toBeGreaterThan(1);
for (const version of named) expect(version).toBe(floor);
});

it('names the Node.js versions CI runs: the test matrix, and the floor the package runs on', () => {
const floor = minimum(pkg.engines.node);
const matrix = (jobs.test?.strategy?.matrix?.node ?? []).map(String);
expect(matrix.length).toBeGreaterThan(0);
// The node20 job builds on a current Node, then runs what the package ships on the floor.
expect(nodeVersion(jobs.node20, 1)).toBe(floor);
expect(section()).toContain(
`CI runs the tests on Node.js ${andList(matrix)}, and the built package on ${floor}`,
);
});

it('names the systems CI runs the tests and the pack test on', () => {
const matrix = (job: string) => (jobs[job]?.strategy?.matrix?.os ?? []).map(String);
// The test job on Linux, the os job on the others, and the pack job on all three.
expect(jobs.test?.['runs-on']).toBe('ubuntu-latest');
expect(matrix('os')).toEqual(expect.arrayContaining(['macos-latest', 'windows-latest']));
expect(matrix('pack')).toEqual(
expect.arrayContaining(['ubuntu-latest', 'macos-latest', 'windows-latest']),
);
expect(section()).toContain(
'Linux, macOS and Windows: CI runs the tests and the pack test on each',
);
});

it('names the ESLint versions the optional peer admits and the plugin is tested on', () => {
// test/eslint-peer.test.ts checks that the peer range admits each of these.
const tested = [
minimum(pkg.peerDependencies.eslint ?? ''),
major(pkg.devDependencies['eslint-v9']),
major(pkg.devDependencies.eslint),
].map(String);
expect(section()).toContain(`ESLint ${andList(tested)}`);
});

it('names the TypeScript onsystem reads code with: its own dependency, not a peer', () => {
const spec = pkg.dependencies.typescript;
expect(pkg.peerDependencies.typescript).toBeUndefined();
const version = /(\d+\.\d+)\.\d+/.exec(spec ?? '')?.[1];
expect(version, spec).toBeDefined();
expect(section()).toContain(`its own TypeScript ${String(version)}`);
});

it('names the MCP protocols pnpm smoke connects with, and links the clients page', () => {
// scripts/smoke.mjs connects with the v1 SDK's client, which asks for its latest protocol,
// then with the v2 client on the protocol it asserts.
const modern = /getNegotiatedProtocolVersion\(\), '(\d{4}-\d{2}-\d{2})'/.exec(
read('scripts/smoke.mjs'),
)?.[1];
expect(modern).toBeDefined();
expect(section()).toContain(`${LATEST_PROTOCOL_VERSION} and ${String(modern)} protocols`);
// The page, not one of its anchors: the client setup and support tiers live there.
expect(fs.existsSync(path.join(root, 'docs/clients.md'))).toBe(true);
expect(section()).toContain('](https://onsystem.vercel.app/docs/clients)');
});
});
12 changes: 9 additions & 3 deletions test/readme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,19 @@ describe('the README', () => {
);
});

it('says what apps that install a design-system package miss when it publishes only compiled code', () => {
it('says what apps that install a design-system package get from compiled code, with and without type declarations', () => {
const quickstart = section('## Quickstart').replace(/\s+/g, ' ');
// In the words init uses to tell the package's team (src/init.ts).
// Every rule runs from the `.d.ts` of a package set up with init
// (test/installed-declarations.test.ts).
expect(quickstart).toContain(
'or from its type declarations (`.d.ts`) when it publishes only compiled `dist/`, and get every rule either way',
);
// Without them, the token rules, in the words init uses to tell the package's team
// (src/init.ts).
const missed = 'no component, prop, variant or native-element checks';
expect(read('src/init.ts')).toContain(missed);
expect(quickstart).toContain(
`From a package that publishes only compiled code, installed apps get the token rules but ${missed}.`,
`From a package that publishes compiled code and no type declarations, they get the token rules but ${missed}.`,
);
});

Expand Down
Loading