Use cases

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:

.alint.yml
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: error
.alint.yml
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: error
  1. 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 in pnpm-workspace.yaml. Both rulesets check READMEs; except: drops the pnpm ruleset's copy of that rule.

  2. Lines 8 to 13

    One license for every package, read from each package.json with a json_path_equals rule.

  3. Lines 15 to 22

    Every workspace entry exists. Each entry in pnpm-workspace.yaml has to match a directory with a package.json in 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 --compact
packages/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.

base.yml
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: error

A 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 #:

.alint.yml
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/.

packages/ui/.alint.yml
version: 1
rules:
  - id: ui-components-pascal-case
    kind: filename_case
    paths: "src/components/**/*.tsx"
    case: pascal
    level: error
  • A nested config declares only version and rules. 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.

FilesWhole tree--changed
10,000154 ms106 ms
100,0001.45 s828 ms
1,000,00016.28 s9.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_graph can 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

.alint.yml
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]

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.