From e90a6df7c2b157666ac5ed673b91c8b28f62e5e2 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Wed, 21 Oct 2026 22:34:09 +0100 Subject: [PATCH] docs(readme): add a compatibility section, and say installed apps read a dist-only package's type declarations The Compatibility section names what package.json declares and CI runs: Node.js 20.19 or later (tests on 22, 24 and 26, the built package on 20.19), Linux, macOS and Windows, ESLint 8.57, 9 and 10, the TypeScript onsystem brings, React 18 and 19 components, Tailwind v3 and v4, both shadcn/ui bases, and the MCP protocols CI connects on, with a link to the clients page. test/compatibility.test.ts reads package.json, ci.yml and scripts/smoke.mjs, so the section cannot drift from them. Since 0.9.0 (#194), an app on a package published as compiled dist/ with type declarations gets every rule; the Quickstart still said such apps got the token rules alone. Only a package that publishes no type declarations gets those now. --- README.md | 17 ++++- test/compatibility.test.ts | 135 +++++++++++++++++++++++++++++++++++++ test/readme.test.ts | 12 +++- 3 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 test/compatibility.test.ts diff --git a/README.md b/README.md index a6b605b0..3c2149b4 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,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. @@ -134,6 +134,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): diff --git a/test/compatibility.test.ts b/test/compatibility.test.ts new file mode 100644 index 00000000..543fda2b --- /dev/null +++ b/test/compatibility.test.ts @@ -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; +} + +interface Job { + 'runs-on'?: string; + strategy?: { matrix?: Record }; + 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; + peerDependencies: Record; + devDependencies: Record; +}; +const jobs = ( + parse(read('.github/workflows/ci.yml')) as { + jobs: Record; + } +).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)'); + }); +}); diff --git a/test/readme.test.ts b/test/readme.test.ts index 825c3e98..12905f21 100644 --- a/test/readme.test.ts +++ b/test/readme.test.ts @@ -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}.`, ); });