diff --git a/.changeset/exclude-adds-to-defaults.md b/.changeset/exclude-adds-to-defaults.md new file mode 100644 index 00000000..1a604abb --- /dev/null +++ b/.changeset/exclude-adds-to-defaults.md @@ -0,0 +1,5 @@ +--- +'onsystem': minor +--- + +`exclude` adds to the defaults instead of replacing them. A config whose `exclude` lists folders without repeating the defaults now also leaves out tests and stories (`*.test.tsx`, `*.stories.tsx`, `__tests__`, `__stories__` and `__mocks__` folders) and `.d.ts` files, which it checked before. To check one of them again, add the default with `!` in front (`"!**/*.{test,spec,stories}.{ts,tsx,js,jsx}"`), or pass `--include-tests` to check tests and stories for one run; a `!` pattern that names no default gets a warning. A design-system package's `exclude` adds to the defaults for finding its own components too. diff --git a/.changeset/registry-aware-zero-config.md b/.changeset/registry-aware-zero-config.md new file mode 100644 index 00000000..4b1612ef --- /dev/null +++ b/.changeset/registry-aware-zero-config.md @@ -0,0 +1,7 @@ +--- +'onsystem': minor +--- + +Zero config reads the folders `npx shadcn add` copies other registries' code into as read-only design systems, next to `components/ui`: for each registry in the `registries` of `components.json`, the folder of that name under the `components` alias (`@signoff-ui` → `components/signoff-ui`, `@ai-elements` → `components/ai-elements`). Before, that code was checked as the app's own: in VoltAgent's chatbot starter, which the corpus now checks, 11 of the 14 findings of `check .` were in `components/ai-elements`, and the next `shadcn add` could bring findings in registry files nobody edited, which failed `check` and the GitHub Action. Now `check`, the Claude Code hook, the ESLint plugin and the Action skip those files, as they skip `components/ui`; `search_components`, `get_component` and `list_components` find their components, and `get_component` says which registry one was installed from and that changes to it belong upstream; and the app's code is checked against them, so `` or `` is a finding, and so is an icon-only registry button without a name. None of them is suggested in place of a native element, and the helpers in a folder's `lib/` (signoff-ui's `Img` and icons) are left out of `list_components` and `search_components`. `inspect` names the folders on its `detected` line, and `inspect --explain` says why each was read or not. A folder is not read when the app's components come from a workspace package imported through its `exports`, and code a registry copies loose into `components/` is still checked as the app's. `init` names those folders on its "Found the design system" line instead of a note of its own. + +A config that lists such a folder in `components`, as `init` writes it, reads it the same way: its components are no longer suggested for native elements (signoff-ui's `Img` for an ``); map one in `elements` to have it suggested. A folder that `components.json` names in no `registries` entry is read as before. diff --git a/corpus/repos.json b/corpus/repos.json index 86a93f5d..a1843fcd 100644 --- a/corpus/repos.json +++ b/corpus/repos.json @@ -13,6 +13,19 @@ "sha": "c2f8235e1f3ea903ad8b7f61447c4f74164b5c58", "runs": [{ "name": "chatbot", "cwd": ".", "check": ["app", "components"] }] }, + { + "name": "voltagent", + "url": "https://github.com/VoltAgent/voltagent", + "sha": "72a46c76b4507e1f98b41d5050a92bd30587eb70", + "paths": ["examples/next-js-chatbot-starter-template"], + "runs": [ + { + "name": "voltagent-chatbot", + "cwd": "examples/next-js-chatbot-starter-template", + "check": ["."] + } + ] + }, { "name": "shadcn-ui", "url": "https://github.com/shadcn-ui/ui", diff --git a/corpus/snapshot.json b/corpus/snapshot.json index 1ac97524..0e2921ee 100644 --- a/corpus/snapshot.json +++ b/corpus/snapshot.json @@ -1,6 +1,6 @@ { "$comment": "Written by `node scripts/corpus.mjs --update`. Counts per run and rule; `labels` records which labelled findings were reported.", - "version": "0.6.0", + "version": "0.7.0", "runs": { "saas-starter": { "inspect": { @@ -70,6 +70,35 @@ } } }, + "voltagent-chatbot": { + "inspect": { + "detected": "components.json (ui: @/components/ui → components/ui); from shadcn registries, read-only: components/ai-elements (@ai-elements, 31 files)", + "files": "49 component files, 1 token file, 0 docs", + "components": 53, + "parts": 208, + "tokens": { + "color": 36, + "typography": 2, + "radius": 8, + "spacing": 1 + }, + "warnings": 0 + }, + "files": 3, + "flagged": 1, + "rules": { + "icon-button-accessible-name": { + "errors": 1, + "warnings": 0, + "fixes": 0 + }, + "no-hardcoded-color": { + "errors": 0, + "warnings": 3, + "fixes": 0 + } + } + }, "shadcn-website": { "inspect": { "detected": "components.json (ui: @/registry/new-york-v4/ui → registry/new-york-v4/ui)", diff --git a/docs/ci.md b/docs/ci.md index dce7eaab..aebf6487 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -2,7 +2,7 @@ The check that runs after each edit in Claude Code also gates every pull request, whoever or whatever wrote it. `onsystem check` runs the same rules as `check_ui` and exits 1 on errors (or on more than `--max-warnings` warnings). It skips the design system's own component files, the ones `components` matches: they implement the scale and the primitives the rules enforce, so a fresh shadcn/ui project's `p-[3px]` is not a finding. `--include-design-system`, or `"includeDesignSystem": true` in the config, lints them too. -In folders and globs, `check` also leaves out tests and stories (`*.test.tsx`, `*.spec.tsx`, `*.stories.tsx`, and whatever is in `__tests__`, `__stories__` and `__mocks__` folders; `--include-tests` checks them), or what the config's `exclude` lists in their place ([a list replaces them](configuration.md#config-file)), and what git ignores (through `git check-ignore`, so nested `.gitignore` files count); a file named outright is always checked. Only JS and TS files are (`.tsx`, `.jsx`, `.ts`, `.js` and their `.mts`, `.cts`, `.mjs` and `.cjs` forms): the CSS, Markdown and JSON that `check $(git diff --name-only)` names or `check "**/*"` matches are skipped, as `check_ui` refuses them. A file that renders an image or an email is not checked, since tokens and classes don't apply there: one that imports `next/og`, `@vercel/og`, `satori` or `@react-email/*`, a Next.js `opengraph-image`, `twitter-image`, `icon` or `apple-icon` route, or a file in an `og` folder. The summary line counts what was left out. Code installed from a shadcn registry is checked as your own until the config sets it apart: [code from shadcn registries](configuration.md#code-from-shadcn-registries). +In folders and globs, `check` also leaves out tests and stories (`*.test.tsx`, `*.spec.tsx`, `*.stories.tsx`, and whatever is in `__tests__`, `__stories__` and `__mocks__` folders; `--include-tests` checks them), what the config's `exclude` adds to them ([`exclude`](configuration.md#config-file)), and what git ignores (through `git check-ignore`, so nested `.gitignore` files count); a file named outright is always checked. Only JS and TS files are (`.tsx`, `.jsx`, `.ts`, `.js` and their `.mts`, `.cts`, `.mjs` and `.cjs` forms): the CSS, Markdown and JSON that `check $(git diff --name-only)` names or `check "**/*"` matches are skipped, as `check_ui` refuses them. A file that renders an image or an email is not checked, since tokens and classes don't apply there: one that imports `next/og`, `@vercel/og`, `satori` or `@react-email/*`, a Next.js `opengraph-image`, `twitter-image`, `icon` or `apple-icon` route, or a file in an `og` folder. The summary line counts what was left out. Code that `npx shadcn add` copied into the folder of a registry `components.json` names is skipped like `components/ui`; other registry code is checked as your own until the config sets it apart: [code from shadcn registries](configuration.md#code-from-shadcn-registries). A folder stands for the `.tsx` and `.jsx` files in it. A JavaScript app can write its JSX in `.js` files (Next.js's JavaScript template has `app/page.js`): `check` names those it left out and gives the command that checks them, such as `check . "**/*.js"`. A `.js` file over 512 KB or named `*.min.js` is taken for a bundle and not looked at. With the [GitHub Action](required-check.md), list the glob in `paths` without quotes, `paths: . **/*.js`: the action passes each path as it is, with no shell. diff --git a/docs/configuration.md b/docs/configuration.md index 76e00260..88629683 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,7 +6,7 @@ Most projects need no config: onsystem finds the design system the way your app Without a config file of its own, a project first takes on the config its design-system package declares in its `package.json`, if one of its dependencies does ([design-system packages](design-system-packages.md)). With a config file of its own, it reads none of the package's config, so copy the package's `components`, `importPath` and `tokens` into it ([what it loses otherwise](design-system-packages.md#in-the-apps)). Without a config file (or with one that leaves `components` unset), the server looks for the design system in this order, and `inspect` prints what it found on its `detected` line: -1. **`components.json`** (shadcn/ui): the `ui` alias, resolved through tsconfig `paths` (`@/registry/new-york-v4/ui`) or a workspace package's `exports` (`@workspace/ui/components` in shadcn's monorepo templates), and `tailwind.css` for tokens. +1. **`components.json`** (shadcn/ui): the `ui` alias, resolved through tsconfig `paths` (`@/registry/new-york-v4/ui`) or a workspace package's `exports` (`@workspace/ui/components` in shadcn's monorepo templates), and `tailwind.css` for tokens. The folders `npx shadcn add` fills from the other registries it names are read too, as read-only design systems ([code from shadcn registries](#code-from-shadcn-registries)). 2. **The root is a design-system package** (when it has no `components.json`): its `package.json` `exports` lead to three or more component files (`"./button": "./src/components/button.tsx"` or `"./components/*": "./src/components/*.tsx"`). An export that points at a barrel (`"./components/button": "./components/button/index.ts"`, as `@calcom/ui` has them) stands for the `.tsx` files the barrel re-exports, through `export *`, `export { … } from` and imported bindings exported again. An export built to `dist/` is read from its source: the `entry` of a tsup, tsdown or Vite config (read as text, never run), else the same path under `src/`, and `.` from `src/index.*`, so Dub's `@dub/ui` (`"import": "./dist/index.mjs"`) is read from `src/index.tsx` and what it re-exports. Exported stylesheets that exist are read as tokens, else `src/globals.css` and the like. 3. **A dependency named like a design system** (`@acme/ui`, `@acme/ui-kit`, `@acme/design-system`, `acme-ui`) that resolves to workspace sources, through a `node_modules` link or the workspace's package globs (pnpm, npm, Yarn and Bun). It is read the same way, or through its own `components.json`, and added to what `components.json` found: Cal.com's app has `@coss/ui` in its `components.json` and imports `@calcom/ui` too, and both are read. Without a `components.json`, the app's own `components/ui` is kept alongside it. A package without `exports` that apps import by path, as Documenso imports `@documenso/ui/primitives/button`, is read from the files the app's code imports, each suggested with the specifier it uses, with a stylesheet imported from it as the theme. 4. **A flat `src/` of components** that wrap a primitives library, the way React Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`). It takes all of: a dependency on `react-aria-components`, `react-aria`, Radix, Base UI, Headless UI or Ark UI; no app entry (`main.tsx`, `App.tsx`, `index.tsx`) or `app`, `pages` or `routes` folder in `src/`; and at least five PascalCase `.tsx` files at its top level, four in five of which import that library. An app's `src/` of pages and features does not qualify. A stylesheet such as `src/index.css` is read as the theme. @@ -43,7 +43,7 @@ The root stays one project, as before, when it has a design system of its own (a { "$schema": "https://unpkg.com/onsystem/schema.json", "components": ["src/components/**/*.tsx"], - "exclude": ["**/*.stories.tsx", "**/*.test.tsx"], + "exclude": ["src/legacy/**"], "tokens": ["src/styles/globals.css", { "path": "tokens/*.tokens.json", "prefix": "acme" }], "docs": ["docs/components/**/*.mdx"], "importPath": "@acme/ui", @@ -62,7 +62,7 @@ The root stays one project, as before, when it has a design system of its own (a | Field | Default | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `components` | [Detected](#zero-config), else `components/ui/**/*.{tsx,jsx}`, `src/components/ui/**/*.{tsx,jsx}` | -| `exclude` | `**/*.{test,spec,stories}.{ts,tsx,js,jsx}`, `**/__{tests,stories,mocks}__/**`, `**/*.d.ts`. A list replaces these, so repeat the ones to keep; `node_modules` is left out either way unless a pattern names it | +| `exclude` | `**/*.{test,spec,stories}.{ts,tsx,js,jsx}`, `**/__{tests,stories,mocks}__/**`, `**/*.d.ts`. A list adds to these, and `!` takes one out (`"!**/*.d.ts"`). `node_modules` is left out unless a pattern names it | | `tokens` | Detected, else `app/globals.css`, `src/app/globals.css`, `styles/globals.css`, `src/styles/globals.css`, `src/index.css`, `app/app.css`, `**/*.tokens.json`; and the stylesheets they import | | `docs` | `docs/components/**/*.{md,mdx}` and `.md`/`.mdx` files next to the components | | `importPath` | Inferred from package `exports` (`@acme/ui/button`), then `tsconfig` `paths` (`@/components/ui/button`). `@acme/ui/{path}` suggests each file by its path in its package | @@ -83,9 +83,20 @@ CLI flags override the file: `--root`, `--config`, `--components`, `--tokens`, ` ## Code from shadcn registries -`npx shadcn add` also installs items from other registries, often into a folder of their own: AI Elements into `components/ai-elements`, signoff-ui into `components/signoff-ui`. Zero config takes only the `ui` folder of `components.json` as the design system, so it checks that code as your own: a native `\n'; + +function app(exclude?: string[]): string { + return fixture({ + 'components/ui/button.tsx': BUTTON, + 'app/globals.css': TOKENS, + 'app/page.tsx': RED, + 'app/page.stories.tsx': RED, + 'app/__tests__/page.tsx': RED, + 'app/legacy/old.tsx': RED, + 'components/signoff-ui/diff-review.tsx': RED, + ...(exclude ? { 'onsystem.config.json': json({ exclude }) } : {}), + }); +} + +async function run(args: string[], cwd: string) { + const out: string[] = []; + const err: string[] = []; + const io: Io = { cwd, color: false, stdout: (t) => out.push(t), stderr: (t) => err.push(t) }; + const code = await main(args, io); + return { code, stdout: out.join('\n'), stderr: err.join('\n') }; +} + +async function checked(cwd: string, ...args: string[]): Promise { + const { stdout } = await run(['check', '.', ...args, '--format', 'json', '--no-cache'], cwd); + return (JSON.parse(stdout) as { file: string; diagnostics: unknown[] }[]) + .filter((r) => r.diagnostics.length) + .map((r) => r.file) + .sort(); +} + +describe('exclude adds to the defaults', () => { + it('keeps tests and stories out when a config leaves out a folder', async () => { + const root = app(['components/signoff-ui/**', 'app/legacy']); + expect((await loadConfig({ root })).exclude).toEqual([ + ...DEFAULT_EXCLUDE, + 'components/signoff-ui/**', + 'app/legacy', + ]); + expect(await checked(root)).toEqual(['app/page.tsx']); + const summary = (await run(['check', '.', '--no-cache'], root)).stdout.split('\n').at(-1); + expect(summary).toContain( + '(1 design-system file skipped; 2 tests and stories left out; 2 excluded files left out)', + ); + // --include-tests still checks them for one run. + expect(await checked(root, '--include-tests')).toEqual([ + 'app/__tests__/page.tsx', + 'app/page.stories.tsx', + 'app/page.tsx', + ]); + }); + + it('repeats nothing for a config that lists the defaults, as configs had to before', async () => { + const root = app([...DEFAULT_EXCLUDE, 'app/legacy']); + expect((await loadConfig({ root })).exclude).toEqual([...DEFAULT_EXCLUDE, 'app/legacy']); + }); + + it('takes out a default that a pattern starting with "!" names', async () => { + const root = app([`!${DEFAULT_TEST_EXCLUDE}`, 'components/signoff-ui']); + const config = await loadConfig({ root }); + expect(config.exclude).toEqual([ + ...DEFAULT_EXCLUDE.filter((e) => e !== DEFAULT_TEST_EXCLUDE), + 'components/signoff-ui', + ]); + expect(config.warnings).toBeUndefined(); + // Stories are checked; the __tests__ folder, another default, stays out. + expect(await checked(root)).toEqual([ + 'app/legacy/old.tsx', + 'app/page.stories.tsx', + 'app/page.tsx', + ]); + }); + + it('warns about a "!" pattern that names no default', async () => { + const root = app(['!app/legacy']); + const config = await loadConfig({ root }); + expect(config.exclude).toEqual(DEFAULT_EXCLUDE); + expect(config.warnings).toEqual([ + `"!app/legacy" in "exclude" takes out none of the defaults, which are ${DEFAULT_EXCLUDE.map((d) => `"${d}"`).join(', ')}: a pattern that starts with "!" names one of them.`, + ]); + }); + + it('leaves out the defaults with an empty list, as with none', async () => { + expect((await loadConfig({ root: app([]) })).exclude).toEqual(DEFAULT_EXCLUDE); + expect((await loadConfig({ root: app() })).exclude).toEqual(DEFAULT_EXCLUDE); + }); +}); diff --git a/test/exclude-scope.test.ts b/test/exclude-scope.test.ts index 36fb79e8..85128c17 100644 --- a/test/exclude-scope.test.ts +++ b/test/exclude-scope.test.ts @@ -125,7 +125,8 @@ describe('coveredByGlob', () => { }); describe('exclude: the same files left out everywhere', () => { - const kept = ['app/legacy-v2/page.tsx', 'app/page.stories.tsx', 'app/page.tsx']; + // `exclude` adds to the defaults: the story stays out too. + const kept = ['app/legacy-v2/page.tsx', 'app/page.tsx']; const patterns = [ 'app/legacy', 'app/legacy/', diff --git a/test/init-registry-folders.test.ts b/test/init-registry-folders.test.ts index f2b98fe7..3de0bd1c 100644 --- a/test/init-registry-folders.test.ts +++ b/test/init-registry-folders.test.ts @@ -119,22 +119,23 @@ describe('init writes the folders shadcn add fills from other registries', () => expect(init.code).toBe(0); expect(written(root)).toEqual({ $schema: 'https://unpkg.com/onsystem/schema.json', + // Zero config found the folders that hold code, read-only; init adds the one to come. components: [ 'components/ui/**/*.{tsx,jsx}', 'components/ai-elements/**/*.{tsx,jsx}', - 'components/react-bits/**/*.{tsx,jsx}', 'components/signoff-ui/**/*.{tsx,jsx}', + 'components/react-bits/**/*.{tsx,jsx}', ], tokens: ['app/globals.css'], }); expect(init.stdout).toContain( - `Added components/ai-elements (@ai-elements, 1 file) and components/signoff-ui (@signoff-ui, 1 file) to "components": they hold the code \`shadcn add\` copied from those registries in components.json. \`check\` and the hook skip it as design-system code, and your code is checked against every component its files export, helpers included, so a native element such as can be told to use one of them. To leave the helpers out, see ${LINK}; to check that code as your own, take the folders out of "components".`, + 'Found the design system through components.json (ui: @/components/ui → components/ui); from shadcn registries, read-only: components/ai-elements (@ai-elements, 1 file), components/signoff-ui (@signoff-ui, 1 file).', ); // No components/react-bits yet: a registry named before its first `shadcn add` gets its // folder too. React Bits puts its files loose in components/, which the config cannot tell // apart, so the line says that code is checked as the app's. expect(init.stdout).toContain( - `Also added components/react-bits (@react-bits) to "components", though it does not exist yet: \`check\` and the hook skip the code \`shadcn add\` copies there from that registry in components.json. Code a registry copies somewhere else, such as loose into components/, is checked as your own (${LINK}).`, + `Added components/react-bits (@react-bits) to "components", though it does not exist yet: \`check\` and the hook skip the code \`shadcn add\` copies there from that registry in components.json. Code a registry copies somewhere else, such as loose into components/, is checked as your own (${LINK}).`, ); // The dry run and the baseline hold the app's findings only, and React Bits' loose file. expect(init.stdout).toContain('Baseline: 5 findings in 2 files'); @@ -183,8 +184,8 @@ describe('init writes the folders shadcn add fills from other registries', () => expect(written(root).components).toEqual([ 'src/components/ui/**/*.{tsx,jsx}', 'src/components/ai-elements/**/*.{tsx,jsx}', - 'src/components/react-bits/**/*.{tsx,jsx}', 'src/components/signoff-ui/**/*.{tsx,jsx}', + 'src/components/react-bits/**/*.{tsx,jsx}', ]); }); diff --git a/test/registry-aware.test.ts b/test/registry-aware.test.ts new file mode 100644 index 00000000..836740e0 --- /dev/null +++ b/test/registry-aware.test.ts @@ -0,0 +1,424 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ESLint, type Linter } from 'eslint'; +import { build } from 'tsdown'; +import tseslint from 'typescript-eslint'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { main, type Io } from '../src/cli.js'; +import { loadConfig } from '../src/config.js'; +import { createServer } from '../src/server/index.js'; +import { fixture, load, TSCONFIG } from './helpers.js'; + +// Zero config in a shadcn/ui app with code from other registries: the folders `npx shadcn add` +// copies @signoff-ui and @ai-elements into, as components.json's `registries` names them, are +// read-only design systems next to components/ui. `check`, the hook and ESLint skip their files, +// the app's code is checked against their components, agents find them, and none of them is +// suggested for a native element. Before, the first `check .` reported that code as the app's, +// and the next `shadcn add` brought new findings in files nobody edited. + +const json = (value: unknown) => JSON.stringify(value, null, 2); + +const stock = fs.readFileSync( + path.join(import.meta.dirname, 'fixtures/shadcn-neutral/globals.css'), + 'utf8', +); + +const DIFF_REVIEW = 'components/signoff-ui/diff-review.tsx'; +const PRIMITIVES = 'components/signoff-ui/lib/primitives.tsx'; +const MESSAGE = 'components/ai-elements/message.tsx'; +const DOCK = 'components/Dock.tsx'; +const PAGE = 'app/page.tsx'; +const INVENTED = 'app/invented/page.tsx'; + +const REGISTRIES = { + '@signoff-ui': 'https://signoff-ui.vercel.app/r/{name}.json', + '@ai-elements': { url: 'https://registry.ai-sdk.dev/{name}.json' }, + '@react-bits': 'https://reactbits.dev/r/{name}-TS-TW.json', +}; + +/** + * The app as `npx shadcn add` leaves it: signoff-ui and AI Elements in folders of their own, + * with a native