From 3798fb7268410ca455aee0333867ef2f67a39373 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Wed, 21 Oct 2026 22:21:48 +0100 Subject: [PATCH 1/5] feat(config): an app's config builds on its design-system package's, and so does the root's An app with an onsystem.config.json of its own read none of the config its design-system package declares. A config holding one rule severity, {"rules":{"no-hardcoded-color":"warn"}}, in an app that installs such a package lost the package's components and color tokens: check said "No design system found" and exited 0. A monorepo root's rules and overrides did not reach any app whose package declares a config, without a word. - loadConfig reads the package's config under the project's own: what the project's config leaves out comes from the package's (components with their importPath, docs and elements; tokens; libraries), and its rules apply over the package's, rule by rule. "extends": false reads a config alone, as before. - A workspace root's config, from the root, builds on the package's the same way (src/workspace.ts passes the root config with the lookup). - inspect and inspect --explain name both configs ("onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)"). - init --force in an app on such a package writes a config with only $schema, which keeps the package's design system. init at a monorepo root says its rules apply over the package's config. - init in an app whose design system zero config reads from an installed package leaves components and tokens to zero config. It wrote paths into node_modules/.pnpm/@_/..., which broke with "Token file not found" at the next upgrade. - init in a package that publishes dist/ without its sources says apps read the components from the type declarations it publishes, when it names some; it said they get no component checks. - The docs say what apps get from a package published with type declarations, and how app and root configs build on the package's. --- .changeset/layered-configs.md | 13 + docs/configuration.md | 31 +- docs/design-system-packages.md | 55 +-- docs/plugin.md | 2 +- schema.json | 6 +- src/cli.ts | 19 +- src/config.ts | 283 +++++++------ src/explain.ts | 22 +- src/init.ts | 156 +++++--- src/workspace.ts | 11 +- test/design-system-package.test.ts | 46 ++- test/design-system-packages-docs.test.ts | 229 ++++++++--- test/dist-only-package.test.ts | 112 ++++-- test/init-registry-apps.test.ts | 38 +- test/layered-config.test.ts | 483 +++++++++++++++++++++++ 15 files changed, 1144 insertions(+), 362 deletions(-) create mode 100644 .changeset/layered-configs.md create mode 100644 test/layered-config.test.ts diff --git a/.changeset/layered-configs.md b/.changeset/layered-configs.md new file mode 100644 index 00000000..c683f12f --- /dev/null +++ b/.changeset/layered-configs.md @@ -0,0 +1,13 @@ +--- +'onsystem': minor +--- + +An app's own config builds on the config its design-system package declares, instead of replacing it. What the app's config leaves out comes from the package's: `components`, with the package's `importPath`, `docs` and `elements` unless the app's config sets `components` of its own; `tokens`; and `libraries`. Its `rules` apply over the package's, rule by rule, and its `overrides`, `exclude`, `tsconfig` and `includeDesignSystem` are its own. Before, an app config that only turned one rule to `"warn"` lost the package's components and color tokens: in an app that installs the package, `check` said `No design system found` and passed. `inspect` names both configs (`config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)`). `"extends": false` in the app's config reads it alone, as before. This can change what `check` reports in an app with a config of its own whose dependency declares one: it now gets the package's components, tokens and rules where its config leaves them out. A workspace package whose `"onsystem"` field cannot be read is a warning in such an app, which then reads its own config alone, and still an error in an app without one. + +A monorepo root's config builds on a project's design-system package config the same way in `check` and `inspect` from the root, so its `rules` and `overrides` apply to every project without a config of its own. Before, the config a project's design-system package declared took their place, without a word. From the project's folder, the root's config is still not read. + +`init --force` in an app on such a package writes a config with only `$schema`, for the app's own rules and overrides, and keeps the package's components, `importPath`, tokens and rules. Before, it wrote the package's paths into the app's config, or a config that lost the package's components. + +`init` in an app whose design system zero config reads from an installed package leaves `components` and `tokens` to zero config. It wrote paths into the package's folder, which pnpm names by version (`node_modules/.pnpm/@acme+ui@1.0.0_react@19.0.0/node_modules/@acme/ui/…`), so after an upgrade `check` failed with `Token file not found`, and the component files it listed matched nothing. + +In a design-system package whose `files` leave out its component sources, `init` says that apps installing it read its components from the type declarations it publishes, when its `package.json` names some. It said they get no component checks, which holds only without type declarations. diff --git a/docs/configuration.md b/docs/configuration.md index 88629683..d7300593 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,7 +4,7 @@ Most projects need no config: onsystem finds the design system the way your app ## Zero config -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: +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)). A config file of its own builds on the package's: what it leaves out, such as `components`, `importPath` and `tokens`, comes from there, and its `rules` apply over the package's; `"extends": false` reads it alone ([an app's own config](design-system-packages.md#an-apps-own-config)). 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. 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. @@ -19,6 +19,8 @@ A candidate whose files cannot be found is skipped. Components found through `ex An app built on MUI has no components folder of its own: its design system is `@mui/material`, installed in `node_modules`. When the project's `package.json` depends on `@mui/material` and the package is installed, onsystem reads its components from the package's type declarations, the same files the app's own imports resolve to, with the TypeScript checker. `inspect` says so on its `library` line: `library MUI (@mui/material 9.4.0): 149 components from its type declarations`. This happens whatever the config says about `components`, and works for MUI 5 to 9. To turn it off, set `"libraries": false` in the config; a list (`"libraries": ["mui"]`) reads only the libraries it names. `inspect` then says the library is `left out by "libraries"`. +A design-system package installed with compiled code and type declarations, whose component sources the project does not read, is read the same way, from its `.d.ts` ([how it works](how-it-works.md)): one that declares its config ([design-system packages](design-system-packages.md#what-apps-get)); without one, one named like a design system in the app's own npm scope (`@acme/ui` for `@acme/web`); or the dependency the config names as its `importPath` (`"importPath": "@dub/ui"`). `inspect` lists it on its `library` line (`Design-system package (@acme/ui 1.0.0): 15 components from its type declarations`). `"libraries": false` turns that off too; a list of libraries leaves it on. + - **Components and props.** Every export with a capitalised name whose type takes props is a component: function and class components and MUI's `OverridableComponent`. Its props are those of each of its call signatures, so ``, with `ChevronLeft` from `@acme/ui`, is reported for having no accessible name. Without `importPath`, that button is not reported. +- **`importPath` does not turn the component rules on**, with no declarations to read. `init` sets it when apps import the package by one name, such as `@acme/ui`. With no component to compare against, imports from the package are not checked: `` from `@acme/ui` is not reported, although the package has no `Badge`. It still tells `icon-button-accessible-name` that an icon the package re-exports is an icon, so ``, with `ChevronLeft` from `@acme/ui`, is reported for having no accessible name. Without `importPath`, that button is not reported. - **The token rules check against the published tokens:** `no-hardcoded-color` when they hold colors, `no-hardcoded-spacing` and `no-hardcoded-radius` when they set a spacing scale and radii. - **`--require-design-system` fails** (exit code 2), and it is on by default in the [GitHub Action](required-check.md). -The CLI, the MCP server, the ESLint plugin, the hook and the Action read the package the same way, so this holds in each. To give these apps every rule, add `src/` to `files` in `package.json`; `init` says when `files` leaves out the component or token files. +The CLI, the MCP server, the ESLint plugin, the hook and the Action read the package the same way, so this holds in each. To give these apps every rule, publish the type declarations with `dist/`, or add `src/` to `files` in `package.json`; `init` says when `files` leaves out the component or token files, and whether apps then read the components from type declarations. ### Without the config @@ -90,7 +101,7 @@ An app reads an installed package that declares no config, or whose declared con - **It is read where it really is.** With pnpm, that is its folder in `node_modules/.pnpm`, where pnpm also installs the packages it depends on, so the props its components take from them are known. When pnpm's store is outside the project, the package is read through the app's `node_modules`; there, the props its components take from their own dependencies are not known. - **Its exported stylesheets are the tokens** (`"./globals.css": "./src/styles/globals.css"`), with the stylesheets of the package that they `@import`, but none in a build folder: `"./styles.css": "./dist/index.css"` is compiled, and brings no tokens. Only the first condition an app resolves counts, as for components: `"./x.css": { "style": "./dist/x.css", "default": "./src/x.css" }` gives apps the compiled `dist/x.css`, so `src/x.css` brings no tokens either. Other installed packages are not read: `@import "tailwindcss"` in them brings none of Tailwind's palette. An app whose config names the package's components but no `tokens` gets them through its own stylesheet, whose `@import "@acme/ui/globals.css"` is followed into `node_modules`. -- **A package named otherwise, or one whose `exports` point at compiled code, needs the config,** as does anything beyond components and tokens: the rules' severities, `elements`, `importPath` and `libraries`. +- **A package named otherwise, or one whose `exports` point at compiled code, needs the config,** or type declarations that are read without it (in the app's own npm scope, or named by its `importPath`), as does anything beyond components and tokens: the rules' severities, `elements`, `importPath` and `libraries`. ## On real code diff --git a/docs/plugin.md b/docs/plugin.md index 74052190..b6a2044a 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -29,7 +29,7 @@ The plugin is the fastest way to keep Claude on your design system. It bundles t - **Errors come back to be fixed.** The hook runs after the file is written. It exits with Claude Code's blocking-error code, and the findings, each with its fix, go to Claude, which corrects the file before moving on. - **Warnings don't.** They reach Claude as a note, without blocking. - **Only what Claude just changed.** After an Edit it lists the findings on the lines the edit changed, not the unchanged lines it repeats around them, and only counts older ones. When it can't tell which lines those are (the new text is in the file more than once, or a formatter rewrote it), it passes the findings on as a note and doesn't block. It honours a [baseline](ci.md#adopting-it-in-an-existing-codebase). -- **Quiet where there is nothing to check.** It stays silent in projects without design-system components (tokens alone don't count) or a config, so installing the plugin for every project is safe. A config that a dependency declares counts: in an app on a [design-system package](design-system-packages.md) that publishes only compiled code, the hook checks the tokens, and their errors block. +- **Quiet where there is nothing to check.** It stays silent in projects without design-system components (tokens alone don't count) or a config, so installing the plugin for every project is safe. A config that a dependency declares counts: in an app on a [design-system package](design-system-packages.md#what-apps-get) that publishes only compiled code, the hook checks the components it reads from the package's type declarations, and the tokens, and their errors block; when the package publishes no type declarations, it checks no component of the package, only the tokens and native elements. - **The design system's own files are left alone.** Like `check`, it skips them: an edit to `components/ui/button.tsx` changes the design system, which is a call for you and your reviewers rather than a lint error. - **So is what `check .` leaves out.** A file the config's `exclude` covers, tests and stories by default (`*.test.tsx`, `*.stories.tsx`, `__tests__` folders) or a folder you list such as `app/legacy`, is not checked: the hook runs `check --skip-excluded`, which leaves such a file out even though it is named. Add a folder to `exclude` to keep the hook off code you don't hold to the design system. A project's own install from before that flag checks the file, as it did before. - **It says when it can't check.** When the CLI can't run (npx can't fetch it because of a [min-release-age policy](troubleshooting.md#a-min-release-age-policy-blocks-npx), a private registry or no network, or the config is broken), it says so once per session and project, as a message to you, and never blocks the edit. When npm could not reach the registry, or npx didn't finish within the hook's 55 seconds, the hook doesn't start npx again for ten minutes, so an offline session waits for npm once, not on every edit. diff --git a/schema.json b/schema.json index 9071e9f4..8f024a4e 100644 --- a/schema.json +++ b/schema.json @@ -7,6 +7,10 @@ "$schema": { "type": "string" }, + "extends": { + "description": "Whether this config builds on the one a design-system package declares (\"onsystem\" in the package.json of a dependency): what it leaves out comes from there (components with their importPath, docs and elements; tokens; libraries), and its rules apply over the package's. Default: true. false reads this config alone.", + "type": "boolean" + }, "components": { "description": "Globs for component source files, relative to the root.", "anyOf": [ @@ -103,7 +107,7 @@ "type": "string" }, "importPath": { - "description": "Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. Default: inferred from package exports and tsconfig paths.", + "description": "Package name components are imported from, e.g. \"@acme/ui\", or a pattern for packages imported by path: \"@acme/ui/{path}\" is @acme/ui/primitives/button for primitives/button.tsx in the package. An installed dependency it names exactly, whose component sources the project does not read, is read from its type declarations (.d.ts). Default: inferred from package exports and tsconfig paths.", "type": "string" }, "elements": { diff --git a/src/cli.ts b/src/cli.ts index 67825026..532369d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1124,7 +1124,7 @@ async function inspect( } const lines: string[] = [ `root ${model.root}`, - `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : '(defaults)'}`, + `config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config, (f) => displayPath(io.cwd, f))}` : '(defaults)'}`, ...(config.detected ? [`detected ${config.detected}`] : []), ...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`), `files ${plural(model.stats.files.components, 'component file')}, ${plural(model.stats.files.tokens, 'token file')}, ${plural(model.stats.files.docs, 'doc')}`, @@ -1296,8 +1296,9 @@ async function inspectWorkspace( const roots = ds.roots(); const tokens = [...tokenCounts(model)].map(([category, n]) => `${category} ${n}`); const own = - project.config.configFile && project.config.configFile !== workspace.configFile - ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}` + project.config.configFile && + (project.config.configFile !== workspace.configFile || project.config.designSystemPackage) + ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config, (f) => displayPath(workspace.root, f))}` : undefined; lines.push( '', @@ -1362,9 +1363,15 @@ function explainWorkspace( return lines; } -/** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */ -function declaredBy(config: ResolvedConfig): string { - return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ''; +/** + * ` (declared by @acme/ui)` after a config a design-system package declares, `, over + * packages/ui/onsystem.config.json (declared by @acme/ui)` after one read over it; empty otherwise. + */ +function declaredBy(config: ResolvedConfig, display: (file: string) => string): string { + const declared = config.designSystemPackage; + if (!declared) return ''; + const over = config.configFile === declared.config ? '' : `, over ${display(declared.config)}`; + return `${over} (declared by ${declared.name})`; } /** `A, B, C` up to a dozen names, then `and 40 more`. */ diff --git a/src/config.ts b/src/config.ts index 87f82651..5606bfa9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -130,6 +130,12 @@ const librariesSchema = z.union([z.literal(false), z.array(z.enum(LIBRARY_IDS))] const configShape = { $schema: z.string().optional(), + extends: z + .boolean() + .optional() + .describe( + 'Whether this config builds on the one a design-system package declares ("onsystem" in the package.json of a dependency): what it leaves out comes from there (components with their importPath, docs and elements; tokens; libraries), and its rules apply over the package\'s. Default: true. false reads this config alone.', + ), components: globsSchema .optional() .describe('Globs for component source files, relative to the root.'), @@ -151,7 +157,7 @@ const configShape = { .string() .optional() .describe( - 'Package name components are imported from, e.g. "@acme/ui", or a pattern for packages imported by path: "@acme/ui/{path}" is @acme/ui/primitives/button for primitives/button.tsx in the package. Default: inferred from package exports and tsconfig paths.', + 'Package name components are imported from, e.g. "@acme/ui", or a pattern for packages imported by path: "@acme/ui/{path}" is @acme/ui/primitives/button for primitives/button.tsx in the package. An installed dependency it names exactly, whose component sources the project does not read, is read from its type declarations (.d.ts). Default: inferred from package exports and tsconfig paths.', ), elements: z .record(z.string(), z.string()) @@ -268,8 +274,10 @@ export interface ResolvedConfig { */ base?: string | undefined; /** - * The design-system package whose config this is: a dependency whose package.json declares it - * (`"onsystem": "./onsystem.config.json"`), taken on by a project with no config of its own. + * The design-system package whose config this project reads: a dependency whose package.json + * declares it (`"onsystem": "./onsystem.config.json"`). A project with no config of its own + * takes it on (`configFile` is the package's); one with a config of its own, or a workspace + * root's, reads it under that one (`configFile` is that one), unless it sets `"extends": false`. * Its paths are rebased from the package; its `tsconfig`, `overrides` and * `includeDesignSystem` stay with it. */ @@ -406,9 +414,11 @@ export async function loadConfigWith( const finder = (dir: string) => packages?.root === dir ? packages : (packages = new PackageFinder(dir)); let lookup = context.lookup; + /** + * The project's own config: a config file in the folder searched, `--config`, or a workspace + * root's that a project in it takes on. + */ let configFile: string | undefined; - /** The design-system package whose config this project takes on, having none of its own. */ - let declared: DesignSystemPackage | undefined; if (options.config) { configFile = path.resolve(cwd, options.config); } else { @@ -418,11 +428,30 @@ export async function loadConfigWith( const legacy = find(LEGACY_CONFIG_FILES); if (legacy) deprecations.push(legacyConfigNotice(legacy, configFile)); configFile ??= legacy ?? ownDeclaredConfig(searchDir); - if (!configFile) { - lookup ??= findDesignSystemPackage(searchDir, finder(searchDir)); - if (lookup?.problems.length) throw declarationError(lookup, searchDir); + } + const config: ParsedConfig = configFile ? await readConfig(configFile, options) : {}; + // An explicit --config sets the root by its folder; one found by name is in the folder searched. + const root = options.root + ? path.resolve(cwd, options.root) + : options.config && configFile + ? path.dirname(configFile) + : searchDir; + if (!isDirectory(root)) throw new ConfigError(`Project root not found: ${root}`); + + // The design-system package a dependency declares a config for: a project with no config of + // its own takes it on, and one with a config of its own reads it under that one. + let declared: DesignSystemPackage | undefined; + if (config.extends !== false) { + lookup ??= findDesignSystemPackage(root, finder(root)); + if (!lookup?.problems.length) { declared = lookup?.found; - configFile = declared?.config; + } else if (!configFile) { + throw declarationError(lookup, root); + } else { + // The project's own config still works on its own; the package's team can fix theirs. + warnings.push( + `${lookup.problems.map((p) => `${p.name}: ${p.problem}`).join('\n')}\nSo ${configFile} is read alone, without the config a design-system package declares. Fix the "${PACKAGE_FIELD}" field in that package, or set "extends": false in ${path.basename(configFile)}.`, + ); } } // Installed packages whose declaration cannot be read: the project is read as if they had none. @@ -431,48 +460,30 @@ export async function loadConfigWith( `${name}'s onsystem config was skipped: ${problem} ${name} is installed in node_modules, so this project is read as if it declared none.`, ); } + const pkg: ParsedConfig | undefined = declared + ? await readConfig(declared.config, options) + : undefined; - let raw: unknown = {}; - if (configFile) { - if (!fs.existsSync(configFile)) throw new ConfigError(`Config file not found: ${configFile}`); - if (options.jsonConfigOnly && !configFile.endsWith('.json')) { - throw new ConfigError( - `${configFile} was not run: the server runs a JavaScript or TypeScript config only in a project it was started in (its working directory, --root or the client's roots), not in one found from the path a tool call names. Start the server in the project, or use onsystem.config.json.`, - ); - } - raw = await readConfigFile(configFile); - } - const parsed = configSchema.safeParse(raw); - if (!parsed.success) { - throw new ConfigError( - `Invalid config in ${configFile ?? '(defaults)'}:\n${z.prettifyError(parsed.error)}`, - ); - } - const config = parsed.data; - // An explicit --config sets the root by its folder; one found by name is in the folder searched. - const root = options.root - ? path.resolve(cwd, options.root) - : options.config && configFile - ? path.dirname(configFile) - : searchDir; - if (!isDirectory(root)) throw new ConfigError(`Project root not found: ${root}`); // A workspace root's config, taken on by a project in it: its paths are relative to the // workspace root. Files and token globs are rebased onto the project's folder; `exclude` // keeps the patterns that can match in it; `overrides` are matched from the workspace root. - // A design-system package's config, taken on by an app that depends on it: its paths are - // relative to the package, and `exclude` keeps every pattern, rebased onto the package. - const base = declared ? declared.dir : options.base ? path.resolve(cwd, options.base) : root; - const inherited = base !== root; - const fromBase = (p: string) => - inherited ? toPosix(path.relative(root, path.resolve(base, slashGlob(p)))) || '.' : p; + // A design-system package's config: its paths are relative to the package, and `exclude` + // keeps every pattern, rebased onto the package. + const base = options.base ? path.resolve(cwd, options.base) : root; + const rebase = + (from: string) => + (p: string): string => + from === root ? p : toPosix(path.relative(root, path.resolve(from, slashGlob(p)))) || '.'; + const fromBase = rebase(base); + const fromPackage = rebase(declared?.dir ?? root); const toProject = toPosix(path.relative(base, root)); const excludeFromBase = (e: string): string | undefined => { - if (!inherited || e.startsWith('**/')) return e; - if (declared) return fromBase(e); + if (base === root || e.startsWith('**/')) return e; // The project's own folder (`apps/web`) leaves out all of it, as `apps/web/**` does. if (e === toProject) return '**'; return e.startsWith(`${toProject}/`) ? e.slice(toProject.length + 1) : undefined; }; + const excludeFromPackage = (e: string) => (e.startsWith('**/') ? e : fromPackage(e)); // A dependency's config reads only within its package when installed, or its workspace: a // path that leaves it is ignored, so a published package cannot have its apps read files // elsewhere on the machine. @@ -482,46 +493,65 @@ export async function loadConfigWith( outside.push(`${field} ${p}`); return false; }; - const configComponents = - config.components === undefined + const pathOf = (t: TokenSource) => (typeof t === 'string' ? t : t.path); + const tokensFrom = + (move: (p: string) => string) => + (t: TokenSource): TokenSourceConfig => + typeof t === 'string' ? { path: move(t) } : { ...t, path: move(t.path) }; + const packageComponents = + pkg?.components === undefined ? undefined - : toArray(config.components).filter(within('components')); - const configTokens = - config.tokens === undefined + : toArray(pkg.components).filter(within('components')).map(fromPackage); + const packageTokens = + pkg?.tokens === undefined ? undefined - : toArray(config.tokens).filter((t) => within('tokens')(typeof t === 'string' ? t : t.path)); - const configDocs = - config.docs === undefined ? undefined : toArray(config.docs).filter(within('docs')); + : toArray(pkg.tokens) + .filter((t) => within('tokens')(pathOf(t))) + .map(tokensFrom(fromPackage)); + const packageDocs = + pkg?.docs === undefined ? undefined : toArray(pkg.docs).filter(within('docs')).map(fromPackage); if (declared && outside.length) { warnings.push( `${declared.name}'s onsystem config names paths outside ${declared.installed ? 'the package' : 'the workspace'}, which were ignored: ${outside.join(', ')}.`, ); } - + // The design system's components, and what goes with them (importPath, docs, elements), come + // from the package's config unless this one names components of its own. + const inherits = pkg !== undefined && config.components === undefined; + const configComponents = + config.components === undefined ? packageComponents : toArray(config.components).map(fromBase); + const configTokens = + config.tokens === undefined ? packageTokens : toArray(config.tokens).map(tokensFrom(fromBase)); + const configDocs = + config.docs === undefined + ? inherits + ? packageDocs + : undefined + : toArray(config.docs).map(fromBase); + const importPath = config.importPath ?? (inherits ? pkg.importPath : undefined); + const librariesSetting = config.libraries ?? pkg?.libraries; + + // The package's rules, then this config's over them. const rules = {} as Record; for (const id of RULE_IDS) { - const setting = config.rules?.[id]; + const setting = config.rules?.[id] ?? pkg?.rules?.[id]; rules[id] = setting === undefined ? { severity: DEFAULT_SEVERITY[id], options: {} } : resolveRule(setting); } // A package's `overrides` are about its own files, which an app does not check. - const overrides: ResolvedOverride[] = (declared ? [] : (config.overrides ?? [])).map( - (override) => ({ - files: toArray(override.files).map(slashGlob), - rules: Object.fromEntries( - Object.entries(override.rules).map(([id, setting]) => [id, resolveRule(setting)]), - ), - }), - ); + const overrides: ResolvedOverride[] = (config.overrides ?? []).map((override) => ({ + files: toArray(override.files).map(slashGlob), + rules: Object.fromEntries( + Object.entries(override.rules).map(([id, setting]) => [id, resolveRule(setting)]), + ), + })); // Paths may be written Windows-style; globs and POSIX need forward slashes. An app keeps its // own tsconfig over a design-system package's, which resolves the package's aliases, not its. const tsconfig = - config.tsconfig === undefined || declared - ? undefined - : fromBase(config.tsconfig.replaceAll('\\', '/')); + config.tsconfig === undefined ? undefined : fromBase(config.tsconfig.replaceAll('\\', '/')); if (tsconfig !== undefined && !fs.existsSync(path.resolve(root, tsconfig))) { // Without it, path aliases do not resolve and most checks go quiet. throw new ConfigError( @@ -530,7 +560,7 @@ export async function loadConfigWith( } // Without component globs, look for components.json or a design-system package. - const componentsSet = Boolean(options.components?.length) || config.components !== undefined; + const componentsSet = Boolean(options.components?.length) || configComponents !== undefined; const detection = componentsSet ? undefined : detectProject(root, tsconfig, undefined, declared, finder(root)); @@ -540,9 +570,9 @@ export async function loadConfigWith( // from a registry ships only some of its files. const explicitTokens = options.tokens?.length ? { paths: options.tokens, from: '--tokens' } - : config.tokens !== undefined && !declared + : config.tokens !== undefined ? { - paths: toArray(config.tokens).map((t) => fromBase(typeof t === 'string' ? t : t.path)), + paths: toArray(config.tokens).map((t) => fromBase(pathOf(t))), from: `"tokens" in ${configFile ?? 'the config'}`, } : undefined; @@ -556,33 +586,25 @@ export async function loadConfigWith( const components = ( options.components?.length ? options.components - : configComponents !== undefined - ? configComponents.map(fromBase) - : detection + : (configComponents ?? + (detection ? [...(detection.withDefaults ? DEFAULT_COMPONENTS : []), ...detection.components] - : DEFAULT_COMPONENTS + : DEFAULT_COMPONENTS)) ).map(slashGlob); // A design-system package whose sources these globs do not read is read from its declarations. - const libraries = detectLibraries(root, config.libraries, { - importPath: config.importPath, - components, - }); + const libraries = detectLibraries(root, librariesSetting, { importPath, components }); - const tokenSources = options.tokens?.length + const tokenSources: TokenSourceConfig[] = options.tokens?.length ? options.tokens.map((p) => ({ path: p })) - : configTokens !== undefined - ? configTokens.map((t) => - typeof t === 'string' ? { path: fromBase(t) } : { ...t, path: fromBase(t.path) }, - ) - : // Detected stylesheets replace the stylesheet guesses, not DTCG files. - [ - ...(detection?.tokens.length - ? [...detection.tokens, ...DEFAULT_TOKENS.filter((t) => t.endsWith('.json'))] - : DEFAULT_TOKENS - ).map((p) => ({ path: p })), - ...FALLBACK_TOKENS.map((p) => ({ path: p, fallback: true })), - ]; + : (configTokens ?? [ + // Detected stylesheets replace the stylesheet guesses, not DTCG files. + ...(detection?.tokens.length + ? [...detection.tokens, ...DEFAULT_TOKENS.filter((t) => t.endsWith('.json'))] + : DEFAULT_TOKENS + ).map((p) => ({ path: p })), + ...FALLBACK_TOKENS.map((p) => ({ path: p, fallback: true })), + ]); // `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. @@ -591,58 +613,60 @@ export async function loadConfigWith( // `./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); + const excludeOf = (listed: readonly string[], move: (e: string) => string | undefined) => { + 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.`, + ); + } + return [ + ...new Set([ + ...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), + ...listed.filter((e) => !e.startsWith('!')).map(tidy), + ]), + ] + .map(move) + .filter((e): e is string => e !== undefined); + }; + const exclude = excludeOf(config.exclude ?? [], excludeFromBase); + // A package's own list finds the package's files. The project's own files keep its defaults: + // the package's patterns only add to them, whatever defaults it takes out. + const packageExclude = pkg?.exclude ? excludeOf(pkg.exclude, excludeFromPackage) : undefined; return { root, - configFile, + configFile: configFile ?? declared?.config, components, - // An app keeps the default excludes for its own files: a package's own `exclude` is about - // 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 } : {}), + exclude: packageExclude + ? [...new Set([...exclude, ...packageExclude.filter((e) => !DEFAULT_EXCLUDE.includes(e))])] + : exclude, + ...(packageExclude && inherits ? { designSystemExclude: packageExclude } : {}), tokens: tokenSources.map((t) => ({ ...t, path: slashGlob(t.path) })), ...(!options.tokens?.length && configTokens === undefined ? { tokensDetected: true } : {}), docs: (options.docs?.length ? options.docs - : configDocs === undefined - ? [...new Set([...DEFAULT_DOCS, ...(detection?.docs ?? [])])] - : configDocs.map(fromBase) + : (configDocs ?? [...new Set([...DEFAULT_DOCS, ...(detection?.docs ?? [])])]) ).map(slashGlob), tsconfig, - importPath: config.importPath, + importPath, // Components a package's config names are imported by its exports, as detection reads them. imports: - detection?.imports ?? (declared && componentsSet ? packageImports(root, declared.dir) : []), + detection?.imports ?? + (declared && inherits && componentsSet ? packageImports(root, declared.dir) : []), detected: detection?.source, ...(detection?.designSystems ? { designSystems: detection.designSystems } : {}), tailwindConfig: findTailwindConfig(root), libraries: libraries.read, - libraryIds: config.libraries, + libraryIds: librariesSetting, librariesOff: libraries.off, librariesUnread: libraries.unread, - elements: config.elements ?? {}, - includeDesignSystem: - options.includeDesignSystem ?? (declared ? undefined : config.includeDesignSystem) ?? false, + elements: { ...(inherits ? pkg.elements : {}), ...config.elements }, + includeDesignSystem: options.includeDesignSystem ?? config.includeDesignSystem ?? false, rules, overrides, - ...(inherited && !declared ? { base } : {}), + ...(base !== root ? { base } : {}), ...(declared ? { designSystemPackage: declared } : {}), configured: componentsSet, ...(deprecations.length ? { deprecations } : {}), @@ -653,7 +677,7 @@ export async function loadConfigWith( /** * Whether `dir` has a config of its own: a config file under its current or old name, or one its * package.json names in its `onsystem` field. Without one, a project takes on its design-system - * package's config, else the workspace root's. + * package's config, and, from a workspace root, the root's over it. */ export function hasOwnConfig(dir: string): boolean { if ([...CONFIG_FILES, ...LEGACY_CONFIG_FILES].some((name) => fs.existsSync(path.join(dir, name)))) @@ -756,6 +780,27 @@ export function nodeRunsTypeScript(version: string = process.versions.node): boo return major > 23 || (major === 23 && minor >= 6) || (major === 22 && minor >= 18); } +type ParsedConfig = z.output; +type TokenSource = z.output; + +/** A config file, read and validated: a JSON one only with `jsonConfigOnly`. */ +async function readConfig( + file: string, + options: Pick, +): Promise { + if (!fs.existsSync(file)) throw new ConfigError(`Config file not found: ${file}`); + if (options.jsonConfigOnly && !file.endsWith('.json')) { + throw new ConfigError( + `${file} was not run: the server runs a JavaScript or TypeScript config only in a project it was started in (its working directory, --root or the client's roots), not in one found from the path a tool call names. Start the server in the project, or use onsystem.config.json.`, + ); + } + const parsed = configSchema.safeParse(await readConfigFile(file)); + if (!parsed.success) { + throw new ConfigError(`Invalid config in ${file}:\n${z.prettifyError(parsed.error)}`); + } + return parsed.data; +} + async function readConfigFile(file: string): Promise { if (/\.[cm]?ts$/.test(file) && !nodeRunsTypeScript()) { throw new ConfigError( diff --git a/src/explain.ts b/src/explain.ts index 83b445f1..d44f19e9 100644 --- a/src/explain.ts +++ b/src/explain.ts @@ -24,7 +24,7 @@ export function explainProject(ds: DesignSystem, cwd: string, indent = ''): stri } if (config.configured) { out( - ` components come from ${config.configFile ? display(cwd, config.configFile) : '--components'}, so zero config did not look`, + ` components come from ${config.configFile ? `${display(cwd, config.configFile)}${over(config, cwd)}` : '--components'}, so zero config did not look`, ); } else { const steps: DetectionStep[] = []; @@ -64,7 +64,7 @@ export function explainProject(ds: DesignSystem, cwd: string, indent = ''): stri field( 'config', config.configFile - ? `${display(cwd, config.configFile)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ''}` + ? `${display(cwd, config.configFile)}${over(config, cwd)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ''}` : '(none)', ); field('components', listOf(config.components) || '(none)'); @@ -219,18 +219,28 @@ export function nodeModulesFor(dir: string): string | undefined { } } +/** `, over packages/ui/onsystem.config.json` for a config read over a design-system package's. */ +function over(config: ResolvedConfig, cwd: string): string { + const declared = config.designSystemPackage; + return declared && config.configFile !== declared.config + ? `, over ${display(cwd, declared.config)}` + : ''; +} + function display(cwd: string, file: string): string { const rel = relativePath(cwd, file); return rel && !rel.startsWith('..') ? rel : file; } /** - * Whether a dependency's declared config was taken on, for a project without a config of its - * own: the package it came from and the others that declare one, or that none does. + * Whether a dependency's declared config was read, taken on by a project without a config of its + * own or under the one it has: the package it came from and the others that declare one, or that + * none does. */ function packageSteps(config: ResolvedConfig, cwd: string): DetectionStep[] { const declared = config.designSystemPackage; - // A config of its own (or --config) is read first; the dependencies are not looked at. + // A config of its own (or --config) read alone: no dependency declares one, or it sets + // `"extends": false`. if (config.configFile && !declared) return []; const lookup = findDesignSystemPackage(config.root); if (!lookup) return []; @@ -251,7 +261,7 @@ function packageSteps(config: ResolvedConfig, cwd: string): DetectionStep[] { { candidate, accepted: true, - reason: `${declared.name} (${declared.installed ? 'installed' : 'a workspace package'}) declares ${display(cwd, declared.config)}, which this project takes on, with its paths from ${display(cwd, declared.dir)}${lookup.others.length ? `; ${lookup.others.join(', ')} also declare one, not read: one config is taken on, from the package the code imports most` : ''}${skipped}`, + reason: `${declared.name} (${declared.installed ? 'installed' : 'a workspace package'}) declares ${display(cwd, declared.config)}, which this project ${config.configFile === declared.config ? 'takes on' : `reads under ${display(cwd, config.configFile ?? '')}`}, with its paths from ${display(cwd, declared.dir)}${lookup.others.length ? `; ${lookup.others.join(', ')} also declare one, not read: one config is taken on, from the package the code imports most` : ''}${skipped}`, }, ]; } diff --git a/src/init.ts b/src/init.ts index 15c56afe..0c5acc31 100644 --- a/src/init.ts +++ b/src/init.ts @@ -67,7 +67,8 @@ export const GUIDE_FILE = 'ONSYSTEM.md'; * (`"onsystem": "./onsystem.config.json"`, and in `files` when the package lists them), so * every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those * apps' teams how to add the agent tools and the CI check. In an app that takes on such a - * package's config, it writes no config of its own unless --force says to. + * package's config, it writes no config of its own unless --force says to, and then one that + * builds on the package's, for the app's own rules and overrides. * * In a terminal, it offers to add a short section on onsystem to the project's AGENTS.md or * CLAUDE.md, for coding agents. @@ -114,28 +115,35 @@ export async function init( const relative = (abs: string) => relativePath(root, abs); // An app on a design-system package that declares its config needs none of its own. const inherited = options.force ? undefined : config.designSystemPackage; - // With --force, the config written here is read instead of the package's. + // With --force, the config written here builds on the package's. const replaced = options.force ? config.designSystemPackage : undefined; // "components" holds what zero config found, not globs the user gave. let found = false; if (inherited) { notes.push( - `This project takes on the config ${inherited.name} declares in its package.json (${relativePath(io.cwd, inherited.config) || inherited.config}), so it needs none of its own; nothing was written. Pass --force to write one, which is read instead.`, + `This project takes on the config ${inherited.name} declares in its package.json (${relativePath(io.cwd, inherited.config) || inherited.config}), so it needs none of its own; nothing was written. Pass --force to write one for its own rules and overrides, which builds on ${inherited.name}'s.`, ); } else if (configOptions.components?.length) { file.components = configOptions.components; if (configOptions.tokens?.length) file.tokens = configOptions.tokens; if (configOptions.docs?.length) file.docs = configOptions.docs; + } else if (replaced) { + // What the config leaves out comes from the package's, as it does with none: it holds only + // the app's own rules and overrides, once they are added. } else if (workspace) { // At a monorepo root, each project keeps zero config; the file holds what they share. notes.push( - `This is a monorepo root: ${workspace.projects.length} of ${workspace.projects.length + workspace.others.length} workspace packages have a design system (${workspace.projects.map((p) => p.dir).join(', ') || 'none'}). Each keeps zero config; rules and overrides you add here apply to every project without a config of its own.`, + `This is a monorepo root: ${workspace.projects.length} of ${workspace.projects.length + workspace.others.length} workspace packages have a design system (${workspace.projects.map((p) => p.dir).join(', ') || 'none'}). Each keeps zero config; rules and overrides you add here apply to every project without a config of its own when it is checked from this folder, over the config its design-system package declares.`, ); } else if (config.detected) { notes.push(`Found the design system through ${config.detected}.`); const specifiers = new Set((config.imports ?? []).map((m) => m.specifier)); const tokens = (await tokenFiles(config)).map(relative); - if (specifiers.size > 1) { + const fromInstalled = installedFields(config.components, tokens); + if (fromInstalled.length) notes.push(installedNote(fromInstalled)); + if (fromInstalled.includes('components')) { + // Spelled out, they would miss the package's next version. + } else if (specifiers.size > 1) { notes.push( `"components" is left to zero config: apps import them by ${specifiers.size} specifiers from the package's exports (${[...specifiers].slice(0, 2).join(', ')}, …), which a config cannot spell.`, ); @@ -154,27 +162,21 @@ export async function init( const docs = config.docs.filter((d) => !DEFAULT_DOCS.includes(d)); if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs]; } - if (tokens.length) file.tokens = tokens; + if (tokens.length && !fromInstalled.includes('tokens')) file.tokens = tokens; } else if ((await componentFiles(config)).length) { // Nothing detected, but the default folders hold components (`components/ui` without a // components.json), as `check` and `inspect` find them: spell out the ones that match. - // With --force in an app, they are the folders a design-system package's config names. - const declared = replaced && config.configured; const matching: string[] = []; for (const glob of config.components) { if ((await componentFiles({ ...config, components: [glob] })).length) matching.push(glob); } - notes.push( - declared - ? `Found the design system through the config ${replaced.name} declares: ${matching.join(', ')}.` - : `Found the design system in the default folders: ${matching.join(', ')}.`, - ); + notes.push(`Found the design system in the default folders: ${matching.join(', ')}.`); file.components = matching; found = true; - // Imported as the package's config says, not by a path into it. - if (declared && config.importPath) file.importPath = config.importPath; const tokens = (await tokenFiles(config)).map(relative); - if (tokens.length) file.tokens = tokens; + const fromInstalled = installedFields([], tokens); + if (fromInstalled.length) notes.push(installedNote(fromInstalled)); + else if (tokens.length) file.tokens = tokens; } else { if (!ask) { io.stderr( @@ -223,11 +225,7 @@ export async function init( fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`); written.push(CONFIG_FILE); } - if (replaced) { - // What the project reads now, next to what it took on. - const own = (await loadTarget({ ...configOptions, config: undefined })).config; - notes.push(await replacedNote(replaced, config, own, io.cwd)); - } + if (replaced) notes.push(replacedNote(replaced, io.cwd)); let registry: Unshipped | undefined; if (designSystem && manifest && name) { written.push(...declarePackage(root, manifest, name, options.force, notes)); @@ -530,7 +528,8 @@ interface Unshipped { * files its `files` list leaves out of the published package, and those outside the package * (`../tailwind-config/theme.css`), which an installed package's config cannot reach. Undefined * for a private package, or when they find every file. Without a `files` list, npm publishes - * everything in the package. + * everything in the package. Without any component file, those apps read the components from + * the type declarations the package publishes, when it names some. */ async function unshipped( config: ResolvedConfig, @@ -547,10 +546,12 @@ async function unshipped( files ? list.filter((f) => !outside(f) && !ships(files, f)) : []; const missing = { components: left(components), tokens: left(tokens) }; const away = { components: components.filter(outside), tokens: tokens.filter(outside) }; - // With every component file left out, the component rules know no component in those apps. + // With every component file left out, those apps read the components from the package's type + // declarations, or, when it publishes none, know no component. const noComponents = components.length > 0 && missing.components.length + away.components.length === components.length; + const declarations = noComponents ? publishedDeclarations(config.root, json, files) : []; // `(3 component files and 1 token file). Those apps do not know the components or tokens in it…` const lost = (lists: { components: string[]; tokens: string[] }, one: boolean) => { const c = lists.components.length; @@ -559,12 +560,16 @@ async function unshipped( ...(c ? [count(c, 'component file')] : []), ...(t ? [count(t, 'token file')] : []), ]); + const them = one ? 'it' : 'them'; + if (c && noComponents && declarations.length) { + return `(${kinds}). Those apps read the components from the type declarations it publishes instead (${andList(declarations)})${t ? `, and do not know the tokens in ${them}` : ''}.`; + } const what = andList([...(c ? ['components'] : []), ...(t ? ['tokens'] : [])], 'or'); const checks = c && noComponents - ? `: they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` + ? `: with no type declarations published either, they get no component, prop, variant or native-element checks, and \`${NAME} check --require-design-system\` fails in them` : ''; - return `(${kinds}). Those apps do not know the ${what} in ${one ? 'it' : 'them'}${checks}.`; + return `(${kinds}). Those apps do not know the ${what} in ${them}${checks}.`; }; const notes: string[] = []; @@ -588,50 +593,79 @@ async function unshipped( notes, next: noComponents && missing.components.length - ? `gets no component checks until "files" publishes ${andList([...new Set(missing.components.map(top))])}` + ? declarations.length + ? `reads its components from the type declarations it publishes, not from ${andList([...new Set(missing.components.map(top))])}` + : `gets no component checks until "files" publishes ${andList([...new Set(missing.components.map(top))])}` : `does not read ${andList([...folders, ...beyond])}`, }; } /** - * Said when --force writes a config in an app that took on a design-system package's: the new - * one is read instead, and what it does not take from the package. `taken` is the config the - * app took on, `own` the one it reads now. + * The type declarations a package's package.json names, as apps that install it look for them: + * `types` or `typings`, a `types` condition in its `exports`, or an `index.d.ts` beside it, each + * that its `files` list publishes. Relative to the package. */ -async function replacedNote( - pkg: DesignSystemPackage, - taken: ResolvedConfig, - own: ResolvedConfig, - cwd: string, -): Promise { - const lacks = async (list: (c: ResolvedConfig) => Promise) => { - const kept = new Set((await list(own)).map((f) => path.resolve(f))); - return (await list(taken)).some((f) => !kept.has(path.resolve(f))); +function publishedDeclarations( + root: string, + json: Record, + files: readonly unknown[] | undefined, +): string[] { + const named: string[] = []; + for (const field of ['types', 'typings']) { + const value = json[field]; + if (typeof value === 'string') named.push(value); + } + const conditions = (value: unknown, key?: string): void => { + if (typeof value === 'string') { + if (key === 'types') named.push(value); + } else if (Array.isArray(value)) { + for (const item of value) conditions(item); + } else if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value)) conditions(v, k); + } }; - const components = await lacks(componentFiles); - const importPath = taken.importPath !== undefined && own.importPath === undefined; - const tokens = await lacks(tokenFiles); + conditions(json.exports); + if (fs.existsSync(path.join(root, 'index.d.ts'))) named.push('index.d.ts'); + return [...new Set(named.map((f) => f.replace(/^\.\//, '')))].filter( + (f) => !files || ships(files, f), + ); +} + +/** + * Said when --force writes a config in an app on a design-system package that declares one: the + * new config builds on the package's, which still gives it what it leaves out. + */ +function replacedNote(pkg: DesignSystemPackage, cwd: string): string { const declared = relativePath(cwd, pkg.config) || pkg.config; - const replaced = `This project took on the config ${pkg.name} declares (${declared}); with --force it has one of its own, which is read instead, so ${pkg.name}'s rules and later changes to its config no longer reach it.`; - if (!components && !importPath && !tokens) return replaced; - const effects = andList([ - ...(components ? [`does not know ${pkg.name}'s components`] : []), - ...(importPath && !components - ? [`does not match imports from ${pkg.name} to its components`] - : []), - ...(tokens ? [`does not read ${pkg.name}'s tokens`] : []), - ]); - // What the package's config spells out can be copied; what zero config found there cannot. - const fields = [ - ...(components ? ['"components"'] : []), - ...(importPath || (components && taken.importPath !== undefined) ? ['"importPath"'] : []), - ...(tokens ? ['"tokens"'] : []), + return `This project reads the config ${pkg.name} declares (${declared}). With --force it has one of its own, ${CONFIG_FILE}, read over that one: what it leaves out, such as "components", "importPath" and "tokens", still comes from ${pkg.name}'s config, and the rules and overrides you add to it apply over ${pkg.name}'s rules. "extends": false in it reads it alone.`; +} + +/** Whether a path, relative to the root, is in a package installed in `node_modules`. */ +const isInstalledPath = (file: string) => /(?:^|\/)node_modules\//.test(file); + +/** + * The fields `init` leaves to zero config because what zero config found for them is in a + * package installed in `node_modules`: `components` (their globs) and `tokens` (files). + */ +function installedFields( + components: readonly string[], + tokens: readonly string[], +): ('components' | 'tokens')[] { + return [ + ...(components.some(isInstalledPath) ? ['components' as const] : []), + ...(tokens.some(isInstalledPath) ? ['tokens' as const] : []), ]; - const copy = - (!components || taken.configured === true) && (!tokens || taken.tokensDetected !== true) - ? `copy ${andList(fields)} from ${declared} into ${CONFIG_FILE}, or ` - : ''; - return `${replaced} It ${effects}: ${copy}delete ${CONFIG_FILE} to take on ${pkg.name}'s config again.`; +} + +/** + * Why `fields` are left to zero config: a path into an installed package breaks when it is + * upgraded, since pnpm keeps each version in a folder of its own + * (`node_modules/.pnpm/@acme+ui@1.2.0_react@19.2.0/node_modules/@acme/ui`), and a list of its + * files misses the ones a new version adds. Zero config finds it again each time. + */ +function installedNote(fields: readonly string[]): string { + const one = fields.length === 1; + return `${andList(fields.map((f) => `"${f}"`))} ${one ? 'is' : 'are'} left to zero config: ${one ? 'it comes' : 'they come'} from a package installed in node_modules, which zero config finds again after each upgrade, while a path written here would break once the package's folder or files change (pnpm keeps each version in a folder of its own in node_modules/.pnpm).`; } const REGISTRY_DOCS = 'https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries'; @@ -807,7 +841,7 @@ function consumerGuide(name: string): string { `npx -y ${NAME} inspect`, '```', '', - `An app with an ${CONFIG_FILE} of its own reads that instead.`, + `An app with an ${CONFIG_FILE} of its own reads it over ${name}'s config: what it leaves out, such as the components, \`importPath\` and tokens, comes from ${name}'s, and the rules and overrides it sets apply over ${name}'s rules. \`"extends": false\` in it reads it alone.`, '', '## Coding agents', '', diff --git a/src/workspace.ts b/src/workspace.ts index ebcfa823..b353836d 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -196,10 +196,11 @@ async function discoverWorkspace( } /** - * A project's config: its own config file, else the config its design-system package declares - * (a dependency with `"onsystem"` in its package.json), else the workspace root's, with paths - * relative to the workspace root, else zero config. `loadConfig` from the project's folder finds - * the first two the same way. CLI flags that apply to every project carry over. + * A project's config: its own config file, else the workspace root's, with paths relative to + * the workspace root, read over the config its design-system package declares (a dependency with + * `"onsystem"` in its package.json), else zero config. `loadConfig` from the project's folder + * finds its own config and the package's the same way. CLI flags that apply to every project + * carry over. */ function loadProjectConfig( dir: string, @@ -219,7 +220,7 @@ function loadProjectConfig( } const lookup = findDesignSystemPackage(dir, packages); // A declaration that cannot be read is reported from the project's own load. - if (lookup?.found || lookup?.problems.length) { + if (lookup?.problems.length) { return loadConfigWith({ ...shared, root: dir }, { packages, lookup }); } return loadConfigWith( diff --git a/test/design-system-package.test.ts b/test/design-system-package.test.ts index 2b98d92b..171abf25 100644 --- a/test/design-system-package.test.ts +++ b/test/design-system-package.test.ts @@ -107,7 +107,7 @@ describe('init in a design-system package', () => { ); expect(init.stdout).toContain('Wrote onsystem.config.json, package.json, ONSYSTEM.md.'); expect(init.stdout).toContain( - 'A workspace app that depends on @acme/kit now finds it with no config of its own: run `npx onsystem inspect` in one to see it, and give its team ONSYSTEM.md. An app that installs @acme/kit from a registry gets no component checks until "files" publishes src/.', + 'A workspace app that depends on @acme/kit now finds it with no config of its own: run `npx onsystem inspect` in one to see it, and give its team ONSYSTEM.md. An app that installs @acme/kit from a registry reads its components from the type declarations it publishes, not from src/.', ); const manifest = read(path.join(kit, 'package.json')); expect(manifest.onsystem).toBe('./onsystem.config.json'); @@ -152,12 +152,14 @@ describe('init in a design-system package', () => { ); expect(fs.existsSync(path.join(app, 'onsystem.config.json'))).toBe(false); expect(appInit.stdout).toContain('Baseline: 2 findings in 1 file → onsystem.baseline.json'); - // --force writes the app a config of its own, which is then read instead. + // --force writes the app a config of its own, which builds on the package's. expect((await run(['init', '--yes', '--force', '--no-cache'], app)).code).toBe(0); expect(fs.existsSync(path.join(app, 'onsystem.config.json'))).toBe(true); - expect((await run(['inspect', '--no-cache'], app)).stdout).toContain( - 'config onsystem.config.json\n', + const forced = (await run(['inspect', '--no-cache'], app)).stdout; + expect(forced).toContain( + `config onsystem.config.json, over ${path.join(kit, 'onsystem.config.json')} (declared by @acme/kit)\n`, ); + expect(forced).toContain('files 3 component files, 1 token file'); }); it('keeps package.json as it is written, an ONSYSTEM.md already there, and a private package unnoted', async () => { @@ -333,40 +335,56 @@ describe('a config a design-system package declares', () => { ); }); - it("gives way to the app's own config, and wins over a workspace root's, from the root too", async () => { + it("is read under the app's own config, and under a workspace root's from the root", async () => { const files = { ...MONOREPO, ...kitAt('packages/kit', { rules: { 'no-hardcoded-spacing': 'error' } }), - 'onsystem.config.json': json({ rules: { 'no-hardcoded-spacing': 'off' } }), + 'onsystem.config.json': json({ + rules: { 'no-hardcoded-spacing': 'off', 'no-unknown-variant': 'off' }, + }), }; const root = fixture(files); + // From the app's folder, the root's config is not read. const fromApp = await loadConfig({ root: path.join(root, 'apps/web') }); expect(fromApp.designSystemPackage?.name).toBe('@acme/kit'); + expect(fromApp.configFile).toBe(path.join(root, 'packages/kit/onsystem.config.json')); expect(fromApp.rules['no-hardcoded-spacing'].severity).toBe('error'); - // From the monorepo root, the app's project reads the same config. + // From the monorepo root, the app's project reads the root's config over the package's. const { workspace } = await loadTarget({ cwd: root }); const project = await workspace?.projectFor('apps/web/app/page.tsx'); - expect(project?.config.configFile).toBe(fromApp.configFile); - expect(project?.config.rules['no-hardcoded-spacing'].severity).toBe('error'); + expect(project?.config.configFile).toBe(path.join(root, 'onsystem.config.json')); + expect(project?.config.designSystemPackage?.name).toBe('@acme/kit'); + expect(project?.config.components).toEqual(fromApp.components); + expect(project?.config.rules['no-hardcoded-spacing'].severity).toBe('off'); const atRoot = await run(['inspect', '--no-cache'], root); expect(atRoot.stdout).toContain( - `apps/web (@acme/web)\n config ${path.join('packages', 'kit', 'onsystem.config.json')} (declared by @acme/kit)`, + `apps/web (@acme/web)\n config onsystem.config.json, over ${path.join('packages', 'kit', 'onsystem.config.json')} (declared by @acme/kit)`, ); + // check from the root reports what check in the app's folder does, but for the rule the + // root's config turns off. const checked = await run(['check', '.', '--format', 'json', '--no-cache'], root); + const inApp = await findings(path.join(root, 'apps/web')); + expect(inApp.some((f) => f.includes('no-unknown-variant'))).toBe(true); expect( (JSON.parse(checked.stdout) as Results) .flatMap((r) => r.diagnostics.map((d) => `${r.file} ${d.ruleId}`)) .filter((f) => f.startsWith('apps/web/')), ).toEqual( - (await findings(path.join(root, 'apps/web'))).map((f) => `apps/web/${f}`.replace(/:.*$/, '')), + inApp + .filter((f) => !f.includes('no-unknown-variant')) + .map((f) => `apps/web/${f}`.replace(/:.*$/, '')), ); - // The app's own config wins over the package's. + // The app's own config builds on the package's, or, with "extends": false, replaces it. fs.writeFileSync(path.join(root, 'apps/web/onsystem.config.json'), json({})); const own = await loadConfig({ root: path.join(root, 'apps/web') }); - expect(own.designSystemPackage).toBeUndefined(); + expect(own.designSystemPackage?.name).toBe('@acme/kit'); expect(own.configFile).toBe(path.join(root, 'apps/web/onsystem.config.json')); - expect(own.rules['no-hardcoded-spacing'].severity).toBe('warn'); + expect(own.rules['no-hardcoded-spacing'].severity).toBe('error'); + fs.writeFileSync(path.join(root, 'apps/web/onsystem.config.json'), json({ extends: false })); + const alone = await loadConfig({ root: path.join(root, 'apps/web') }); + expect(alone.designSystemPackage).toBeUndefined(); + expect(alone.rules['no-hardcoded-spacing'].severity).toBe('warn'); }); it('is an error when the package names one that cannot be read, a config in code among them', async () => { diff --git a/test/design-system-packages-docs.test.ts b/test/design-system-packages-docs.test.ts index a5a9ae9b..6ec17dda 100644 --- a/test/design-system-packages-docs.test.ts +++ b/test/design-system-packages-docs.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest'; import { main, type Io } from '../src/cli.js'; import { RULE_IDS } from '../src/config.js'; import { createServer } from '../src/server/index.js'; -import { fixture, load } from './helpers.js'; +import { fixture, load, TSCONFIG } from './helpers.js'; import { section } from './markdown.js'; // docs/design-system-packages.md: what an app that depends on a design-system package gets, by @@ -48,8 +48,16 @@ const PACKAGE = { 'src/input.tsx': 'export function Input(props: { name?: string }) { return }\n', 'dist/index.mjs': 'export function Button() {}\nexport function Card() {}\nexport function Input() {}\n', - 'dist/index.d.ts': - 'export declare function Button(props: { variant?: "default" | "outline" }): unknown;\nexport declare function Card(props: object): unknown;\nexport declare function Input(props: object): unknown;\n', + // As tsc emits them from the sources. + 'dist/index.d.ts': [ + "import * as react from 'react';", + "import * as react_jsx_runtime from 'react/jsx-runtime';", + 'declare function Button(props: react.ButtonHTMLAttributes & { variant?: "default" | "outline" }): react_jsx_runtime.JSX.Element;', + 'declare function Card(props: react.HTMLAttributes): react_jsx_runtime.JSX.Element;', + 'declare function Input(props: react.InputHTMLAttributes): react_jsx_runtime.JSX.Element;', + 'export { Button, Card, Input };', + '', + ].join('\n'), }; /** @@ -180,14 +188,40 @@ function published(pkg: Record): Record { const at = (prefix: string, files: Record) => Object.fromEntries(Object.entries(files).map(([file, text]) => [`${prefix}/${file}`, text])); -/** An app that installs the package from a registry, with `pkg` in node_modules. */ -function installedApp(pkg: Record, extra: Record = {}): string { - return fixture({ - 'package.json': json({ name: 'web', dependencies: { '@acme/ui': '1.0.0' } }), - 'app/page.tsx': PAGE, - ...at('node_modules/@acme/ui', pkg), - ...extra, - }); +/** + * An app that installs the package from a registry, with `pkg` in node_modules. With `react`, + * React's types are installed too, which type declarations need to declare a component. + */ +function installedApp( + pkg: Record, + extra: Record = {}, + { react = false, name = 'web' }: { react?: boolean; name?: string } = {}, +): string { + return fixture( + { + 'package.json': json({ + name, + dependencies: { '@acme/ui': '1.0.0', ...(react ? { react: '19.0.0' } : {}) }, + }), + ...(react ? { 'tsconfig.json': TSCONFIG } : {}), + 'app/page.tsx': PAGE, + ...at('node_modules/@acme/ui', pkg), + ...extra, + }, + { nodeModules: react }, + ); +} + +/** A published package without its type declarations: no `.d.ts`, and no `types` to name one. */ +function withoutTypes(pkg: Record): Record { + const manifest = JSON.parse(pkg['package.json'] ?? '{}') as { + exports: Record; + }; + const { types: _, ...main } = manifest.exports['.'] as Record; + return { + ...without(pkg, (file) => file.endsWith('.d.ts')), + 'package.json': json({ ...manifest, exports: { ...manifest.exports, '.': main } }), + }; } /** A pnpm monorepo: the package in packages/ui, and the app in apps/web. Returns the app. */ @@ -251,11 +285,12 @@ async function tool(root: string, name: string, args: Record): } describe('what apps get from a design-system package (docs/design-system-packages.md)', () => { - it('has a row for a workspace package and for four ways of publishing one', () => { + it('has a row for a workspace package and for five ways of publishing one', () => { expect(rows().map(([how]) => how)).toEqual([ 'As a workspace package', 'From a registry, with the config, the token files and the component sources (`src/`) published', - 'From a registry, with the config, the token files and compiled `dist/` published, but not `src/`', + 'From a registry, with the config, the token files, compiled `dist/` and its type declarations (`.d.ts`) published, but not `src/`', + 'From a registry, with the config, the token files and compiled `dist/` published, but neither `src/` nor type declarations', 'From a registry, without the config, named like a design system (`@acme/ui`), with `exports` that point at the component sources (`./src/index.ts`)', 'From a registry, without the config, with `exports` that point at `dist/`, or named otherwise (`@acme/kit`)', ]); @@ -271,10 +306,30 @@ describe('what apps get from a design-system package (docs/design-system-package ); }); - it('runs the token rules from a package published without src/, and says the component rules did not run', async () => { + it('runs every rule from a package published as dist/ with its type declarations, which give its components', async () => { const pkg = await initialised(); const distOnly = without(published(pkg), (file) => file.startsWith('src/')); - expect(Object.keys(distOnly).sort()).toEqual([ + const app = installedApp(distOnly, {}, { react: true }); + const { rules, stderr } = await check(app); + expect(rules).toEqual(rulesIn(rows()[2]?.[1] ?? '')); + expect(rows()[2]?.[1]).toBe('All eight, from its type declarations'); + expect(stderr).not.toContain('No design-system components found'); + const library = + 'library Design-system package (@acme/ui 1.0.0): 3 components from its type declarations'; + expect((await run(['inspect', '--no-cache'], app)).stdout).toContain(library); + expect(whatAppsGet).toContain(`\`${library}\``); + expect(await tool(app, 'get_component', { name: 'Button' })).toContain('@acme/ui'); + expect(whatAppsGet).toContain( + 'every rule runs, and agents find the components with `list_components`, `search_components` and `get_component`.', + ); + // --require-design-system finds the design system (exit code 2 would say it did not). + expect((await run(['check', '.', '--require-design-system', '--no-cache'], app)).code).toBe(1); + }); + + it('runs the token rules from a package published without src/ or type declarations, and says the component rules did not run', async () => { + const pkg = await initialised(); + const withTypes = without(published(pkg), (file) => file.startsWith('src/')); + expect(Object.keys(withTypes).sort()).toEqual([ 'dist/index.d.ts', 'dist/index.mjs', 'onsystem.config.json', @@ -282,12 +337,13 @@ describe('what apps get from a design-system package (docs/design-system-package 'theme.css', ]); // init's config, as published: apps import the package by one name, so it sets importPath. - expect(JSON.parse(distOnly['onsystem.config.json'] ?? '{}')).toMatchObject({ + expect(JSON.parse(withTypes['onsystem.config.json'] ?? '{}')).toMatchObject({ importPath: '@acme/ui', }); - const app = installedApp(distOnly); + expect(whatAppsGet).toContain('When the package publishes no type declarations either:'); + const app = installedApp(withoutTypes(withTypes), {}, { react: true }); const { rules, findings, stderr } = await check(app); - const cell = rows()[2]?.[1] ?? ''; + const cell = rows()[3]?.[1] ?? ''; expect(rules).toEqual(rulesIn(cell)); // The icon rule runs on the native ;\n', + }); + install(app, version); + return app; + } + + /** Installs `@acme/ui` at `version` as pnpm does, in place of the one installed. */ + function install(app: string, version: string): void { + const link = path.join(app, 'node_modules/@acme/ui'); + fs.rmSync(link, { force: true, recursive: true }); + fs.rmSync(path.join(app, 'node_modules/.pnpm'), { force: true, recursive: true }); + const dir = path.join( + app, + `node_modules/.pnpm/@acme+ui@${version}_react@19.0.0/node_modules/@acme/ui`, + ); + const files = { + 'package.json': json({ + name: '@acme/ui', + version, + files: ['src'], + exports: { '.': './src/index.tsx', './globals.css': './src/globals.css' }, + }), + 'src/globals.css': '@theme {\n --color-primary: oklch(0.55 0.2 260);\n}\n', + 'src/index.tsx': + 'export * from "./button";\nexport * from "./card";\nexport * from "./input";\n', + 'src/button.tsx': BUTTON, + 'src/card.tsx': CARD, + 'src/input.tsx': INPUT, + }; + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text); + } + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(dir, link, 'junction'); + } + + it('leaves its components and tokens to zero config, which finds them again after an upgrade', async () => { + const app = pnpmApp('1.0.0'); + const init = await run(['init', '--yes', '--no-cache'], app); + expect(init.code).toBe(0); + expect(init.stdout).toContain('Found the design system through installed package @acme/ui.'); + expect(init.stdout).toContain( + '"components" and "tokens" are left to zero config: they come from a package installed in node_modules, which zero config finds again after each upgrade', + ); + const written = fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8'); + expect(written).not.toContain('node_modules'); + fs.rmSync(path.join(app, 'onsystem.baseline.json')); + const before = await check(app, 'app/page.tsx'); + expect(before.rules).toEqual( + new Set(['no-unknown-variant', 'no-unknown-prop', 'no-hardcoded-color']), + ); + + // pnpm puts 1.1.0 in a folder of its own; the config still finds the package. + install(app, '1.1.0'); + const after = await check(app, 'app/page.tsx'); + expect(after.code).toBe(1); + expect(after.findings).toEqual(before.findings); + }); +}); + +describe('init in a design-system package published as dist/', () => { + const DTS = `import * as react from 'react'; +import * as react_jsx_runtime from 'react/jsx-runtime'; +declare function Button(props: react.ButtonHTMLAttributes & { variant?: "default" | "outline" }): react_jsx_runtime.JSX.Element; +declare function Card(props: react.HTMLAttributes): react_jsx_runtime.JSX.Element; +declare function Input(props: react.InputHTMLAttributes): react_jsx_runtime.JSX.Element; +export { Button, Card, Input }; +`; + + /** The package before `init`, with `files` publishing `dist/` and the theme, and `types` or not. */ + function distPackage(types: boolean): Record { + const { 'onsystem.config.json': _, ...sources } = acmeUi({}); + return { + ...sources, + 'package.json': json({ + name: '@acme/ui', + version: '1.0.0', + files: ['dist', 'theme.css'], + exports: { + '.': { ...(types ? { types: './dist/index.d.ts' } : {}), import: './dist/index.mjs' }, + './theme.css': './theme.css', + }, + }), + ...(types ? { 'dist/index.d.ts': DTS } : {}), + }; + } + + /** `init --yes` in the package, then an app that installs what npm publishes of it. */ + async function setUp(types: boolean) { + const pkg = fixture(distPackage(types)); + const init = await run(['init', '--yes', '--no-cache'], pkg); + expect(init.code).toBe(0); + const published = ['package.json', 'onsystem.config.json', 'theme.css', 'dist/index.mjs']; + if (types) published.push('dist/index.d.ts'); + const app = fixture( + { + 'package.json': json({ name: 'web', dependencies: { '@acme/ui': '1.0.0', react: '19' } }), + 'tsconfig.json': TSCONFIG, + 'app/page.tsx': PAGE, + ...Object.fromEntries( + published.map((file) => [ + `node_modules/@acme/ui/${file}`, + fs.readFileSync(path.join(pkg, file), 'utf8'), + ]), + ), + }, + { nodeModules: true }, + ); + return { init, app }; + } + + it('says that apps read the components from the type declarations it publishes', async () => { + const { init, app } = await setUp(true); + expect(init.stdout).toContain( + 'Apps that install @acme/ui from a registry get only what "files" in package.json publishes, which leaves out src/ (3 component files). Those apps read the components from the type declarations it publishes instead (dist/index.d.ts). Add it to "files" for them; workspace apps read the sources either way.', + ); + expect(init.stdout).toContain( + 'An app that installs @acme/ui from a registry reads its components from the type declarations it publishes, not from src/.', + ); + const { rules } = await check(app, 'app/page.tsx'); + for (const rule of COMPONENT_RULES) expect(rules).toContain(rule); + expect((await run(['check', '.', '--require-design-system', '--no-cache'], app)).code).toBe(1); + }); + + it('says that apps get no component checks when it publishes no type declarations', async () => { + const { init, app } = await setUp(false); + expect(init.stdout).toContain( + 'which leaves out src/ (3 component files). Those apps do not know the components in it: with no type declarations published either, they get no component, prop, variant or native-element checks, and `onsystem check --require-design-system` fails in them.', + ); + expect(init.stdout).toContain( + 'An app that installs @acme/ui from a registry gets no component checks until "files" publishes src/.', + ); + const { rules } = await check(app, 'app/page.tsx'); + for (const rule of COMPONENT_RULES) expect(rules).not.toContain(rule); + expect((await run(['check', '.', '--require-design-system', '--no-cache'], app)).code).toBe(2); + }); +}); From 816acb15150e8eab1c383d7029923a572a04cf3e Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 23 Oct 2026 21:31:31 +0100 Subject: [PATCH 2/5] fix(config): keep a stylesheet init leaves to zero config, list tokens-only projects, watch the package's config - init in an app whose components zero config finds itself (components.json) and whose tokens include an installed package's stylesheet left "tokens" out but wrote "components". A config that names components turns detection off, so a stylesheet outside the usual places (app/styles/tailwind.css, and the package's theme it imports) was lost: the dry run said "No color tokens found". init now leaves "components" to zero config as well when a config that names them would read other token files, and says so. With app/globals.css it still writes the components. - A workspace project whose design-system package's config gives it no component file (tokens only) is listed with a design system again when the root has a config: it was moved to "Without a design system". - The MCP server watches the config a design-system package declares when the project reads it under a config of its own or the root's, so an edit to it is picked up live again. - Docs and the changeset: the package's exclude patterns are added to the app's, and elements merge element by element. init's note in a design-system package says apps read its config under their own. --- .changeset/layered-configs.md | 2 +- docs/configuration.md | 2 +- docs/design-system-packages.md | 2 +- src/design-system.ts | 20 ++- src/init.ts | 58 +++++++- src/workspace.ts | 5 +- test/design-system-package.test.ts | 76 ++++++++++- test/design-system-packages-docs.test.ts | 32 ++++- test/layered-config.test.ts | 163 +++++++++++++++++++++++ 9 files changed, 342 insertions(+), 18 deletions(-) diff --git a/.changeset/layered-configs.md b/.changeset/layered-configs.md index c683f12f..bd54bc90 100644 --- a/.changeset/layered-configs.md +++ b/.changeset/layered-configs.md @@ -2,7 +2,7 @@ 'onsystem': minor --- -An app's own config builds on the config its design-system package declares, instead of replacing it. What the app's config leaves out comes from the package's: `components`, with the package's `importPath`, `docs` and `elements` unless the app's config sets `components` of its own; `tokens`; and `libraries`. Its `rules` apply over the package's, rule by rule, and its `overrides`, `exclude`, `tsconfig` and `includeDesignSystem` are its own. Before, an app config that only turned one rule to `"warn"` lost the package's components and color tokens: in an app that installs the package, `check` said `No design system found` and passed. `inspect` names both configs (`config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)`). `"extends": false` in the app's config reads it alone, as before. This can change what `check` reports in an app with a config of its own whose dependency declares one: it now gets the package's components, tokens and rules where its config leaves them out. A workspace package whose `"onsystem"` field cannot be read is a warning in such an app, which then reads its own config alone, and still an error in an app without one. +An app's own config builds on the config its design-system package declares, instead of replacing it. What the app's config leaves out comes from the package's: `components`, with the package's `importPath`, `docs` and `elements` unless the app's config sets `components` of its own; `tokens`; and `libraries`. Its `rules` apply over the package's, rule by rule, and, with the package's components, its `elements` element by element; the package's `exclude` patterns are added to the app's; its `overrides`, `tsconfig` and `includeDesignSystem` are its own. Before, an app config that only turned one rule to `"warn"` lost the package's components and color tokens: in an app that installs the package, `check` said `No design system found` and passed. `inspect` names both configs (`config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)`). `"extends": false` in the app's config reads it alone, as before. This can change what `check` reports in an app with a config of its own whose dependency declares one: it now gets the package's components, tokens and rules where its config leaves them out. A workspace package whose `"onsystem"` field cannot be read is a warning in such an app, which then reads its own config alone, and still an error in an app without one. A monorepo root's config builds on a project's design-system package config the same way in `check` and `inspect` from the root, so its `rules` and `overrides` apply to every project without a config of its own. Before, the config a project's design-system package declared took their place, without a word. From the project's folder, the root's config is still not read. diff --git a/docs/configuration.md b/docs/configuration.md index d7300593..1a3b4aaf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,7 @@ The root stays one project, as before, when it has a design system of its own (a ## Config file -`onsystem.config.json` (or `.mjs`, `.js`, or `.ts` on Node.js 22.18 or later) in the project root. Every field is optional; the [JSON Schema](../schema.json) gives editor completion. `npx onsystem init` writes one: the folders zero config found, or the default `components/ui` folders when they hold the components, spelled out (it leaves `components` to zero config when a package's exports name more than a few files or import paths a config cannot spell), or your answers to where the components, tokens and import path are when it found nothing. It never replaces an existing config without `--force`. At a monorepo root it writes a config with only `$schema`, for rules all projects share. In a design-system package it also declares the config in `package.json`, so every app that depends on the package reads it, and writes `ONSYSTEM.md` for those apps' teams ([design-system packages](design-system-packages.md)); in an app that takes on such a config, it writes none of its own unless `--force` is passed, and then one with only `$schema`, which builds on the package's. +`onsystem.config.json` (or `.mjs`, `.js`, or `.ts` on Node.js 22.18 or later) in the project root. Every field is optional; the [JSON Schema](../schema.json) gives editor completion. `npx onsystem init` writes one: the folders zero config found, or the default `components/ui` folders when they hold the components, spelled out (it leaves `components` to zero config when a package's exports name more than a few files or import paths a config cannot spell, and `components` or `tokens` when they come from a package installed in `node_modules`, whose folder and files can change at each upgrade; with such tokens, `components` as well when a config that names them would read other stylesheets), or your answers to where the components, tokens and import path are when it found nothing. It never replaces an existing config without `--force`. At a monorepo root it writes a config with only `$schema`, for rules all projects share. In a design-system package it also declares the config in `package.json`, so every app that depends on the package reads it, and writes `ONSYSTEM.md` for those apps' teams ([design-system packages](design-system-packages.md)); in an app that takes on such a config, it writes none of its own unless `--force` is passed, and then one with only `$schema`, which builds on the package's. ```json title="onsystem.config.json" { diff --git a/docs/design-system-packages.md b/docs/design-system-packages.md index 3a4f014a..a186c5bf 100644 --- a/docs/design-system-packages.md +++ b/docs/design-system-packages.md @@ -57,7 +57,7 @@ A project reads up to two configs, the first over the second, and zero config fi ### An app's own config -An app's own config builds on the package's: each field it sets replaces the package's, and each it leaves out comes from there. `components` brings the package's `importPath`, `docs` and `elements` with it, so a config that sets `components` of its own takes none of those from the package; `tokens` and `libraries` come from the package unless the app's config sets them. Its `rules` apply over the package's, rule by rule, and its `overrides`, `exclude`, `tsconfig` and `includeDesignSystem` are its own, as the package's stay with the package. So a config that only changes a rule's severity, `{ "rules": { "no-hardcoded-color": "warn" } }`, keeps the package's components and tokens, and every other rule as the package sets it. A [monorepo root](configuration.md#monorepo-roots)'s config, from the root, builds on the package's the same way, so its `rules` and `overrides` (`"files": ["apps/admin/legacy/**"]`) apply in every app without a config of its own. `inspect` names both configs: +An app's own config builds on the package's: what it leaves out comes from there. `components` brings the package's `importPath`, `docs` and `elements` with it, so a config that sets `components` of its own takes none of those from the package; `tokens` and `libraries` come from the package unless the app's config sets them. Its `rules` apply over the package's, rule by rule, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own, as the package's stay with the package. So a config that only changes a rule's severity, `{ "rules": { "no-hardcoded-color": "warn" } }`, keeps the package's components and tokens, and every other rule as the package sets it. A [monorepo root](configuration.md#monorepo-roots)'s config, from the root, builds on the package's the same way, so its `rules` and `overrides` (`"files": ["apps/admin/legacy/**"]`) apply in every app without a config of its own. `inspect` names both configs: ```text config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui) diff --git a/src/design-system.ts b/src/design-system.ts index d36d0899..9ea58e08 100644 --- a/src/design-system.ts +++ b/src/design-system.ts @@ -822,16 +822,17 @@ export class DesignSystemHost { } /** - * Watches the config file, the tsconfig and the tsconfigs it extends through - * their directories: editors save by renaming a new file over the old one, - * which ends a watch on the file. Called again after each build, so a new - * `extends` target is picked up. + * Watches the config file, the config a design-system package declares, which a project with + * a config of its own reads under that one, the tsconfig and the tsconfigs it extends through + * their directories: editors save by renaming a new file over the old one, which ends a watch + * on the file. Called again after each build, so a new `extends` target is picked up. */ private watchConfigFiles(): void { const { root, configFile, tsconfig, designSystemPackage } = this.config; const files = [ configFile, - // The design-system package's package.json, which names its config. + // The design-system package's config, and its package.json, which names it. + designSystemPackage?.config, designSystemPackage && path.join(designSystemPackage.dir, 'package.json'), // Zero-config detection reads these; a change re-runs it. ...DETECTION_FILES.map((f) => path.join(root, f)), @@ -919,10 +920,15 @@ export class DesignSystemHost { /** Re-reads the config, re-watches what it points at and rebuilds. */ private async reloadConfig(): Promise { const { root, configFile, designSystemPackage, warnings } = this.config; - // A design-system package's config is found again from the project, so it is rebased again. + // A design-system package's config is found again from the project, so it is rebased again; + // a config of the project's own is read again, over it. const load = this.options.loadConfig ?? - (() => loadConfig({ root, config: designSystemPackage ? undefined : configFile })); + (() => + loadConfig({ + root, + config: configFile === designSystemPackage?.config ? undefined : configFile, + })); try { this.config = await load(); } catch (error) { diff --git a/src/init.ts b/src/init.ts index 0c5acc31..581cdca1 100644 --- a/src/init.ts +++ b/src/init.ts @@ -7,6 +7,8 @@ import { BASELINE_FILE, LEGACY_BASELINE_FILE } from './baseline.js'; import { CONFIG_FILES, DEFAULT_DOCS, + DEFAULT_TOKENS, + FALLBACK_TOKENS, LEGACY_CONFIG_FILES, type LoadConfigOptions, type ResolvedConfig, @@ -140,7 +142,12 @@ export async function init( const specifiers = new Set((config.imports ?? []).map((m) => m.specifier)); const tokens = (await tokenFiles(config)).map(relative); const fromInstalled = installedFields(config.components, tokens); - if (fromInstalled.length) notes.push(installedNote(fromInstalled)); + if (fromInstalled.length) notes.push(installedNote(fromInstalled, tokens)); + // With "tokens" left to zero config, a config that names components can read other ones. + const changed = + fromInstalled.includes('tokens') && !fromInstalled.includes('components') + ? await tokensWithComponents(config, tokens) + : undefined; if (fromInstalled.includes('components')) { // Spelled out, they would miss the package's next version. } else if (specifiers.size > 1) { @@ -151,6 +158,10 @@ export async function init( notes.push( `"components" is left to zero config: it found ${config.components.length} files through the package's exports, which it keeps following as the package changes.`, ); + } else if (changed) { + notes.push( + `"components" is left to zero config as well: a config that names them reads the tokens only from the usual stylesheets (${DEFAULT_TOKENS[0]} and the like)${changed.lost.length ? `, which leave out ${andList(changed.lost)}` : ', not the ones zero config reads'}.`, + ); } else { // The detected globs, spelled out; a package imported by one name keeps it. A pattern // (`@acme/ui/components/*`) is no import: zero config keeps giving each file its own. @@ -175,7 +186,7 @@ export async function init( found = true; const tokens = (await tokenFiles(config)).map(relative); const fromInstalled = installedFields([], tokens); - if (fromInstalled.length) notes.push(installedNote(fromInstalled)); + if (fromInstalled.length) notes.push(installedNote(fromInstalled, tokens)); else if (tokens.length) file.tokens = tokens; } else { if (!ask) { @@ -504,7 +515,7 @@ function declarePackage( written.push('package.json'); } notes.push( - `This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ', also added to "files"' : ''}), which every app that depends on ${name} reads when it has no config of its own.`, + `This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ', also added to "files"' : ''}), which every app that depends on ${name} reads, under its own config when it has one.`, ); const guide = path.join(root, GUIDE_FILE); if (force || !fs.existsSync(guide)) { @@ -663,9 +674,46 @@ function installedFields( * (`node_modules/.pnpm/@acme+ui@1.2.0_react@19.2.0/node_modules/@acme/ui`), and a list of its * files misses the ones a new version adds. Zero config finds it again each time. */ -function installedNote(fields: readonly string[]): string { +function installedNote( + fields: readonly ('components' | 'tokens')[], + tokens: readonly string[], +): string { const one = fields.length === 1; - return `${andList(fields.map((f) => `"${f}"`))} ${one ? 'is' : 'are'} left to zero config: ${one ? 'it comes' : 'they come'} from a package installed in node_modules, which zero config finds again after each upgrade, while a path written here would break once the package's folder or files change (pnpm keeps each version in a folder of its own in node_modules/.pnpm).`; + // The app's own stylesheet, next to the package's, is left to zero config with it. + const some = fields.includes('tokens') && !tokens.every(isInstalledPath); + const what = some + ? `${fields.includes('components') ? 'the components and ' : ''}some of the token files come` + : one + ? 'it comes' + : 'they come'; + return `${andList(fields.map((f) => `"${f}"`))} ${one ? 'is' : 'are'} left to zero config: ${what} from a package installed in node_modules, which zero config finds again after each upgrade, while a path written here would break once the package's folder or files change (pnpm keeps each version in a folder of its own in node_modules/.pnpm).`; +} + +/** + * What changes when a config names components and leaves `tokens` (relative to the root, as + * zero config finds them) to zero config: such a config turns detection off, so the stylesheets + * it found (components.json's `app/styles/tailwind.css`, and what they import from an installed + * package) give way to the usual ones ({@link DEFAULT_TOKENS}), with what those import. Undefined + * when that reads the same token files; else `lost` holds the ones it leaves out, the app's own + * first. + */ +async function tokensWithComponents( + config: ResolvedConfig, + tokens: readonly string[], +): Promise<{ lost: string[] } | undefined> { + const usual = await tokenFiles({ + ...config, + tokens: [ + ...DEFAULT_TOKENS.map((p) => ({ path: p })), + ...FALLBACK_TOKENS.map((p) => ({ path: p, fallback: true })), + ], + tokensDetected: true, + }); + const read = new Set(usual.map((f) => relativePath(config.root, f))); + if (read.size === tokens.length && tokens.every((t) => read.has(t))) return undefined; + const lost = tokens.filter((t) => !read.has(t)); + const own = lost.filter((t) => !isInstalledPath(t)); + return { lost: own.length ? own : lost }; } const REGISTRY_DOCS = 'https://onsystem.vercel.app/docs/configuration#code-from-shadcn-registries'; diff --git a/src/workspace.ts b/src/workspace.ts index b353836d..7746946a 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -167,7 +167,10 @@ async function discoverWorkspace( for (const dir of workspacePackageDirs(root)) { const config = await loadProjectConfig(dir, root, rootConfig.configFile, options); const files = (await componentFiles(config)).length; - const own = config.configFile !== undefined && config.configFile !== rootConfig.configFile; + // A config of its own, or one its design-system package declares, read under the root's. + const own = + (config.configFile !== undefined && config.configFile !== rootConfig.configFile) || + config.designSystemPackage !== undefined; // An app on a published library (MUI) has its design system in node_modules. const library = config.libraries?.some((l) => l.dir !== undefined) ?? false; if (files || own || library) { diff --git a/test/design-system-package.test.ts b/test/design-system-package.test.ts index 171abf25..1274efff 100644 --- a/test/design-system-package.test.ts +++ b/test/design-system-package.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'; import { main, type Io } from '../src/cli.js'; import { DEFAULT_COMPONENTS, DEFAULT_EXCLUDE, loadConfig } from '../src/config.js'; import { DesignSystemHost } from '../src/design-system.js'; +import { WorkspaceProjects } from '../src/server/projects.js'; import { serveStdio } from '../src/server/stdio.js'; import { silentLogger } from '../src/util/log.js'; import { loadTarget } from '../src/workspace.js'; @@ -100,7 +101,7 @@ describe('init in a design-system package', () => { const init = await run(['init', '--yes', '--no-cache'], kit); expect(init.code).toBe(0); expect(init.stdout).toContain( - 'This is a design-system package: package.json declares its config ("onsystem": "./onsystem.config.json", also added to "files"), which every app that depends on @acme/kit reads when it has no config of its own.', + 'This is a design-system package: package.json declares its config ("onsystem": "./onsystem.config.json", also added to "files"), which every app that depends on @acme/kit reads, under its own config when it has one.', ); expect(init.stdout).toContain( 'Apps that install @acme/kit from a registry get only what "files" in package.json publishes, which leaves out src/ (3 component files and 1 token file)', @@ -674,4 +675,77 @@ describe('a config a design-system package declares', () => { await expect(changed).resolves.toEqual(['Button', 'Card', 'Input']); host.close(); }, 15_000); + + it("is read again when it changes, under the app's own config", async () => { + const root = fixture({ + ...MONOREPO, + ...kitAt('packages/kit', {}), + 'apps/web/onsystem.config.json': json({ rules: { 'no-hardcoded-color': 'warn' } }), + }); + const app = path.join(root, 'apps/web'); + const config = await loadConfig({ root: app }); + expect(config.configFile).toBe(path.join(app, 'onsystem.config.json')); + const host = new DesignSystemHost(config, { cache: false }); + await host.get(); + const changed = new Promise((resolve) => { + host.onChange((ds) => { + if (ds.config.rules['no-hardcoded-spacing'].severity === 'error') { + resolve(ds.components.map((c) => c.name).sort()); + } + }); + }); + host.watch(); + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync( + path.join(root, 'packages/kit/onsystem.config.json'), + json({ rules: { 'no-hardcoded-spacing': 'error' } }), + ); + await expect(changed).resolves.toEqual(['Button', 'Card', 'Input']); + expect((await host.get()).config.rules['no-hardcoded-color'].severity).toBe('warn'); + host.close(); + }, 15_000); + + it('is read again when it changes, under a monorepo root config, by the server at the root', async () => { + const root = fixture({ + ...MONOREPO, + ...kitAt('packages/kit', {}), + 'onsystem.config.json': json({ rules: { 'no-hardcoded-color': 'warn' } }), + }); + const { workspace } = await loadTarget({ cwd: root }); + if (!workspace) throw new Error('expected a workspace'); + const page = 'apps/web/app/page.tsx'; + const severity = async () => { + const { config } = (await projects.forPath(page)).ds; + return config.rules['no-hardcoded-spacing'].severity; + }; + let reloaded: () => void = () => undefined; + const projects = new WorkspaceProjects(workspace, { + cache: false, + watch: true, + logger: silentLogger, + onChange: () => { + reloaded(); + }, + }); + const { ds } = await projects.forPath(page); + expect(ds.config.configFile).toBe(path.join(root, 'onsystem.config.json')); + expect(ds.config.designSystemPackage?.name).toBe('@acme/kit'); + const changed = new Promise((resolve) => { + reloaded = () => { + void severity().then((s) => { + if (s === 'error') resolve(); + }); + }; + }); + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync( + path.join(root, 'packages/kit/onsystem.config.json'), + json({ rules: { 'no-hardcoded-spacing': 'error' } }), + ); + await changed; + const after = (await projects.forPath(page)).ds; + expect(after.config.rules['no-hardcoded-color'].severity).toBe('warn'); + expect(after.components.map((c) => c.name).sort()).toEqual(['Button', 'Card', 'Input']); + projects.close(); + }, 15_000); }); diff --git a/test/design-system-packages-docs.test.ts b/test/design-system-packages-docs.test.ts index 6ec17dda..ab90f2d0 100644 --- a/test/design-system-packages-docs.test.ts +++ b/test/design-system-packages-docs.test.ts @@ -7,7 +7,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { describe, expect, it } from 'vitest'; import { main, type Io } from '../src/cli.js'; -import { RULE_IDS } from '../src/config.js'; +import { loadConfig, RULE_IDS } from '../src/config.js'; import { createServer } from '../src/server/index.js'; import { fixture, load, TSCONFIG } from './helpers.js'; import { section } from './markdown.js'; @@ -620,6 +620,36 @@ describe("an app's own config (docs/design-system-packages.md#an-apps-own-config expect(inTheApps).toContain(`\`\`\`text\n${line}\n\`\`\``); }); + it("adds the package's exclude patterns to its own, and, with the package's components, its elements over the package's", async () => { + expect(inTheApps).toContain( + "Its `rules` apply over the package's, rule by rule, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own", + ); + const pkg = withConfig(published(await initialised()), (config) => ({ + ...config, + exclude: ['**/*.fixture.tsx'], + elements: { a: 'Link', img: 'Image' }, + })); + const color = 'export default () =>
x
;\n'; + const extra = { 'app/x.fixture.tsx': color, 'app/legacy/old.tsx': color }; + /** The files `check .` reports findings in. */ + const reported = async (app: string) => { + const { stdout } = await run(['check', '.', '--format', 'json', '--no-cache'], app); + return (JSON.parse(stdout) as { file: string; diagnostics: unknown[] }[]) + .filter((r) => r.diagnostics.length) + .map((r) => r.file) + .sort(); + }; + // Without a config, then with one that has an exclude list of its own. + expect(await reported(installedApp(pkg, extra))).toEqual([ + 'app/legacy/old.tsx', + 'app/page.tsx', + ]); + const own = json({ exclude: ['app/legacy/**'], elements: { img: 'Picture' } }); + const app = installedApp(pkg, { ...extra, 'onsystem.config.json': own }); + expect(await reported(app)).toEqual(['app/page.tsx']); + expect((await loadConfig({ root: app })).elements).toEqual({ a: 'Link', img: 'Picture' }); + }); + it('is read alone with "extends": false, which can leave the app without the components and tokens of a package whose exports point at dist/', async () => { const pkg = await initialised(); const app = installedApp(withoutTypes(published(pkg)), { 'onsystem.config.json': alone }); diff --git a/test/layered-config.test.ts b/test/layered-config.test.ts index f0777782..b18aa311 100644 --- a/test/layered-config.test.ts +++ b/test/layered-config.test.ts @@ -300,6 +300,44 @@ describe("a workspace root's config, on a design-system package that declares on ]); }); + it("lists a project whose package's config gives it tokens alone among those with a design system", async () => { + // An installed package published as dist/ without type declarations: its config gives the + // app its tokens and no component file. + const root = fixture({ + 'pnpm-workspace.yaml': 'packages:\n - apps/*\n', + 'package.json': json({ name: 'acme', private: true }), + 'onsystem.config.json': json({ rules: { 'no-hardcoded-spacing': 'off' } }), + 'apps/docs/package.json': json({ name: 'docs' }), + 'apps/web/package.json': json({ name: 'web', dependencies: { '@acme/ui': '1.0.0' } }), + 'apps/web/app/page.tsx': + 'export default () =>
x
;\n', + ...at('apps/web/node_modules/@acme/ui', { + 'package.json': json({ + name: '@acme/ui', + version: '1.0.0', + files: ['dist', 'theme.css', 'onsystem.config.json'], + exports: { '.': './dist/index.mjs', './theme.css': './theme.css' }, + onsystem: './onsystem.config.json', + }), + 'onsystem.config.json': json({ importPath: '@acme/ui', tokens: ['theme.css'] }), + 'theme.css': THEME, + 'dist/index.mjs': 'export function Button() {}\n', + }), + }); + const inspect = await run(['inspect', '--no-cache'], root); + expect(inspect.stdout).toContain( + '2 packages (pnpm-workspace.yaml), 1 project with a design system', + ); + expect(inspect.stdout).toContain( + `apps/web (web)\n config onsystem.config.json, over ${path.join('apps', 'web', 'node_modules', '@acme', 'ui', 'onsystem.config.json')} (declared by @acme/ui)\n`, + ); + expect(inspect.stdout).toContain('Without a design system (1): apps/docs'); + // Its tokens are checked, with the root's rules over the package's. + expect((await check(root)).findings).toEqual([ + 'apps/web/app/page.tsx:1 no-hardcoded-color error', + ]); + }); + it('reads no package config under it with "extends": false', async () => { const root = monorepo({ extends: false, rules: { 'no-hardcoded-spacing': 'off' } }); const { workspace } = await loadTarget({ cwd: root }); @@ -404,6 +442,131 @@ describe('init in an app that zero config reads an installed design-system packa }); }); +describe("init in an app whose own stylesheet imports an installed design-system package's", () => { + /** `@acme/ui` as published: compiled code, its type declarations and its theme. */ + const PUBLISHED = { + 'package.json': json({ + name: '@acme/ui', + version: '1.0.0', + types: './dist/index.d.ts', + exports: { + '.': { types: './dist/index.d.ts', import: './dist/index.mjs' }, + './theme.css': './theme.css', + }, + }), + 'theme.css': '@theme {\n --color-primary: oklch(0.55 0.2 260);\n --spacing: 0.25rem;\n}\n', + 'dist/index.mjs': 'export function Card() {}\n', + 'dist/index.d.ts': + "import * as react from 'react';\ndeclare function Card(props: react.HTMLAttributes): react.JSX.Element;\nexport { Card };\n", + }; + + /** + * A shadcn app in @acme's npm scope, so zero config reads @acme/ui from its type declarations + * and resolves its theme: components.json names `css`, which imports the theme. @acme/ui is + * installed as npm does it, or as pnpm does, in a folder of its own under node_modules/.pnpm. + */ + function shadcnApp(layout: 'npm' | 'pnpm', css: string): string { + const app = fixture( + { + 'package.json': json({ + name: '@acme/web', + dependencies: { '@acme/ui': '1.0.0', react: '19.0.0' }, + }), + 'tsconfig.json': TSCONFIG, + 'components.json': json({ + style: 'new-york', + tsx: true, + tailwind: { css, baseColor: 'neutral', cssVariables: true }, + aliases: { components: '@/components', ui: '@/components/ui' }, + }), + 'components/ui/button.tsx': BUTTON, + 'components/ui/input.tsx': INPUT, + [css]: + '@import "tailwindcss";\n@import "@acme/ui/theme.css";\n@theme {\n --color-brand: oklch(0.7 0.1 150);\n}\n', + 'app/page.tsx': + 'import { Button } from "@/components/ui/button";\nexport default () =>
;\n', + }, + { nodeModules: true }, + ); + installUi(app, layout, '1.0.0'); + return app; + } + + /** Installs @acme/ui at `version` in place of the one installed. */ + function installUi(app: string, layout: 'npm' | 'pnpm', version: string): void { + const link = path.join(app, 'node_modules/@acme/ui'); + fs.rmSync(link, { force: true, recursive: true }); + fs.rmSync(path.join(app, 'node_modules/.pnpm'), { force: true, recursive: true }); + const dir = + layout === 'npm' + ? link + : path.join( + app, + `node_modules/.pnpm/@acme+ui@${version}_react@19.0.0/node_modules/@acme/ui`, + ); + for (const [file, text] of Object.entries(PUBLISHED)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text.replace('"1.0.0"', `"${version}"`)); + } + if (layout === 'pnpm') { + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(dir, link, 'junction'); + } + } + + /** `init --yes` in the app: what it says and writes, and what `check` then reports. */ + async function init(app: string) { + const zero = await check(app, 'app/page.tsx'); + const { code, stdout } = await run(['init', '--yes', '--no-cache'], app); + expect(code).toBe(0); + fs.rmSync(path.join(app, 'onsystem.baseline.json')); + const written = fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8'); + return { zero, stdout, written, after: await check(app, 'app/page.tsx') }; + } + + it.each(['npm', 'pnpm'] as const)( + 'leaves components to zero config as well when the stylesheet is not one of the usual ones (%s)', + async (layout) => { + const app = shadcnApp(layout, 'app/styles/tailwind.css'); + const { zero, stdout, written, after } = await init(app); + expect(zero.rules).toEqual( + new Set(['no-unknown-prop', 'no-hardcoded-color', 'prefer-design-system-component']), + ); + expect(stdout).toContain( + '"tokens" is left to zero config: some of the token files come from a package installed in node_modules, which zero config finds again after each upgrade', + ); + expect(stdout).toContain( + '"components" is left to zero config as well: a config that names them reads the tokens only from the usual stylesheets (app/globals.css and the like), which leave out app/styles/tailwind.css.', + ); + expect(JSON.parse(written)).toEqual({ $schema: 'https://unpkg.com/onsystem/schema.json' }); + expect(after.findings).toEqual(zero.findings); + const inspect = await run(['inspect', '--no-cache'], app); + expect(inspect.stdout).toContain('files 2 component files, 2 token files'); + + // A new version moves pnpm's folder; zero config finds the theme again. + installUi(app, layout, '1.1.0'); + expect((await check(app, 'app/page.tsx')).findings).toEqual(zero.findings); + }, + ); + + it.each(['npm', 'pnpm'] as const)( + 'writes the components when the usual stylesheets lead to the same tokens (%s)', + async (layout) => { + const app = shadcnApp(layout, 'app/globals.css'); + const { zero, stdout, written, after } = await init(app); + expect(zero.rules).toContain('no-hardcoded-color'); + expect(stdout).not.toContain('"components" is left to zero config'); + expect(JSON.parse(written)).toEqual({ + $schema: 'https://unpkg.com/onsystem/schema.json', + components: ['components/ui/**/*.{tsx,jsx}'], + }); + expect(after.findings).toEqual(zero.findings); + installUi(app, layout, '1.1.0'); + expect((await check(app, 'app/page.tsx')).findings).toEqual(zero.findings); + }, + ); +}); + describe('init in a design-system package published as dist/', () => { const DTS = `import * as react from 'react'; import * as react_jsx_runtime from 'react/jsx-runtime'; From f18f4d507c5e4d861c4ab7ec8e683d3de786cb16 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 23 Oct 2026 22:22:31 +0100 Subject: [PATCH 3/5] fix(config): an app config with components of its own keeps its own tokens and libraries An app config that sets "components" but leaves "tokens" out took the design-system package's tokens instead of zero config's stylesheets, so the app's own app/globals.css, and what it imports, was no longer read. With {"components":["components/ui/**/*.tsx"]}, an app/globals.css that imports @acme/ui/theme.css and adds --color-brand and --radius-card, and @acme/ui declaring tokens ["theme.css"], inspect went from 11 tokens to 2 and check . from 2 errors and 1 warning (exit 1) to 2 warnings (exit 0). init writes such a config ({components}, no tokens) when the stylesheet imports an installed package. "libraries" fell back to the package's the same way, so a package's "libraries": false turned MUI off in such an app. - The package's tokens and libraries now come only with its components, as importPath, docs and elements already did: a config that sets components of its own reads them as on main. - "extends": "@acme/ui" says that extends is true or false and that the package is found from the dependencies, instead of "expected boolean, received string". - inspect --explain with "extends": false names the dependency whose config was left out, and why. - Docs, the changeset, the extends description (config.ts, schema.json) and init's note in a design-system package say so. --- .changeset/layered-configs.md | 2 +- docs/configuration.md | 4 +- docs/design-system-packages.md | 2 +- schema.json | 2 +- src/config.ts | 24 +++++-- src/explain.ts | 18 +++-- src/init.ts | 2 +- test/design-system-packages-docs.test.ts | 23 +++++- test/layered-config.test.ts | 90 ++++++++++++++++++++++-- 9 files changed, 145 insertions(+), 22 deletions(-) diff --git a/.changeset/layered-configs.md b/.changeset/layered-configs.md index bd54bc90..577120a2 100644 --- a/.changeset/layered-configs.md +++ b/.changeset/layered-configs.md @@ -2,7 +2,7 @@ 'onsystem': minor --- -An app's own config builds on the config its design-system package declares, instead of replacing it. What the app's config leaves out comes from the package's: `components`, with the package's `importPath`, `docs` and `elements` unless the app's config sets `components` of its own; `tokens`; and `libraries`. Its `rules` apply over the package's, rule by rule, and, with the package's components, its `elements` element by element; the package's `exclude` patterns are added to the app's; its `overrides`, `tsconfig` and `includeDesignSystem` are its own. Before, an app config that only turned one rule to `"warn"` lost the package's components and color tokens: in an app that installs the package, `check` said `No design system found` and passed. `inspect` names both configs (`config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)`). `"extends": false` in the app's config reads it alone, as before. This can change what `check` reports in an app with a config of its own whose dependency declares one: it now gets the package's components, tokens and rules where its config leaves them out. A workspace package whose `"onsystem"` field cannot be read is a warning in such an app, which then reads its own config alone, and still an error in an app without one. +An app's own config builds on the config its design-system package declares, instead of replacing it. What the app's config leaves out comes from the package's, unless it sets `components` of its own: `components`, with the package's `importPath`, `docs`, `elements`, `tokens` and `libraries`. A config that sets `components` reads its tokens as before: without `tokens`, from the app's own stylesheet (`app/globals.css`) and the ones it imports. Its `rules` apply over the package's, rule by rule, either way, and, with the package's components, its `elements` element by element; the package's `exclude` patterns are added to the app's; its `overrides`, `tsconfig` and `includeDesignSystem` are its own. Before, an app config that only turned one rule to `"warn"` lost the package's components and color tokens: in an app that installs the package, `check` said `No design system found` and passed. `inspect` names both configs (`config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui)`). `"extends": false` in the app's config reads it alone, as before. This can change what `check` reports in an app with a config of its own whose dependency declares one: it now gets the package's rules where its config leaves them out, and the package's components and tokens when it sets no `components` of its own. A workspace package whose `"onsystem"` field cannot be read is a warning in such an app, which then reads its own config alone, and still an error in an app without one. A monorepo root's config builds on a project's design-system package config the same way in `check` and `inspect` from the root, so its `rules` and `overrides` apply to every project without a config of its own. Before, the config a project's design-system package declared took their place, without a word. From the project's folder, the root's config is still not read. diff --git a/docs/configuration.md b/docs/configuration.md index 1a3b4aaf..ddc0ecc1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,7 +4,7 @@ Most projects need no config: onsystem finds the design system the way your app ## Zero config -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)). A config file of its own builds on the package's: what it leaves out, such as `components`, `importPath` and `tokens`, comes from there, and its `rules` apply over the package's; `"extends": false` reads it alone ([an app's own config](design-system-packages.md#an-apps-own-config)). 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: +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)). A config file of its own builds on the package's: its `rules` apply over the package's, and, unless it sets `components` of its own, what it leaves out, such as `components`, `importPath` and `tokens`, comes from there; `"extends": false` reads it alone ([an app's own config](design-system-packages.md#an-apps-own-config)). 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. 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. @@ -71,7 +71,7 @@ The root stays one project, as before, when it has a design system of its own (a | `includeDesignSystem` | `false`: `check` skips the component files themselves ([CI](ci.md)) | | `tsconfig` | `tsconfig.json` in the root | | `libraries` | Every [published library](#published-libraries-mui) onsystem reads that the project depends on (`mui`), and the design-system packages read from their type declarations. `false` reads none of them; a list such as `["mui"]` names libraries only: it reads those, and the packages | -| `extends` | `true`: the config builds on the one a design-system package declares, when a dependency declares one ([an app's own config](design-system-packages.md#an-apps-own-config)). `false` reads it alone | +| `extends` | `true`: the config builds on the one a design-system package declares, when a dependency declares one ([an app's own config](design-system-packages.md#an-apps-own-config)); the package is found from the dependencies, not named here. `false` reads it alone | | `overrides` | None. Each entry's `rules` apply over `rules` to the files its `files` globs match, relative to the root; later entries win. Unknown keys and rules are named with the closest valid one | Paths and globs are relative to the root and use forward slashes. Windows-style backslashes (`components\ui\**\*.tsx`, `.\tsconfig.app.json`) are read as separators, except in a pattern that already uses `/`, where `\` escapes glob syntax (`app/\(marketing\)/**`). In `exclude`, a folder leaves out everything in it: `app/legacy`, `./app/legacy/` and `app/legacy/**` are the same, and `**/legacy` is every `legacy` folder. `check` in folders and globs, `check .` at a monorepo root, the [ESLint plugin](eslint.md), the [Claude Code hook](plugin.md#what-the-hook-does) and `check_ui` all leave out the same files. A `tsconfig` that does not exist is a config error rather than a silent fallback. Component, token and docs files may lie outside the root, through `../`, an absolute path or a link, as far as the workspace or git repository the project is in, or in a package installed in their `node_modules`; a file whose real path is anywhere else is not read, and `inspect` says so (`Not read: docs/credentials.md leads outside the project.`). diff --git a/docs/design-system-packages.md b/docs/design-system-packages.md index a186c5bf..3dd9247c 100644 --- a/docs/design-system-packages.md +++ b/docs/design-system-packages.md @@ -57,7 +57,7 @@ A project reads up to two configs, the first over the second, and zero config fi ### An app's own config -An app's own config builds on the package's: what it leaves out comes from there. `components` brings the package's `importPath`, `docs` and `elements` with it, so a config that sets `components` of its own takes none of those from the package; `tokens` and `libraries` come from the package unless the app's config sets them. Its `rules` apply over the package's, rule by rule, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own, as the package's stay with the package. So a config that only changes a rule's severity, `{ "rules": { "no-hardcoded-color": "warn" } }`, keeps the package's components and tokens, and every other rule as the package sets it. A [monorepo root](configuration.md#monorepo-roots)'s config, from the root, builds on the package's the same way, so its `rules` and `overrides` (`"files": ["apps/admin/legacy/**"]`) apply in every app without a config of its own. `inspect` names both configs: +An app's own config builds on the package's: what it leaves out comes from there. `components` brings the package's `importPath`, `docs`, `elements`, `tokens` and `libraries` with it, each unless the app's config sets it, so a config that sets `components` of its own takes none of those from the package. Zero config fills in what such a config leaves out, as in any project: without `tokens`, it reads the app's own stylesheet (`app/globals.css`) and the ones it `@import`s, the package's theme among them when it imports it. Its `rules` apply over the package's, rule by rule, either way, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own, as the package's stay with the package. So a config that only changes a rule's severity, `{ "rules": { "no-hardcoded-color": "warn" } }`, keeps the package's components and tokens, and every other rule as the package sets it. A [monorepo root](configuration.md#monorepo-roots)'s config, from the root, builds on the package's the same way, so its `rules` and `overrides` (`"files": ["apps/admin/legacy/**"]`) apply in every app without a config of its own. `inspect` names both configs: ```text config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui) diff --git a/schema.json b/schema.json index 8f024a4e..5479e9bc 100644 --- a/schema.json +++ b/schema.json @@ -8,7 +8,7 @@ "type": "string" }, "extends": { - "description": "Whether this config builds on the one a design-system package declares (\"onsystem\" in the package.json of a dependency): what it leaves out comes from there (components with their importPath, docs and elements; tokens; libraries), and its rules apply over the package's. Default: true. false reads this config alone.", + "description": "Whether this config builds on the one a design-system package declares (\"onsystem\" in the package.json of a dependency, found from the dependencies): its rules apply over the package's, and, unless it sets components of its own, what it leaves out comes from the package's (components with their importPath, docs, elements, tokens and libraries). Default: true. false reads this config alone.", "type": "boolean" }, "components": { diff --git a/src/config.ts b/src/config.ts index 5606bfa9..c39de167 100644 --- a/src/config.ts +++ b/src/config.ts @@ -131,10 +131,14 @@ const librariesSchema = z.union([z.literal(false), z.array(z.enum(LIBRARY_IDS))] const configShape = { $schema: z.string().optional(), extends: z - .boolean() + .boolean({ + // `"extends": "@acme/ui"` otherwise reads "expected boolean, received string". + error: (issue) => + `"extends" is true or false, not ${JSON.stringify(issue.input)}: the design-system package is found from the dependencies, the one whose package.json declares its config ("onsystem"). Leave "extends" out to build on it, or set it to false to read this config alone.`, + }) .optional() .describe( - 'Whether this config builds on the one a design-system package declares ("onsystem" in the package.json of a dependency): what it leaves out comes from there (components with their importPath, docs and elements; tokens; libraries), and its rules apply over the package\'s. Default: true. false reads this config alone.', + 'Whether this config builds on the one a design-system package declares ("onsystem" in the package.json of a dependency, found from the dependencies): its rules apply over the package\'s, and, unless it sets components of its own, what it leaves out comes from the package\'s (components with their importPath, docs, elements, tokens and libraries). Default: true. false reads this config alone.', ), components: globsSchema .optional() @@ -282,6 +286,8 @@ export interface ResolvedConfig { * `includeDesignSystem` stay with it. */ designSystemPackage?: DesignSystemPackage | undefined; + /** The project's config sets `"extends": false`, so no design-system package's config is read. */ + extendsOff?: boolean | undefined; /** `components` comes from the config file or `--components`, not from detection or the defaults. */ configured?: boolean | undefined; /** @@ -515,13 +521,18 @@ export async function loadConfigWith( `${declared.name}'s onsystem config names paths outside ${declared.installed ? 'the package' : 'the workspace'}, which were ignored: ${outside.join(', ')}.`, ); } - // The design system's components, and what goes with them (importPath, docs, elements), come - // from the package's config unless this one names components of its own. + // The design system's components, and what goes with them (importPath, docs, elements, tokens, + // libraries), come from the package's config unless this one names components of its own: + // then zero config fills in what it leaves out, such as the app's own stylesheet for tokens. const inherits = pkg !== undefined && config.components === undefined; const configComponents = config.components === undefined ? packageComponents : toArray(config.components).map(fromBase); const configTokens = - config.tokens === undefined ? packageTokens : toArray(config.tokens).map(tokensFrom(fromBase)); + config.tokens === undefined + ? inherits + ? packageTokens + : undefined + : toArray(config.tokens).map(tokensFrom(fromBase)); const configDocs = config.docs === undefined ? inherits @@ -529,7 +540,7 @@ export async function loadConfigWith( : undefined : toArray(config.docs).map(fromBase); const importPath = config.importPath ?? (inherits ? pkg.importPath : undefined); - const librariesSetting = config.libraries ?? pkg?.libraries; + const librariesSetting = config.libraries ?? (inherits ? pkg.libraries : undefined); // The package's rules, then this config's over them. const rules = {} as Record; @@ -668,6 +679,7 @@ export async function loadConfigWith( overrides, ...(base !== root ? { base } : {}), ...(declared ? { designSystemPackage: declared } : {}), + ...(config.extends === false ? { extendsOff: true } : {}), configured: componentsSet, ...(deprecations.length ? { deprecations } : {}), ...(warnings.length ? { warnings } : {}), diff --git a/src/explain.ts b/src/explain.ts index d44f19e9..b831f179 100644 --- a/src/explain.ts +++ b/src/explain.ts @@ -239,12 +239,22 @@ function display(cwd: string, file: string): string { */ function packageSteps(config: ResolvedConfig, cwd: string): DetectionStep[] { const declared = config.designSystemPackage; - // A config of its own (or --config) read alone: no dependency declares one, or it sets - // `"extends": false`. - if (config.configFile && !declared) return []; + const candidate = "a design-system package's config"; + // A config of its own (or --config) read alone: it sets `"extends": false`, which names the + // package it leaves out, or no dependency declares one. + if (config.configFile && !declared) { + const found = config.extendsOff ? findDesignSystemPackage(config.root)?.found : undefined; + if (!found) return []; + return [ + { + candidate, + accepted: false, + reason: `${found.name} (${found.installed ? 'installed' : 'a workspace package'}) declares ${display(cwd, found.config)}, not read: "extends": false in ${display(cwd, config.configFile)} reads it alone`, + }, + ]; + } const lookup = findDesignSystemPackage(config.root); if (!lookup) return []; - const candidate = "a design-system package's config"; const skipped = lookup.skipped.length ? `; ${lookup.skipped.map((p) => p.name).join(', ')} ${lookup.skipped.length === 1 ? 'declares one' : 'declare one'} that cannot be read, skipped since installed` : ''; diff --git a/src/init.ts b/src/init.ts index 581cdca1..e8d768d9 100644 --- a/src/init.ts +++ b/src/init.ts @@ -889,7 +889,7 @@ function consumerGuide(name: string): string { `npx -y ${NAME} inspect`, '```', '', - `An app with an ${CONFIG_FILE} of its own reads it over ${name}'s config: what it leaves out, such as the components, \`importPath\` and tokens, comes from ${name}'s, and the rules and overrides it sets apply over ${name}'s rules. \`"extends": false\` in it reads it alone.`, + `An app with an ${CONFIG_FILE} of its own reads it over ${name}'s config: the rules and overrides it sets apply over ${name}'s rules, and, unless it sets components of its own, what it leaves out, such as the components, \`importPath\` and tokens, comes from ${name}'s. \`"extends": false\` in it reads it alone.`, '', '## Coding agents', '', diff --git a/test/design-system-packages-docs.test.ts b/test/design-system-packages-docs.test.ts index ab90f2d0..bdae60b0 100644 --- a/test/design-system-packages-docs.test.ts +++ b/test/design-system-packages-docs.test.ts @@ -622,7 +622,7 @@ describe("an app's own config (docs/design-system-packages.md#an-apps-own-config it("adds the package's exclude patterns to its own, and, with the package's components, its elements over the package's", async () => { expect(inTheApps).toContain( - "Its `rules` apply over the package's, rule by rule, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own", + "Its `rules` apply over the package's, rule by rule, either way, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own", ); const pkg = withConfig(published(await initialised()), (config) => ({ ...config, @@ -650,6 +650,27 @@ describe("an app's own config (docs/design-system-packages.md#an-apps-own-config expect((await loadConfig({ root: app })).elements).toEqual({ a: 'Link', img: 'Picture' }); }); + it("takes none of what goes with the package's components with components of its own, and reads the app's own stylesheet", async () => { + expect(inTheApps).toContain( + "`components` brings the package's `importPath`, `docs`, `elements`, `tokens` and `libraries` with it, each unless the app's config sets it, so a config that sets `components` of its own takes none of those from the package. Zero config fills in what such a config leaves out, as in any project: without `tokens`, it reads the app's own stylesheet (`app/globals.css`) and the ones it `@import`s, the package's theme among them when it imports it.", + ); + const app = installedApp(published(await initialised()), { + 'onsystem.config.json': json({ components: ['components/ui/**/*.tsx'] }), + 'components/ui/button.tsx': PACKAGE['src/button.tsx'], + 'app/globals.css': '@import "@acme/ui/theme.css";\n@theme {\n --color-brand: #1a7f37;\n}\n', + 'app/page.tsx': 'export default () =>
x
;\n', + }); + const config = await loadConfig({ root: app }); + expect(config.importPath).toBeUndefined(); + expect(config.tokens.map((t) => t.path)).toContain('app/globals.css'); + const { findings } = await check(app); + // The app's own token, and the spacing scale of the package's theme it imports. + expect(findings.map((d) => d.message)).toContain( + 'Hardcoded color `bg-[#1a7f37]`. Matches token color-brand (exact match) → `bg-brand`.', + ); + expect(findings.map((d) => d.ruleId)).toContain('no-hardcoded-spacing'); + }); + it('is read alone with "extends": false, which can leave the app without the components and tokens of a package whose exports point at dist/', async () => { const pkg = await initialised(); const app = installedApp(withoutTypes(published(pkg)), { 'onsystem.config.json': alone }); diff --git a/test/layered-config.test.ts b/test/layered-config.test.ts index b18aa311..52f1fe7e 100644 --- a/test/layered-config.test.ts +++ b/test/layered-config.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { main, type Io } from '../src/cli.js'; -import { DEFAULT_DOCS, loadConfig } from '../src/config.js'; +import { DEFAULT_DOCS, DEFAULT_TOKENS, FALLBACK_TOKENS, loadConfig } from '../src/config.js'; import { loadTarget } from '../src/workspace.js'; import { fixture, TSCONFIG } from './helpers.js'; @@ -175,7 +175,7 @@ describe("an app's own config, on a design-system package that declares one", () expect(rules).not.toContain('no-unknown-variant'); }); - it("takes none of the package's importPath, docs or elements with components of its own, but keeps its tokens", async () => { + it("takes none of the package's importPath, docs, elements, tokens or libraries with components of its own", async () => { const pkg = acmeUi({ ...INIT_CONFIG, docs: ['docs/**/*.md'], @@ -193,16 +193,83 @@ describe("an app's own config, on a design-system package that declares one", () expect(own.imports).toEqual([]); expect(own.elements).toEqual({}); expect(own.docs).toEqual(DEFAULT_DOCS); - expect(own.tokens.map((t) => t.path)).toEqual(['node_modules/@acme/ui/theme.css']); - expect(own.libraryIds).toBe(false); + // Zero config's stylesheets, as in an app whose dependency declares no config. + expect(own.tokens.map((t) => t.path)).toEqual([...DEFAULT_TOKENS, ...FALLBACK_TOKENS]); + expect(own.tokensDetected).toBe(true); + expect(own.libraryIds).toBeUndefined(); - // Without components of its own, the app adds to the package's elements. + // Without components of its own, the app adds to the package's elements, and takes the + // rest of what goes with the package's components. const added = await loadConfig({ root: installedApp(pkg, { 'onsystem.config.json': json({ elements: { img: 'Image' } }) }), }); expect(added.elements).toEqual({ a: 'Link', img: 'Image' }); expect(added.docs).toEqual(['node_modules/@acme/ui/docs/**/*.md']); expect(added.importPath).toBe('@acme/ui'); + expect(added.tokens.map((t) => t.path)).toEqual(['node_modules/@acme/ui/theme.css']); + expect(added.libraryIds).toBe(false); + }); + + it("reads its own stylesheet's tokens with components of its own, as it did before", async () => { + // The app's stylesheet imports the package's theme and adds tokens of its own; its + // config names its own shadcn components and leaves tokens to zero config. + const app = installedApp(acmeUi(INIT_CONFIG), { + 'onsystem.config.json': json({ components: ['components/ui/**/*.tsx'] }), + 'tsconfig.json': TSCONFIG, + 'components/ui/button.tsx': BUTTON, + 'app/globals.css': + '@import "tailwindcss";\n@import "@acme/ui/theme.css";\n@theme {\n --color-brand: #1a7f37;\n --radius-card: 0.875rem;\n}\n', + 'app/page.tsx': + 'import { Button } from "@/components/ui/button";\nexport default () =>
;\n', + }); + const config = await loadConfig({ root: app }); + expect(config.designSystemPackage?.name).toBe('@acme/ui'); + expect(config.tokens.map((t) => t.path)).toContain('app/globals.css'); + const { code, stdout } = await run(['check', '.', '--format', 'json', '--no-cache'], app); + const messages = (JSON.parse(stdout) as Results).flatMap((r) => + r.diagnostics.map((d) => `${d.ruleId} ${d.severity}: ${d.message}`), + ); + // --color-brand and --radius-card are the app's own tokens. + expect(messages).toContain( + 'no-hardcoded-color error: Hardcoded color `bg-[#1a7f37]`. Matches token color-brand (exact match) → `bg-brand`.', + ); + expect(messages).toContain( + 'no-hardcoded-radius warning: `rounded-[14px]` is 14px, which is on the radius scale: use `rounded-card`.', + ); + // --spacing comes from the package's theme, which the stylesheet imports. + expect( + messages.some((m) => m.startsWith('no-hardcoded-spacing ') && m.includes('p-[13px]')), + ).toBe(true); + expect(code).toBe(1); + }); + + it('keeps the published libraries on when the package turns them off, with components of its own', async () => { + // The app depends on MUI (not installed here: the setting is what is tested). + const pkg = acmeUi({ ...INIT_CONFIG, libraries: false }); + const files = (config: Record) => ({ + 'package.json': json({ + name: 'web', + dependencies: { '@acme/ui': '1.0.0', '@mui/material': '^9.4.0' }, + }), + 'onsystem.config.json': json(config), + 'components/ui/button.tsx': BUTTON, + }); + const names = (libraries: { package: string }[] | undefined) => + (libraries ?? []).map((l) => l.package); + const own = await loadConfig({ + root: installedApp(pkg, files({ components: ['components/ui/**/*.tsx'] })), + }); + expect(own.libraryIds).toBeUndefined(); + expect(names(own.libraries)).toContain('@mui/material'); + expect(names(own.librariesOff)).toEqual([]); + // With the package's components, its `libraries` applies, unless the app's config sets one. + const inherited = await loadConfig({ root: installedApp(pkg, files({})) }); + expect(inherited.libraryIds).toBe(false); + expect(names(inherited.libraries)).toEqual([]); + expect(names(inherited.librariesOff)).toEqual(['@mui/material']); + const set = await loadConfig({ root: installedApp(pkg, files({ libraries: ['mui'] })) }); + expect(set.libraryIds).toEqual(['mui']); + expect(names(set.libraries)).toEqual(['@mui/material']); }); it('is read alone with "extends": false, as an app config was before', async () => { @@ -219,6 +286,19 @@ describe("an app's own config, on a design-system package that declares one", () expect((await run(['inspect', '--no-cache'], app)).stdout).toContain( 'config onsystem.config.json\n', ); + // --explain names the package it leaves out, and why. + expect((await run(['inspect', '--explain', '--no-cache'], app)).stdout).toContain( + `no a design-system package's config: @acme/ui (installed) declares node_modules/@acme/ui/onsystem.config.json, not read: "extends": false in onsystem.config.json reads it alone`, + ); + }); + + it('says what "extends" takes when it names a package', async () => { + const app = installedApp(acmeUi(INIT_CONFIG), { + 'onsystem.config.json': json({ extends: '@acme/ui' }), + }); + await expect(loadConfig({ root: app })).rejects.toThrow( + '"extends" is true or false, not "@acme/ui": the design-system package is found from the dependencies, the one whose package.json declares its config ("onsystem"). Leave "extends" out to build on it, or set it to false to read this config alone.', + ); }); it("warns, and reads its own config alone, when a workspace package's declaration cannot be read", async () => { From d0c8a0c28b0c1fcc92533ccba10fe8636c1880f8 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 23 Oct 2026 23:15:48 +0100 Subject: [PATCH 4/5] fix(init): keep an app config's own components with --force, and name the package's config in --explain init --force in an app whose config names components of its own wrote a config with $schema only whenever a dependency declared a config, so the app took the package's components, checked its own components as app code, and lost its own tokens. It now writes $schema only when the app's config takes the package's components: it sets no components, or ones that match no file outside the package. A config with components of the app's own is written out again, as on main. inspect --explain named the app's config as where the components come from when they came from the package's. ResolvedConfig.inherits says which one it is. The docs say that an app taking the package's components reads only the package's tokens, with the workaround, that the two configs layer field by field, and which apps init --force writes $schema alone for. --- .changeset/layered-configs.md | 2 +- docs/design-system-packages.md | 6 +- src/config.ts | 6 ++ src/explain.ts | 11 +++- src/init.ts | 29 +++++++++- test/design-system-packages-docs.test.ts | 62 +++++++++++++++++++- test/layered-config.test.ts | 74 ++++++++++++++++++++++++ 7 files changed, 180 insertions(+), 10 deletions(-) diff --git a/.changeset/layered-configs.md b/.changeset/layered-configs.md index 577120a2..46ffcbde 100644 --- a/.changeset/layered-configs.md +++ b/.changeset/layered-configs.md @@ -6,7 +6,7 @@ An app's own config builds on the config its design-system package declares, ins A monorepo root's config builds on a project's design-system package config the same way in `check` and `inspect` from the root, so its `rules` and `overrides` apply to every project without a config of its own. Before, the config a project's design-system package declared took their place, without a word. From the project's folder, the root's config is still not read. -`init --force` in an app on such a package writes a config with only `$schema`, for the app's own rules and overrides, and keeps the package's components, `importPath`, tokens and rules. Before, it wrote the package's paths into the app's config, or a config that lost the package's components. +`init --force` in an app on such a package, without a config or with one whose `components` match no file outside the package, writes a config with only `$schema`, for the app's own rules and overrides, and keeps the package's components, `importPath`, tokens and rules. Before, it wrote the package's paths into the app's config, or a config that lost the package's components. In an app whose config names components of its own, it writes them again, as before. `init` in an app whose design system zero config reads from an installed package leaves `components` and `tokens` to zero config. It wrote paths into the package's folder, which pnpm names by version (`node_modules/.pnpm/@acme+ui@1.0.0_react@19.0.0/node_modules/@acme/ui/…`), so after an upgrade `check` failed with `Token file not found`, and the component files it listed matched nothing. diff --git a/docs/design-system-packages.md b/docs/design-system-packages.md index 3dd9247c..151af03d 100644 --- a/docs/design-system-packages.md +++ b/docs/design-system-packages.md @@ -48,7 +48,7 @@ detected workspace package @acme/ui - **What stays with the package:** `tsconfig` (the app keeps its own), `overrides` (their globs are about the package's files) and `includeDesignSystem`. - **A missing file is not an error in the app.** A token file the config names that is not there matches nothing, since a published package ships only some of its files. In the package itself it is an error, so a typo is caught there. -A project reads up to two configs, the first over the second, and zero config fills in the fields neither sets: +A project reads up to two configs, the first over the second, field by field as [an app's own config](#an-apps-own-config) says, and zero config fills in the rest: 1. Its own config file (`onsystem.config.json`, or another [config file](configuration.md#config-file) name), or one its own `package.json` names in `"onsystem"`. Without one, when it is checked from a [monorepo root](configuration.md#monorepo-roots), the root's config. 2. The config a dependency declares. When several do, the one the app's code imports most. @@ -57,13 +57,13 @@ A project reads up to two configs, the first over the second, and zero config fi ### An app's own config -An app's own config builds on the package's: what it leaves out comes from there. `components` brings the package's `importPath`, `docs`, `elements`, `tokens` and `libraries` with it, each unless the app's config sets it, so a config that sets `components` of its own takes none of those from the package. Zero config fills in what such a config leaves out, as in any project: without `tokens`, it reads the app's own stylesheet (`app/globals.css`) and the ones it `@import`s, the package's theme among them when it imports it. Its `rules` apply over the package's, rule by rule, either way, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own, as the package's stay with the package. So a config that only changes a rule's severity, `{ "rules": { "no-hardcoded-color": "warn" } }`, keeps the package's components and tokens, and every other rule as the package sets it. A [monorepo root](configuration.md#monorepo-roots)'s config, from the root, builds on the package's the same way, so its `rules` and `overrides` (`"files": ["apps/admin/legacy/**"]`) apply in every app without a config of its own. `inspect` names both configs: +An app's own config builds on the package's: what it leaves out comes from there. `components` brings the package's `importPath`, `docs`, `elements`, `tokens` and `libraries` with it, each unless the app's config sets it, so a config that sets `components` of its own takes none of those from the package. Zero config fills in what such a config leaves out, as in any project: without `tokens`, it reads the app's own stylesheet (`app/globals.css`) and the ones it `@import`s, the package's theme among them when it imports it. Its `rules` apply over the package's, rule by rule, either way, and, with the package's components, its `elements` apply over the package's, element by element. The package's `exclude` patterns are added to the app's, as they are in an app without a config. Its `overrides`, `tsconfig` and `includeDesignSystem` are its own, as the package's stay with the package. So a config that only changes a rule's severity, `{ "rules": { "no-hardcoded-color": "warn" } }`, keeps the package's components and tokens, and every other rule as the package sets it. An app that takes the package's components this way, or without a config, reads the tokens the package's config names, when it names some, and not its own stylesheet's: tokens the app adds in `app/globals.css` are not read. To read them as well, list both in its config, `"tokens": ["app/globals.css", "node_modules/@acme/ui/theme.css"]` (`../../packages/ui/theme.css` from a workspace app), since a `tokens` list brings none of the stylesheets its files `@import`: the app keeps the package's components. A [monorepo root](configuration.md#monorepo-roots)'s config, from the root, builds on the package's the same way, so its `rules` and `overrides` (`"files": ["apps/admin/legacy/**"]`) apply in every app without a config of its own. `inspect` names both configs: ```text config onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json (declared by @acme/ui) ``` -`init --force` in an app on such a package writes a config with only `$schema` in it, for the app's own rules and overrides, and says what it builds on. +`init --force` in an app on such a package, without a config or with one whose `components` match no file outside the package, writes a config with only `$schema` in it, for the app's own rules and overrides, and says what it builds on. In an app whose config names components of its own, it writes them again, as it does without such a package. `"extends": false` in the app's config reads it alone. Zero config then fills in only the fields it leaves out, as in any project: it finds a package named like a design system, as a workspace package or installed in `node_modules` with `exports` that point at its component sources ([without the config](#without-the-config)), but not one named otherwise (`@acme/kit`) or one whose `exports` point at compiled code: of those, it reads only the type declarations a package publishes, when it declares its config ([what apps get](#what-apps-get)). So such a config that only changes a rule's severity can leave the app without the package's components and tokens, and `check` then says which rules did not run. Copy `components`, `importPath` and `tokens` from the package's config into the app's, with paths into the package: `node_modules/@acme/ui/src/**/*.tsx`, or `../../packages/ui/src/**/*.tsx` from a workspace app. `init` writes no `importPath` for a package that apps import by subpath (`@acme/ui/components/button`), so when the package's config has none, set it to the package's name (`@acme/ui`). Without it, `no-unknown-component`, `no-unknown-prop`, `no-unknown-variant`, and `icon-button-accessible-name` on the package's `;\n', + }); + const before = await check(app); + expect(before.findings).toEqual([ + 'app/page.tsx:2 no-hardcoded-color error', + 'app/page.tsx:2 no-hardcoded-spacing warning', + ]); + const init = await run(['init', '--yes', '--force', '--no-cache'], app); + expect(init.code).toBe(0); + expect(init.stdout).not.toContain('This project reads the config @acme/ui declares'); + expect(JSON.parse(fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8'))).toEqual({ + $schema: 'https://unpkg.com/onsystem/schema.json', + components: ['components/ui/**/*.tsx'], + tokens: ['app/globals.css'], + }); + fs.rmSync(path.join(app, 'onsystem.baseline.json')); + // Its own Button is still a component, not app code, and --color-brand still a token. + expect((await check(app)).findings).toEqual(before.findings); + }); +}); + +describe('inspect --explain in an app on a design-system package', () => { + it("names the package's config as where the components come from, unless the app's config names its own", async () => { + const explain = async (config: string) => { + const app = installedApp(acmeUi(INIT_CONFIG), { + 'onsystem.config.json': config, + 'components/ui/button.tsx': BUTTON, + }); + const { stdout } = await run(['inspect', '--explain', '--no-cache'], app); + return stdout.split('\n').find((line) => line.startsWith(' components come from')); + }; + expect(await explain(ONE_RULE)).toBe( + ' components come from node_modules/@acme/ui/onsystem.config.json, so zero config did not look', + ); + expect(await explain(json({ components: ['components/ui/**/*.tsx'] }))).toBe( + ' components come from onsystem.config.json, over node_modules/@acme/ui/onsystem.config.json, so zero config did not look', + ); + }); }); describe('init in an app that zero config reads an installed design-system package for', () => { From c4dfcd09db4c0ecb2f5ddce70bd40f5037546ebf Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 23 Oct 2026 23:42:42 +0100 Subject: [PATCH 5/5] fix(init): keep an app config's own tokens list with --force, and no package note with --components init --force over a config that takes the package's components and lists its own stylesheet beside the package's theme (the documented way to read both) wrote $schema only and dropped that list: 12 tokens became 3 and the colors went from errors to warnings. It now keeps the list when one of its files is outside the package, and says so. A list of the package's files only is still left to the package's config. With --components, init no longer says the components come from the package's config. --- src/init.ts | 47 +++++++++++++++++++++++++++++++++---- test/layered-config.test.ts | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/init.ts b/src/init.ts index 1a89dacf..b024c14f 100644 --- a/src/init.ts +++ b/src/init.ts @@ -119,7 +119,9 @@ export async function init( const inherited = options.force ? undefined : config.designSystemPackage; // With --force, the config written here builds on the package's, unless the one there names // components of the app's own: those are written out again, as they are without a package. - const declaring = options.force ? config.designSystemPackage : undefined; + // Components given on the command line are the app's own, as in a config without the package. + const declaring = + options.force && !configOptions.components?.length ? config.designSystemPackage : undefined; const replaced = declaring && (config.inherits || !(await namesOwnComponents(config, declaring))) ? declaring @@ -136,7 +138,10 @@ export async function init( if (configOptions.docs?.length) file.docs = configOptions.docs; } else if (replaced) { // What the config leaves out comes from the package's, as it does with none: it holds only - // the app's own rules and overrides, once they are added. + // the app's own rules and overrides, once they are added, and the old config's tokens when + // they name a stylesheet of the app's own beside the package's. + const tokens = existing ? ownTokens(existing, root, replaced) : undefined; + if (tokens) file.tokens = tokens; } else if (workspace) { // At a monorepo root, each project keeps zero config; the file holds what they share. notes.push( @@ -241,7 +246,7 @@ export async function init( fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`); written.push(CONFIG_FILE); } - if (replaced) notes.push(replacedNote(replaced, io.cwd)); + if (replaced) notes.push(replacedNote(replaced, io.cwd, file.tokens !== undefined)); let registry: Unshipped | undefined; if (designSystem && manifest && name) { written.push(...declarePackage(root, manifest, name, options.force, notes)); @@ -651,9 +656,41 @@ function publishedDeclarations( * Said when --force writes a config in an app on a design-system package that declares one: the * new config builds on the package's, which still gives it what it leaves out. */ -function replacedNote(pkg: DesignSystemPackage, cwd: string): string { +function replacedNote(pkg: DesignSystemPackage, cwd: string, keptTokens: boolean): string { const declared = relativePath(cwd, pkg.config) || pkg.config; - return `This project reads the config ${pkg.name} declares (${declared}). With --force it has one of its own, ${CONFIG_FILE}, read over that one: what it leaves out, such as "components", "importPath" and "tokens", still comes from ${pkg.name}'s config, and the rules and overrides you add to it apply over ${pkg.name}'s rules. "extends": false in it reads it alone.`; + const fields = keptTokens + ? '"components" and "importPath"' + : '"components", "importPath" and "tokens"'; + const kept = keptTokens + ? ' It keeps the "tokens" the old one listed, which name a stylesheet of this project\'s own.' + : ''; + return `This project reads the config ${pkg.name} declares (${declared}). With --force it has one of its own, ${CONFIG_FILE}, read over that one: what it leaves out, such as ${fields}, still comes from ${pkg.name}'s config, and the rules and overrides you add to it apply over ${pkg.name}'s rules.${kept} "extends": false in it reads it alone.`; +} + +/** + * The `tokens` an existing JSON config lists, when one of them is outside the design-system + * package, links resolved: an app that takes the package's components and reads its own + * stylesheet too lists both. A list of the package's files only is left to the package's config. + */ +function ownTokens(file: string, root: string, pkg: DesignSystemPackage): string[] | undefined { + let tokens: unknown; + try { + tokens = (JSON.parse(fs.readFileSync(file, 'utf8')) as { tokens?: unknown }).tokens; + } catch { + return undefined; + } + if (!Array.isArray(tokens) || !tokens.every((t): t is string => typeof t === 'string')) { + return undefined; + } + const real = (abs: string) => { + try { + return fs.realpathSync(abs); + } catch { + return abs; + } + }; + const dir = real(pkg.dir); + return tokens.some((t) => !isInside(dir, real(path.resolve(root, t)))) ? tokens : undefined; } /** diff --git a/test/layered-config.test.ts b/test/layered-config.test.ts index dc844940..9369a6a8 100644 --- a/test/layered-config.test.ts +++ b/test/layered-config.test.ts @@ -480,6 +480,48 @@ describe('init in an app on a design-system package', () => { } }); + it("keeps, with --force, the tokens of a config that lists its own stylesheet beside the package's", async () => { + // The documented way to take the package's components and read the app's stylesheet too. + const tokens = ['app/globals.css', 'node_modules/@acme/ui/theme.css']; + const app = installedApp(acmeUi(INIT_CONFIG), { + 'onsystem.config.json': json({ tokens }), + 'app/globals.css': '@theme {\n --color-brand: #1a7f37;\n}\n', + }); + const before = await check(app); + const init = await run(['init', '--yes', '--force', '--no-cache'], app); + expect(init.code).toBe(0); + expect(init.stdout).toContain( + 'such as "components" and "importPath", still comes from @acme/ui\'s config', + ); + expect(init.stdout).toContain( + 'It keeps the "tokens" the old one listed, which name a stylesheet of this project\'s own.', + ); + expect(JSON.parse(fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8'))).toEqual({ + $schema: 'https://unpkg.com/onsystem/schema.json', + tokens, + }); + fs.rmSync(path.join(app, 'onsystem.baseline.json')); + expect((await check(app)).findings).toEqual(before.findings); + }); + + it("says nothing of the package's config when --components names the app's own", async () => { + const app = installedApp(acmeUi(INIT_CONFIG), { + 'onsystem.config.json': ONE_RULE, + 'components/ui/button.tsx': BUTTON, + }); + const init = await run( + ['init', '--yes', '--force', '--no-cache', '--components', 'components/ui/**/*.tsx'], + app, + ); + expect(init.code).toBe(0); + expect(init.stdout).not.toContain('This project reads the config @acme/ui declares'); + expect( + JSON.parse(fs.readFileSync(path.join(app, 'onsystem.config.json'), 'utf8')), + ).toMatchObject({ + components: ['components/ui/**/*.tsx'], + }); + }); + it('writes, with --force, the components of a config that names its own again, as it does without the package', async () => { // The app's own shadcn components and stylesheet, as in a config written before the package // declared one.