Monorepos and workspace tables
Markdown for agentsThis guide checks the architecture of a monorepo with one config at its root. A table says which apps and packages may use which. A second rule makes each app list the packages that it imports.
Before you start
Section titled “Before you start”- Read Getting started.
- Your monorepo uses workspaces of npm, pnpm or Yarn, so that each workspace package is linked in
node_modules.
The example has two apps and two packages:
- archangel.config.ts
- package.json
Directoryapps/
Directoryapi/
- package.json
Directorysrc/
- index.ts
Directoryweb/
- package.json
Directorysrc/
- index.ts
Directorypackages/
Directorycontract/
- package.json
Directorysrc/
- index.ts
Directoryui/
- package.json
Directorysrc/
- index.ts
How archangel sees a workspace package
Section titled “How archangel sees a workspace package”An app imports a package of the workspace by its name, for example @acme/ui. The package
manager links node_modules/@acme/ui to packages/ui. Archangel follows the link, as Node and
TypeScript do. So the import goes to a file in packages/ui, and a rule about parts judges it as
an import of that file.
Write the workspace table
Section titled “Write the workspace table”-
Write one config at the root of the monorepo, with one part for each app and each package:
archangel.config.ts import { defineConfig } from '@enhance-eng/archangel';export default defineConfig({parts: {web: 'apps/web/**',api: 'apps/api/**',ui: 'packages/ui/**',contract: 'packages/contract/**',},rules: ({ mayImport, rule }) => [mayImport('workspace', {web: ['ui', 'contract'],api: ['contract'],ui: [],contract: [],}, {because: 'An app uses packages. A package never uses an app, and the contract depends on nothing.',}),rule('declared-packages', {files: 'apps/*/src/**',mayNotImport: { packages: ['*'] },dependency: 'undeclared',because: 'Each app lists what it uses in its own package.json, so it builds alone.',examples: {rejected: [{'apps/web/package.json': '{ "name": "@acme/web" }','apps/web/src/a.ts': "import 'react';",}],},}),],});The table
workspacesays:- The web app may use the UI kit and the contract.
- The API may use only the contract.
- The UI kit and the contract may use no other workspace.
-
Check the monorepo:
Terminal $ npx archangel checkerror[may-import]: api may not import ui, through "@acme/ui": api may import only contract, and itself--> apps/api/src/index.ts:2:23|2 | import { Price } from '@acme/ui';| ^^^^^^^^^^ api may import only contract, and itself|= help: remove the import, or move the code to a file whose row permits the import= note: rule: workspace.api= note: because: An app uses packages. A package never uses an app, and the contract depends on nothing.error[may-not-import]: apps/api/src/index.ts may not import "zod", because files in "apps/*/src/**" may not import any package that apps/api/package.json does not list--> apps/api/src/index.ts:3:19|3 | import { z } from 'zod';| ^^^^^ remove the import, or move the code to files that may import it|= note: rule: declared-packages= note: because: Each app lists what it uses in its own package.json, so it builds alone.checked 5 files and 7 imports: 2 problemsFor what a code means, run `archangel docs may-import` or `archangel docs may-not-import`.The check finds two problems. The first is from the table: the API imports the UI kit. The second is from the rule
declared-packages, in the next section.
Here is the API file:
import type { Order } from '@acme/contract';import { Price } from '@acme/ui';import { z } from 'zod';
const order = z.object({ id: z.string(), total: z.number() });
export function parseOrder(body: unknown): Order { Price({ cents: 0 }); return order.parse(body);}Make each app list its packages
Section titled “Make each app list its packages”In a monorepo, a package that one workspace installs is often visible to all of them. An app can
then import a package that its own package.json does not list. It works on your laptop and fails
when the app builds alone.
The rule declared-packages refuses this. It uses two options:
mayNotImport: { packages: ['*'] }names each outside package.dependency: 'undeclared'limits the rule to packages that the nearestpackage.jsonof the importing file does not list.
The API imports zod. The root package.json installs it, but apps/api/package.json does not
list it:
{ "name": "@acme/api", "private": true, "type": "module", "main": "src/index.ts", "dependencies": {"@acme/contract": "*", "fastify": "5.6.0"}}The API also imports @acme/ui, which its package.json does not list either. The rule does not
refuse it, because the import goes to a file of the repository, not to an outside package. The
table judges it.
To find packages that are only in devDependencies, use dependency: 'dev'.
Prove the rules
Section titled “Prove the rules”$ npx archangel testpass declared-packages rejected #1 apps/web/package.json, apps/web/src/a.ts1 examples, none failed; 0 rules without a rejected example4 table rows need no written example; `archangel rules` shows what each coversThe example of declared-packages writes its own package.json. A rule with dependency needs
files that you write, because the text of the manifest decides the result. One-line examples do
not work here.
$ npx archangel rulesworkspace 4 rows archangel.config.ts:11 because: An app uses packages. A package never uses an app, and the contract depends on nothing.
workspace.web: web may import only ui, contract, and itself 1 file · 2 imports judged · 0 problems · 0 exceptions workspace.api: api may import only contract, and itself 1 file · 2 imports judged · 1 problem · 0 exceptions workspace.ui: ui may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions workspace.contract: contract may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions workspace.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 4 files · 0 problems
declared-packages archangel.config.ts:20 because: Each app lists what it uses in its own package.json, so it builds alone. files in apps/*/src/** may not import * that the nearest package.json does not list 2 files · 1 import judged · 1 problem · 0 exceptions
Not judged by any rule: 0 files, 0 imports between repository files.Outside packages that no rule names: none.One config or many
Section titled “One config or many”Archangel uses the nearest config at or above the folder where it starts. One config at the root can check all workspaces in one run, and a table can say how they depend on each other. A config in a workspace sees only that workspace. Use one root config for the rules between workspaces.
Next steps
Section titled “Next steps”- Import options lists
dependency,specifier,instanceandwithin. - Next.js shows how to use a preset for one app, with its
root.