From 7159b5bf65dc4d5a097f52db535ecb2eaeccc377 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 09:38:31 +0100 Subject: [PATCH 1/3] feat(config): make exclude add to the defaults, with ! to take one out A config's `exclude` replaced the default list, so `"exclude": ["components/signoff-ui/**"]`, the advice for a folder installed from a registry, left that folder out and started checking every test, story and .d.ts file. The list now adds to the defaults; a pattern that starts with `!` takes out the default it names, and one that names no default gets a warning. A design-system package's list adds to them too. --- .changeset/exclude-adds-to-defaults.md | 5 ++ docs/ci.md | 2 +- docs/configuration.md | 6 +- schema.json | 2 +- src/config.ts | 30 ++++++-- test/config.test.ts | 3 +- test/design-system-package.test.ts | 6 +- test/exclude-additive.test.ts | 102 +++++++++++++++++++++++++ test/exclude-scope.test.ts | 3 +- test/registry-code.test.ts | 10 ++- 10 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 .changeset/exclude-adds-to-defaults.md create mode 100644 test/exclude-additive.test.ts 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/docs/ci.md b/docs/ci.md index dce7eaab..a464a53d 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 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). 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..279079ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | @@ -127,6 +127,6 @@ Every component the folder's files export counts, helpers too: with all of signo `exclude` is not enough on its own: - A file named outright on the command line is checked whatever `exclude` says (`npx onsystem check components/signoff-ui/diff-review.tsx`). The Claude Code hook and `check_ui` leave it out, as `check .` does, so an agent that edits a registry file is not told to fix it. -- In an app's config, `exclude` replaces the default list rather than adding to it: with `"exclude": ["components/signoff-ui/**"]`, `check .` leaves the folder out and reports tests and stories from then on. Repeat the defaults ([`exclude`](#config-file)) to keep them out. +- `exclude` adds to the default list: with `"exclude": ["components/signoff-ui/**"]`, `check .` leaves the folder out, and tests and stories stay out ([`exclude`](#config-file)). After `npx shadcn add` from a registry you have not set apart yet, add its folder to the config, or `check` reports its files as your code. diff --git a/schema.json b/schema.json index 37b2da99..9071e9f4 100644 --- a/schema.json +++ b/schema.json @@ -22,7 +22,7 @@ ] }, "exclude": { - "description": "Globs for files to leave out (stories, tests), 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. Replaces the defaults; node_modules is skipped either way unless a pattern names it.", + "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`.", "type": "array", "items": { "type": "string" diff --git a/src/config.ts b/src/config.ts index e364a59c..ad9be567 100644 --- a/src/config.ts +++ b/src/config.ts @@ -137,7 +137,7 @@ const configShape = { .array(z.string()) .optional() .describe( - 'Globs for files to leave out (stories, tests), 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. Replaces the defaults; node_modules is skipped either way unless a pattern names it.', + '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`.', ), tokens: z .union([tokenSourceSchema, z.array(tokenSourceSchema)]) @@ -570,10 +570,27 @@ export async function loadConfigWith( ...FALLBACK_TOKENS.map((p) => ({ path: p, fallback: true })), ]; - const exclude = (config.exclude ?? DEFAULT_EXCLUDE) - .map(slashGlob) - // `./app/legacy/` is the folder `app/legacy`, also when a root config's pattern is rebased. - .map((e) => e.replace(/^(?:\.\/)+/, '').replace(/(.)\/+$/, '$1')) + // `exclude` adds to the defaults. A pattern that starts with `!` takes out the default it + // names (`!**/*.d.ts`), for a project that checks what the defaults leave out. + const tidy = (e: string) => + slashGlob(e) + // `./app/legacy/` is the folder `app/legacy`, also when a root config's pattern is rebased. + .replace(/^(?:\.\/)+/, '') + .replace(/(.)\/+$/, '$1'); + const listed = config.exclude ?? []; + const dropped = new Set(listed.filter((e) => e.startsWith('!')).map((e) => tidy(e.slice(1)))); + for (const pattern of dropped) { + if (DEFAULT_EXCLUDE.includes(pattern)) continue; + warnings.push( + `"!${pattern}" 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.`, + ); + } + const exclude = [ + ...new Set([ + ...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), + ...listed.filter((e) => !e.startsWith('!')).map(tidy), + ]), + ] .map(excludeFromBase) .filter((e): e is string => e !== undefined); @@ -589,7 +606,8 @@ export async function loadConfigWith( : DEFAULT_COMPONENTS ).map(slashGlob), // An app keeps the default excludes for its own files: a package's own `exclude` is about - // the package, and only adds to them. Finding the package's files uses its list as it is. + // the package, and only adds to them, whatever defaults it takes out. Finding the package's + // files uses its list as it is. exclude: declared && config.exclude ? [...new Set([...DEFAULT_EXCLUDE, ...exclude])] : exclude, ...(declared && config.exclude ? { designSystemExclude: exclude } : {}), tokens: tokenSources.map((t) => ({ ...t, path: slashGlob(t.path) })), diff --git a/test/config.test.ts b/test/config.test.ts index 7a43b28b..425f49d5 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -8,6 +8,7 @@ import { ConfigError, configJsonSchema, DEFAULT_COMPONENTS, + DEFAULT_EXCLUDE, loadConfig, nodeRunsTypeScript, rulesFor, @@ -298,7 +299,7 @@ describe('loadConfig', () => { components: ['components/ui/**/*.tsx'], tokens: [{ path: 'app/globals.css' }, { path: 'tokens/*.tokens.json', prefix: 'acme' }], docs: ['guides/*.mdx'], - exclude: ['**/*.stories.tsx', 'app/\\(marketing\\)/**'], + exclude: [...DEFAULT_EXCLUDE, '**/*.stories.tsx', 'app/\\(marketing\\)/**'], }); }); diff --git a/test/design-system-package.test.ts b/test/design-system-package.test.ts index bfcbd775..2b98d92b 100644 --- a/test/design-system-package.test.ts +++ b/test/design-system-package.test.ts @@ -246,6 +246,7 @@ describe('a config a design-system package declares', () => { '**/*.stories.tsx', ]); expect(config.designSystemExclude).toEqual([ + ...DEFAULT_EXCLUDE, '../../packages/kit/src/index.tsx', '**/*.stories.tsx', ]); @@ -488,8 +489,9 @@ describe('a config a design-system package declares', () => { const config = await loadConfig({ root: app }); const own = ['../../packages/kit/src/index.tsx', '../../packages/kit/src/legacy/**']; expect(config.exclude).toEqual([...DEFAULT_EXCLUDE, ...own]); - // The package's files are found with its own list, as the package finds them. - expect(config.designSystemExclude).toEqual(own); + // The package's files are found with its own list, which adds to the defaults, as the + // package finds them. + expect(config.designSystemExclude).toEqual([...DEFAULT_EXCLUDE, ...own]); expect((await run(['inspect', '--no-cache'], app)).stdout).toMatch( /Components \(3 \+ 0 parts\)\n {2}Button .*\n {2}Card .*\n {2}Input /, ); diff --git a/test/exclude-additive.test.ts b/test/exclude-additive.test.ts new file mode 100644 index 00000000..1b90f0f7 --- /dev/null +++ b/test/exclude-additive.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { main, type Io } from '../src/cli.js'; +import { DEFAULT_EXCLUDE, DEFAULT_TEST_EXCLUDE, loadConfig } from '../src/config.js'; +import { fixture } from './helpers.js'; + +// `exclude` adds to the defaults (node_modules, .d.ts files, tests and stories) instead of +// replacing them. Before, `"exclude": ["components/signoff-ui/**"]`, the advice for a folder +// installed from a registry, left that folder out and started checking every test and story. + +const json = (value: unknown) => JSON.stringify(value, null, 2); + +const BUTTON = + 'export function Button(props: React.ComponentProps<"button">) { return \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/registry-code.test.ts b/test/registry-code.test.ts index 969c738c..6fb4c1c6 100644 --- a/test/registry-code.test.ts +++ b/test/registry-code.test.ts @@ -221,11 +221,13 @@ describe('code from shadcn registries (docs/configuration.md)', () => { ); }); - it('with `exclude`, still checks a file named outright, and starts checking stories', async () => { + it('with `exclude`, still checks a file named outright, and keeps stories out', async () => { expect(configuration).toContain('`"exclude": ["components/signoff-ui/**"]`'); const root = app({ exclude: ['components/signoff-ui/**'] }); const all = await check(root, '.'); - expect(all.reported).toEqual(['app/page.stories.tsx', MESSAGE]); + // `exclude` adds to the defaults, which leave tests and stories out. + expect(all.reported).toEqual([MESSAGE]); + expect(all.text).toContain('1 test or story left out'); const named = await check(root, DIFF_REVIEW); expect([named.code, named.reported]).toEqual([1, [DIFF_REVIEW]]); // check_ui leaves it out, as `check .` and the hook do. @@ -234,7 +236,7 @@ describe('code from shadcn registries (docs/configuration.md)', () => { ); }); - it('lists the default `exclude` in the field table, which keeps tests and stories out again', async () => { + it('lists the default `exclude` in the field table, which a list adds to', async () => { const row = configuration.split('\n').find((line) => line.startsWith('| `exclude`')); const defaults = [ ...(row?.split('|')[2]?.split('. A list')[0] ?? '').matchAll(/`([^`]+)`/g), @@ -242,7 +244,7 @@ describe('code from shadcn registries (docs/configuration.md)', () => { expect([...defaults].sort()).toEqual( DEFAULT_EXCLUDE.filter((e) => e !== '**/node_modules/**').sort(), ); - expect(row).toContain('A list replaces these'); + expect(row).toContain('A list adds to these'); const root = app({ exclude: ['components/signoff-ui/**', ...defaults] }); const all = await check(root, '.'); expect(all.reported).toEqual([MESSAGE]); From 83a40a00510e051c793a92146e885b94e5be39b6 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 09:38:44 +0100 Subject: [PATCH 2/3] feat: read shadcn registry folders as read-only design systems Zero config took only the ui folder of components.json as the design system, so code `npx shadcn add` copied from other registries (components/ai-elements, components/signoff-ui) was checked as the app's own, and the next `shadcn add` could bring findings in files nobody edited. For each registry in the `registries` of components.json, the folder of that name under the components alias is now a design system of its own, after the others: check, the hook, ESLint and the Action skip its files, the app's code is checked against its components, agents find them (get_component says which registry each came from), none is suggested for a native element, and its lib/ helpers stay out of list_components and search_components. inspect names the folders on its detected line, and --explain says why each was read or not. A config that lists such a folder, as init writes it, reads it the same way. Not when the components come from a workspace package imported through its exports. --- .changeset/registry-aware-zero-config.md | 7 + docs/ci.md | 2 +- docs/configuration.md | 26 +- docs/troubleshooting.md | 2 +- src/design-system.ts | 82 ++++- src/detect.ts | 193 ++++++++++- src/explain.ts | 7 +- src/lint/rules/components.ts | 19 +- src/server/render.ts | 14 +- test/init-registry-folders.test.ts | 9 +- test/registry-aware.test.ts | 424 +++++++++++++++++++++++ test/registry-code.test.ts | 2 +- 12 files changed, 749 insertions(+), 38 deletions(-) create mode 100644 .changeset/registry-aware-zero-config.md create mode 100644 test/registry-aware.test.ts 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/docs/ci.md b/docs/ci.md index a464a53d..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), 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 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 279079ac..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. @@ -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 `