A linter for monorepos and multiple repositories
Packages drift. A new one lands without a README, one says Apache-2.0 while the rest say MIT, and the workspace file lists a directory nobody created. Across dozens of repositories, each drifts its own way.
alint checks every package from one .alint.yml at the root, and one shared ruleset, pinned by its hash, can cover every repository you look after.
A workspace, checked
A pnpm workspace with a web app and three packages, and one entry in pnpm-workspace.yaml that points at a missing directory. This config checks it:
version: 1
extends:
- alint://bundled/monorepo@v1
- url: alint://bundled/monorepo/pnpm-workspace@v1
except: [pnpm-workspace-member-has-readme]
rules:
- id: packages-are-mit
kind: json_path_equals
paths: ["apps/*/package.json", "packages/*/package.json"]
path: "$.license"
equals: "MIT"
level: error
- id: workspace-members-exist
kind: registry_paths_resolve
source: pnpm-workspace.yaml
extract: { yaml: "$.packages[*]" }
entries_are_globs: true
expect: dir
must_contain: package.json
level: errorversion: 1
extends:
- alint://bundled/monorepo@v1
- url: alint://bundled/monorepo/pnpm-workspace@v1
except: [pnpm-workspace-member-has-readme]
rules:
- id: packages-are-mit
kind: json_path_equals
paths: ["apps/*/package.json" , "packages/*/package.json" ]
path: "$.license"
equals: "MIT"
level: error
- id: workspace-members-exist
kind: registry_paths_resolve
source: pnpm-workspace.yaml
extract: { yaml: "$.packages[*]" }
entries_are_globs: true
expect: dir
must_contain: package.json
level: error- Lines 2 to 5
The monorepo rulesets. A README and a manifest in every package, no two package directories with the same name and a
packages:list inpnpm-workspace.yaml. Both rulesets check READMEs;except:drops the pnpm ruleset's copy of that rule. - Lines 8 to 13
One license for every package, read from each
package.jsonwith a json_path_equals rule. - Lines 15 to 22
Every workspace entry exists. Each entry in
pnpm-workspace.yamlhas to match a directory with apackage.jsonin it (registry_paths_resolve).
- shop/the 3 findings of alint check, marked on the workspace's files
- .alint.yml✓
- README.md✓
- apps/
- apps/web/
- apps/web/README.md✓
- apps/web/package.json✓
- package.json✓
- packages/
- packages/api-client/
- packages/api-client/README.md✓
- packages/api-client/package.json
error packages-are-mit
value at path does not equal expected: expected "MIT", got "Apache-2.0"
- packages/emails/
warning monorepo-packages-have-readme
Every monorepo package directory should have a README.md.
- packages/emails/package.json✓
- packages/ui/
- packages/ui/README.md✓
- packages/ui/package.json✓
- pnpm-workspace.yaml
error workspace-members-exist
pnpm-workspace.yaml: entry "tools/codegen" matched no path on disk
Summary (3 violations): ✗ 2 errors ⚠︎ 1 warning 5 passing · 3 failing
alint check --compactpackages/emails: warning: monorepo-packages-have-readme: Every monorepo package directory should have a README.md.packages/api-client/package.json: error: packages-are-mit: value at path does not equal expected: expected "MIT", got "Apache-2.0"pnpm-workspace.yaml: error: workspace-members-exist: pnpm-workspace.yaml: entry "tools/codegen" matched no path on disk2 errors, 1 warning.The same run, one line per finding. Each package is reported once: the README check comes from the monorepo ruleset only.
Checks across packages
- Every package directory has a README and a manifest
- for_each_dir
- Package directory names are unique across the tree
- unique_by
- A field has the same value in every manifest, such as the license
- json_path_equals, toml_path_equals
- Every member the workspace lists exists, and no package is missing from the list
- registry_paths_resolve
- A value in one file matches a value in another, such as a version pinned in two places
- cross_file
- Code in one package doesn't import another package's internals
- import_gate, file_graph
- Generated files match what their generator produces
- generated_file_fresh
- The same license header in every source file, whatever the language
- file_header
Most of these are cross-file rules: they compare files with each other, where a code linter looks at one file at a time. They run on the whole tree even in a --changed run, so a new package without its README is still reported.
The workspace rulesets
monorepo (4 rules) always runs: a README in every directory under packages/, crates/, apps/ and services/, a manifest in each package and crate, and unique directory names. cargo-workspace, pnpm-workspace and yarn-workspace(3 rules each) run only in their kind of workspace, and check that it declares its members and that each member has a README and a name.
The Cargo ruleset looks for members under crates/. If your workspace keeps them elsewhere, as Deno's does, define a rule with the same id and your own select:; it replaces the bundled rule's settings field by field.
One ruleset for every repository
Put the rules every repository shares in one file and publish it: here, base.yml in a repository of its own, but any https:// URL works.
version: 1
rules:
- id: packages-are-mit
kind: json_path_equals
paths: ["apps/*/package.json" , "packages/*/package.json" ]
path: "$.license"
equals: "MIT"
level: error
- id: packages-have-readme
kind: for_each_dir
select: "packages/*"
require:
- kind: file_exists
paths: "{path}/README.md"
level: errorA shared file holds rules only: alint refuses one that extends other configs, runs commands, reads outside the repository or sets what a baseline hides.
Every repository's .alint.yml extends it by URL, with the file's SHA-256 after the #:
version: 1
extends:
- alint://bundled/oss-baseline@v1
- alint://bundled/node@v1
- https://raw.githubusercontent.com/your-org/alint-rules/v3/base.yml#sha256-fe3939bf948a49e7377af1e3d3a0677dc94160bdee5e0b8766cc129473cc803e Get the hash with sha256sum base.yml. alint won't fetch an https:// URL without one, and refuses a file whose hash doesn't match.
Publish each version under its own tag, like v3 here. Moving a repository to a new version means changing the tag and the hash in that line; Renovate and Dependabot don't update it, so for many repositories a short script does: fetch the new file, run sha256sum and open a pull request with the new line in each one.
Rules for one package
With nested_configs: true in the root config, alint also loads every .alint.yml below the root. A nested config adds rules for its own directory: alint puts the directory in front of each rule's paths, so this one only sees packages/ui/src/components/.
version: 1
rules:
- id: ui-components-pascal-case
kind: filename_case
paths: "src/components/**/*.tsx"
case: pascal
level: error- A nested config declares only
versionandrules. Rulesets, facts, variables and ignore patterns stay in the root config. - Every nested rule needs a path to scope it, and can't reach outside its directory.
- A nested config can't change a root rule: reusing a rule id is a load error. To keep a root rule out of one package, narrow its paths in the root config.
A root rule can also follow the members the workspace declares instead of a glob you keep in step by hand: scope_filter with include_manifest_paths reads the member list from pnpm-workspace.yaml or Cargo.toml. The nested configs and scoping pages have the details.
Pull requests and large trees
In a pull request, alint check --changed --base origin/main checks only the files the pull request changed with the rules that look at one file at a time; rules that span files still see the whole tree. Benchmark scenario S4 is a polyglot git tree checked by the oss-baseline, rust, node, python, monorepo and monorepo/cargo-workspace rulesets together (50 rules). The --changed runs time uncommitted edits to a tenth of the files (plain --changed); with --base, alint asks git for the diff against the branch instead.
| Files | Whole tree | --changed |
|---|---|---|
| 10,000 | 154 ms | 106 ms |
| 100,000 | 1.45 s | 828 ms |
| 1,000,000 | 16.28 s | 9.35 s |
alint 0.18.2 on an Intel Core i7-6700HQ laptop with 4cores; means from hyperfine. Raw results, and every scenario on the benchmarks page.
On real monorepos
The reference configs cover 30 open-source repositories, written to test alint on real trees; the projects don't use alint. Four of the monorepos among them:
- vercel/next.js
A pnpm workspace and a Cargo workspace in one tree. Of its 63 crates, 4 have no license value the rule accepts (MIT, MPL-2.0 or Apache-2.0).
- pnpm/pnpm
201 packages, kept in line by a 499-line plugin that enforces 13 conventions on every
package.json. The config checks 4 of them exactly and 2 in part, with no plugin to install. - NixOS/nixpkgs
One rule checks that each of the 20,698 package directories has its
package.nix. In May 2026, the 72 rules that didn't call other tools, from the config as it was then, checked a 52,354-file checkout in 332 ms (alint 0.9.17). - apache/arrow
Six language implementations in one tree. Each of the 8 Ruby gems ships its license, notice, README, Gemfile and gemspec; each of the 7 GLib libraries has its
meson.build.
What it leaves to other tools
- Bazel, Nx, Turborepo, Cargo and pnpm decide what to build and in which order. alint reads their manifests as data and doesn't evaluate build targets.
- cargo-deny, npm audit, Renovate and Dependabot check the versions, vulnerabilities and licenses of your dependencies.
file_graphcan reject import cycles between files, but alint doesn't model which package depends on which.- ESLint, Clippy, Ruff and golangci-lint check the code inside the files.
A config for your workspace
One for each kind of workspace, with the same except: as the example at the top. Install alint, save one as .alint.yml at the root, and run alint check there.
pnpm
version: 1
extends:
- alint://bundled/oss-baseline@v1
- alint://bundled/node@v1
- alint://bundled/monorepo@v1
- url: alint://bundled/monorepo/pnpm-workspace@v1
except: [pnpm-workspace-member-has-readme]Cargo
version: 1
extends:
- alint://bundled/oss-baseline@v1
- alint://bundled/rust@v1
- alint://bundled/monorepo@v1
- url: alint://bundled/monorepo/cargo-workspace@v1
except: [cargo-workspace-member-has-readme]Yarn
version: 1
extends:
- alint://bundled/oss-baseline@v1
- alint://bundled/node@v1
- alint://bundled/monorepo@v1
- url: alint://bundled/monorepo/yarn-workspace@v1
except: [yarn-workspace-member-has-readme]alint init --monorepo writes a config like these and turns on nested configs, but without the except: line. Add it, or each package without a README is reported twice.
More in the docs: alint and monorepos has recipes for Cargo, pnpm, Yarn, mixed and Bazel trees, and composition and trust covers how extends: merges rulesets and what a shared one may not do.