Skip to content

feat: read installed design-system packages that publish their sources - #176

Merged
dgesteves merged 4 commits into
mainfrom
fix/os2-75-installed-source-packages
Oct 16, 2026
Merged

dgesteves merged 4 commits into
mainfrom
fix/os2-75-installed-source-packages

Conversation

@dgesteves

@dgesteves dgesteves commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Roadmap item: OS2-75 (read installed design-system packages that ship source; follow @import into node_modules).

What was wrong

Zero config treated every dependency installed in node_modules as compiled and skipped it. @trycompai/design-system@1.1.19 publishes only sources: its exports point at ./src/index.ts and ./src/components/ui/*.tsx, it ships 72 .tsx files, and apps import @trycompai/design-system/globals.css. An app on it got nothing. With the package copied into an app's node_modules, main's inspect printed Components (0 + 0 parts) and Tokens (9) (Tailwind's default spacing and radii only). inspect --explain said not a workspace package: an installed package is compiled, and only sources are read, which is false for this package. check on a page with seven seeded mistakes gave 0 errors, 1 warning and No design system found. The app's @import "@trycompai/design-system/globals.css" was not followed either, because the stylesheet resolver followed @import only into workspace packages.

What changed

src/detect.ts:

  • PackageFinder.installed(name) finds a dependency in the nearest node_modules, not linked to a workspace package. It returns the package's real folder, the same place the declared-config path reads (place()), so that pnpm's sibling dependencies resolve. If the real folder is outside the root and its workspace (pnpm's store elsewhere on the machine), it returns the folder it was found by in node_modules. fix: read design-system files only inside the project, links resolved #172's loader containment reads that path.
  • In detectProject, a dependency named like a design system that is neither the declared package nor a workspace package is now looked up there. It is read through fromPackage alone, with its exports as written and as an app resolves them (next bullets): directly, or through barrels. It is accepted only when they lead to .tsx/.jsx files on disk. Installed packages do not go through fromImports or fromPackageComponentsJson.
  • Exports that point at dist/ are not followed back to src/ in an installed package (they still are in a workspace package and in a package whose config the app takes on). Component kits named like a design system, such as @copilotkit/react-ui and @assistant-ui/react-ui, publish src/ next to dist/ for their source maps. Followed back, they became a second design system in apps that already have one.
  • An installed package's exports are taken through the conditions an app resolves: import, require, module-sync, node, module, browser, style and default (APP_CONDITIONS), for its components and for the stylesheets an @import reaches. For both, the first of them decides. An app's @import "@acme/ui/x.css", exported as { "style": "./dist/x.css", "default": "./src/x.css" }, reads nothing, since Tailwind CSS takes the compiled dist/x.css. An export that maps the subpath decides even when it leads to nothing. A condition for the package's own repository that comes first is passed over: @expo/ui@58.0.18 has "expo-source": "./src/universal/index.ts" before "default": "./build/universal/index.js", and react-native-builder-bob writes "source": "./src/index.tsx" before "default": "./lib/module/index.js". Read through its first condition, @expo/ui was detected as an Expo app's design system. development is passed over too. Workspace packages and packages whose config the app takes on still take the first condition but types, which leads to their sources.
  • Nothing compiled in an installed package is read: no JavaScript, JSX or CSS in dist/, build/, lib/, out/, esm/ or cjs/, whether an export, a barrel or an @import leads to it. TypeScript there (lib/utils.ts) is source. Nor is an export that points at JavaScript in any other folder (./index.js at the root, as in @cloudscape-design/components, ./es/index.mjs in @lobehub/ui, ./build-module/index.mjs in @wordpress/components). Its re-exports are not followed, and inspect --explain names it as build output. So a compiled package named like a design system costs no parse of its compiled files on load, wherever they are (timings below). The trade-off: a package that publishes a .js barrel in front of .jsx files is not read. The docs say so: point the exports at the .jsx files, or declare the config.
  • Explain wording. A package whose exports point at build output, or at JavaScript anywhere, gets its exports point at build output (node_modules/@acme/ui/dist/index.mjs), not at component sources, and only sources are read. A dependency that is neither installed nor a workspace package gets not a workspace package, and not installed.
  • stylesheetResolver resolves @import "<pkg>/..." into an installed package only when zero config reads that package as a design system (named like one, exports point at its sources), and never to a stylesheet in its build folder. A relative @import from a stylesheet inside an installed package resolves only inside that same package, outside its build folders. So the package's @import './theme.css' is read, while @import 'tailwindcss', @import 'tw-animate-css', a ../../tailwindcss/index.css path out of the package and a ../../dist/partial.css path into its build folder are not.

src/tokens/imports.ts: the resolver type's comment says what it now resolves to. No other change was needed there, and src/explain.ts needed none: the candidate lines come from detectProject.

Docs: docs/design-system-packages.md gets a "Without the config" subsection and two table rows: all eight rules for a package named like a design system whose exports point at its sources, and nothing from one whose exports point at dist/ or that is named otherwise. The subsection says that exports are read through the conditions an app resolves, so a source or expo-source condition before a default into the build is passed over, that exports into dist/ are not followed back to src/, that nothing compiled is read (stylesheets included), and that when pnpm's store is outside the project, the props a package's components take from their own dependencies are not known. The "In the apps" sentence that said zero config never finds an installed package is updated. docs/troubleshooting.md quoted the old explain message, so it now quotes the new one. No other wave-1 PR touches it.

Changeset: minor, because roadmap/build/WAVES.md plans the release after wave 4 as a minor for this item and three others ("new behaviour"). Change it to patch if you prefer.

Choices:

  • Real folder, not the link. I checked TypeScript directly. A component file opened through node_modules/@x/ds (a pnpm link) does not resolve an import of a package pnpm installed next to it. The same file opened at node_modules/.pnpm/@x+ds@1.0.0/node_modules/@x/ds does. A test pins this: with the link path, the Button's props from its own dependency stay unresolved.
  • Packages named like a design system only, the same candidate set that workspace packages use, so other dependencies are not read.
  • Exports as written, not mapped from dist/ to src/. The name check alone also catches popular widget kits. A design-system package that wants apps to read it while its exports point at dist/ can declare its config, which keeps the mapping.
  • The conditions an app resolves, not "any condition into a build folder". A package is read when the target an app's build gets is a source. { "import": "./src/index.ts", "require": "./dist/index.cjs" } is read, since bundlers take import. browser and style are in the set because bundlers and Tailwind CSS resolve them for an app; react-native, react-server, development and production are not, so a source that only one of those leads to is not read.
  • No compiled CSS. A built stylesheet can carry Tailwind's palette or a widget's own theme, and onsystem reads only sources.

Behavior changes for upgraders

  • An app with no config, or with a config that leaves components unset, now picks up an installed package named like a design system whose exports point at its sources. It gets that package's components and, as tokens, its exported stylesheets outside its build folders. A package whose exports point at dist/ is not read, with or without src/ published, as on main. The docs test "replaces the package's: ..." uses such a package. A test next to it shows that an app with its own rules-only config keeps the components of a package whose exports point at src/.

Follow-ups (files outside this item's boundary)

  • docs/configuration.md (OS2-41's file in this wave, merged in fix(cli): match exclude the same way in check, at the root, in ESLint, the hook and check_ui #175): step 3 of zero config and the tokens paragraph should say that an installed package named like a design system whose exports point at its sources is read, and that @import is followed into it.
  • src/init.ts (fix(init): add the shadcn registry folders to the components it writes #173's file; tracked in the roadmap's follow-up list, to land before the post-wave-4 release): init --yes in an app that reads such a package writes the detected paths it can spell. With pnpm, those paths contain the version. In a real pnpm install of @trycompai/design-system@1.1.19, it wrote "tokens": ["node_modules/.pnpm/@trycompai+design-system@1.1.19_@date-fns+tz@1.5.0_..._c47a9a74.../node_modules/@trycompai/design-system/src/styles/globals.css"], on this branch and on this branch merged with main. That path stops existing at the next upgrade, and check then fails with Token file not found. This PR's base stops with zero config found no design system and writes nothing. init should leave an installed package's components and tokens to zero config, or write them through node_modules/<name>/.

How it was tested

New test/installed-source-packages.test.ts, 23 tests. Of the first 18, 15 fail on main. The 3 that pass there pin what must stay unread (a package not named like a design system, an @import into a package that publishes no source, and an app with its own design system that installs a package laid out like @expo/ui). 6 of them fail on 462f468, before the conditions fix. The 5 added in this round fail on e80b44b, the head before this round:

  • npm layout modelled on @trycompai/design-system: detected installed package @acme/design-system, the explain line, Button, Card and Input with importPath @acme/design-system, get_component over MCP, and check reporting no-unknown-variant, prefer-design-system-component and no-hardcoded-color.
  • Tokens come from the exported stylesheet and the partial it imports. None come from tailwindcss or tw-animate-css, whether the stylesheet imports them by name or by a ../ path out of the package.
  • An app config that names only the components gets the tokens through the app's own @import "@acme/design-system/globals.css".
  • pnpm layout: read from node_modules/.pnpm/..., and the Button's render prop from its own dependency resolves.
  • pnpm store outside the project: read through the app's node_modules, with no Not read warning, and the Button's render prop from its own dependency is not known (the docs say so).
  • npm workspace with the package hoisted to the root: read from ../../node_modules/....
  • dist/-only package: not read (even with .jsx files in dist/), with the new explain message.
  • A CopilotKit-like kit (@acme/react-ui: exports to dist/, src/*.tsx published, an exported dist/index.css with :root vars, a Thread exported as an Object.assign default) in a shadcn app, imported by a page and by the app's stylesheet: the same detection, components, tokens (name and value) and check findings as the same app without it installed. On main, this test fails only on its explain line. As the only candidate, the kit is not read either.
  • A package that is read, with an exported ./styles.css in dist/, a ./theme.css export with "source" into src/ before "default" into dist/, a source stylesheet that imports ../../dist/partial.css, and an app stylesheet that imports all three: no token from any, with zero config and with a config that names only the components.
  • Exports with a condition for the package's own repository before the build: { "expo-source": "./src/index.ts", "default": "./build/index.js" } (with types as @expo/ui has them), bob's { "source": "./src/index.tsx", "default": "./lib/module/index.js" }, older bob's source before import and require, and development before default. None is read, and inspect --explain names the build target.
  • Exports that point at JavaScript outside a build folder: ./index.js (Cloudscape's layout), ./es/index.mjs (@lobehub/ui's) and ./build-module/index.js (@wordpress/components's). Each one re-exports a barrel that leads to three .jsx files, which only following the compiled re-exports would reach. None is read, and inspect --explain names the export as build output.
  • A package whose . export points at ./index.js, with ./atoms/* and ./molecules/* into src/: read through the source patterns alone (Card's import path is @acme/design-system/molecules/card, not the .js barrel's @acme/design-system).
  • An app's @import of ./x.css, exported as { "style": "./dist/x.css", "default": "./src/x.css" }: no token from either file, with zero config and with a config that names only the components.
  • A shadcn app that also installs the @expo/ui-like package and uses its Button with an unknown prop: the same detection, components, tokens and check findings as without it.
  • Positive: a package whose . export has source before a nested import into src/ (and require into dist/), ./components/* with expo-source before browser into src/, and ./globals.css with development into dist/ before style into src/: read, with its 3 components and its tokens.

Updated: test/design-system-packages-docs.test.ts (the two rows; a package whose exports point at src/, run end to end with all eight rules, including when its declared config is not published; a package whose exports point at dist/, with src/ published or not, or with a source condition into src/ before them, gets nothing, with the explain line and the conditions sentence quoted in the docs; the "In the apps" sentence) and the one explain line in test/init.test.ts. This round adds two docs tests. The conditions the docs list are the eight a package is read through, and it is not read through development or react-native. A .js barrel in front of .jsx files gets only icon-button-accessible-name, with the explain line and the docs sentence.

Gate, all passing:

  • pnpm install --frozen-lockfile --prefer-offline
  • pnpm format:check
  • pnpm lint
  • pnpm typecheck
  • pnpm test: 73 files passed, 1 skipped; 981 tests passed, 3 skipped
  • pnpm build: schema.json unchanged
  • pnpm smoke

Rebased on current main (#173, #174, #175 and #178 in); the gate above ran on the rebased branch.

Corpus, on this head: node scripts/corpus.mjs --dir <scratch folder> with the built CLI printed "Counts match corpus/snapshot.json". The blind sample fp-sample-20261016 still has 80 of its 80 labels reported. No snapshot, label or README change.

Timing of inspect --no-cache, median of 7 runs at a 1-minute load average of about 6. The real tarballs were copied into an app's node_modules, and none of them is read on any of the three builds (Components (0 + 0 parts), Tokens (0)):

Package (the . export) main e80b44b this head
@cloudscape-design/components@3.0.1401 (./index.js) 296 ms 533 ms 296 ms
@wordpress/components@42.0.0 (./build-module/index.mjs) 313 ms 404 ms 302 ms
@lobehub/ui@6.0.1 (./es/index.mjs) 304 ms 409 ms 286 ms

On e80b44b, inspect --explain said its exports lead to 0 component files; a design system exports at least 3 for all three. On this head it says, for example, no installed package @lobehub/ui: its exports point at build output (node_modules/@lobehub/ui/es/index.mjs, node_modules/@lobehub/ui/es/i18n/index.mjs), not at component sources, and only sources are read.

By hand, with the real @trycompai/design-system@1.1.19 tarball copied into an app's node_modules (no dependencies installed) and a page with the finding's seeded mistakes:

main this branch
inspect Components (0 + 0 parts), Tokens (9) detected installed package @trycompai/design-system, Components (87 + 268 parts), Tokens (46)
inspect --explain not a workspace package: an installed package is compiled, ... found installed package @trycompai/design-system: node_modules/@trycompai/design-system, 71 component files
check app/page.tsx 0 errors, 1 warning 5 errors, 1 warning: variant, SuperCard, native <input>, palette, hex, spacing

The "this branch" column holds after the conditions fix: with the fixed build, inspect printed the same detected, Components (87 + 268 parts) and Tokens (46) lines, inspect --explain the same 71 component files line, and check on a page with the same kinds of mistakes 5 errors, 1 warning. On this head, inspect --explain on the same app still prints detected installed package @trycompai/design-system, Components (87 + 268 parts), Tokens (46) and 71 component files. The 71 component files and 46 tokens are the same as the finding's hand config. The loading prop finding needs @types/react, which this copy does not install, as in the verifier's run. From the first round, before the conditions fix: a real pnpm install of the same package gives the same component and token counts and 7 errors, 1 warning, the isLoading prop included. Linked into another install's .pnpm folder (pnpm's store outside the project), it gives 6 errors, 1 warning: the isLoading prop is not flagged there, as the docs now say. check --no-cache on the page took 0.75 s on this branch and 0.31 s on main (median of 3 runs, at a 1-minute load average of 16). The difference is the time to extract 71 components.

With the real @expo/ui@58.0.18 tarball copied into an app's node_modules and a page that uses its swift-ui Host, Text and Button:

main previous head this branch
inspect Components (0 + 0 parts), Tokens (0) detected installed package @expo/ui, Components (147 + 93 parts) Components (0 + 0 parts), Tokens (0)
check app/index.tsx No problems, No design system found No problems, No color tokens found No problems, No design system found

inspect --explain on this branch says no installed package @expo/ui: its exports point at build output (node_modules/@expo/ui/build/universal/index.js, node_modules/@expo/ui/build/swift-ui/index.js), not at component sources, and only sources are read.

From the first round, before the conditions fix: with the real @copilotkit/react-ui@1.79.1 and @assistant-ui/react-ui@0.2.1 installed in a shadcn app (components.json, components/ui), inspect and check . print the same as main, apart from the timing line: Components (5 + 15 parts), Tokens (31), and 12 errors, 3 warnings and 8 errors, 3 warnings. No CopilotKit component or token, and no no-unknown-component on assistant-ui's Thread. inspect --explain now says no installed package @copilotkit/react-ui: its exports point at build output (node_modules/@copilotkit/react-ui/dist/index.mjs, node_modules/@copilotkit/react-ui/dist/index.css), not at component sources, and only sources are read.

Zero config skipped every dependency installed in node_modules as
compiled, so an app on a source-only package such as
@trycompai/design-system (exports at ./src/index.ts, 72 .tsx files)
got no components and no tokens.

An installed dependency named like a design system is now read
through its exports when they lead to the .tsx or .jsx sources it
publishes, from its real folder so pnpm's sibling dependencies
resolve, or through the app's node_modules when pnpm's store is
elsewhere. Its build output is not walked. Its exported stylesheets
are its tokens, with the partials of the same package they import,
and an app stylesheet's @import of such a package is followed into
node_modules. Other installed packages (tailwindcss) are not read.
inspect --explain now says a package publishes build output and no
component source, instead of calling every installed package compiled.
… sources

An installed package that declares no config was read from dist/ back to
src/ when src/ was published. Component kits named like a design system
(@copilotkit/react-ui, @assistant-ui/react-ui) publish src/ next to dist/
for their source maps, so a shadcn app picked them up as a second design
system, with their compiled dist/index.css as tokens and false
no-unknown-component findings on valid imports.

An installed package's exports are now read as written, and nothing
compiled in it is read: no JavaScript, JSX or CSS in dist/, build/, lib/,
out/, esm/ or cjs/, from its exports, its barrels or an @import.
@trycompai/design-system, whose exports point at ./src, reads as before.

The explain line now says "its exports point at build output (...), not
at component sources". The docs say that, and that props from a
package's own dependencies are not known when pnpm's store is outside the
project.
… app resolves

A custom condition before default, such as @expo/ui's "expo-source" or
react-native-builder-bob's "source", points at src/ for the package's own
repository; apps that install it get the build after it. Reading the first
condition detected @expo/ui@58.0.18 as an Expo app's design system (147
components from its src/). Installed packages now take each export through
import, require, module-sync, node, module, browser, style and default only,
for components and for the stylesheets an app's @import reaches.
@dgesteves
dgesteves force-pushed the fix/os2-75-installed-source-packages branch from 0624742 to e80b44b Compare October 16, 2026 06:45
An installed package named like a design system whose exports point at
JavaScript outside dist/, build/, lib/, out/, esm/ and cjs/ (`./index.js`
at its root, `es/index.mjs`, `build-module/index.mjs`) had its whole chain
of compiled re-exports parsed on every load, to find no component. Such an
export is now skipped like build output, and inspect --explain names it as
build output instead of "its exports lead to 0 component files". A package
that publishes a `.js` barrel in front of `.jsx` files is no longer read;
the docs say so.

An app stylesheet's @import into an installed package now resolves through
the first stylesheet target the matching export gives under the conditions
an app resolves, as the package's own stylesheets are found, and does not
fall through to a later condition when that one is compiled.

The docs and the changeset list all eight conditions, `module-sync` and
`node` included.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant