Monorepos are first-class citizens in CARL v1.0. Many CARL criteria live at
application scope — the linter config, the test runner, the build pipeline —
and in a monorepo those artifacts exist per package, not at the repo root. CARL
evaluates each application independently for application-scope criteria and
reports results as k/n alongside the per-application detail.
This guide walks through how a monorepo is assessed, using a typical pnpm-workspaces + Turborepo layout as the example.
Repository scope vs application scope
The standard defines three scopes:
- Repository scope. One answer for the whole subject.
SEC-010(branch protection on the default branch) is repository-scoped; the repo either has it or it doesn't. - Application scope. One answer per application.
CQV-010(linter configured and runnable) is application-scoped; in a monorepo, one package may have a healthy ESLint config while another has nothing. - Organization scope. One answer across multiple repositories. Useful for org-wide policies like centralized dependency updates.
When the assessor encounters a monorepo, it:
- Detects that a monorepo structure is present.
- Enumerates the set of applications.
- Runs repository-scope criteria once against the repo root.
- Runs application-scope criteria once per application and reports
k/n. - Reports the repository's level as the minimum across applications for the application-scope criteria, combined with the repository-scope results.
Monorepo detection
CARL detects the following monorepo tooling and emits the appropriate application set:
- pnpm workspaces (
pnpm-workspace.yamlwithpackages:globs) - npm / yarn workspaces (
package.json"workspaces"field) - Turborepo (
turbo.jsonplus any of the above) - Nx (
nx.jsonwithprojects:pointers) - Lerna (
lerna.jsonwithpackages:)
If you use a tool we don't detect yet, file an issue. In the meantime you can point the CLI at application directories explicitly:
carl assess ./apps/web
carl assess ./apps/api
carl assess ./packages/engine
Example: a pnpm + Turborepo monorepo
acme/
├── pnpm-workspace.yaml # packages: ['packages/*']
├── turbo.json # task pipeline
├── tsconfig.base.json # shared strict-mode TS config
├── package.json # root package — dev tooling, scripts
├── docs/ # prose documentation (not an "application")
├── packages/
│ ├── engine/ # application: @acme/engine
│ ├── cli/ # application: @acme/cli
│ ├── web/ # application: @acme/web
│ ├── docs/ # application: @acme/docs
│ └── types/ # application: @acme/types
└── design-tokens/ # shared tokens (not an "application")
When assessed, this repo produces one repository-scope result (branch
protection, CODEOWNERS, governance files) and one application-scope result per
packages/* entry. The report shows them as:
Level: L2 Tested (8/10 L2 criteria pass)
Applications assessed: 5 (engine, cli, web, docs, types)
Pillar: Code Quality & Validation — 0.92
CQV-010 (Linter configured): 5/5 applications pass
CQV-030 (Strict types): 5/5 applications pass
CQV-040 (Pre-commit hooks): 5/5 applications pass (repo-scope — satisfied at root)
Pillar: Testing — 0.73
TST-020 (Unit tests run): 4/5 applications pass (types package has no tests)
TST-040 (Coverage ≥ 80%): 3/5 applications pass (cli, docs below threshold)
Per-application overrides
An application may need different tooling than the repo default. CARL handles this naturally because application-scope criteria are evaluated per-package against per-package configuration:
- A TypeScript strict-mode check reads
packages/engine/tsconfig.json, not the roottsconfig.base.json. - A test-coverage check reads
packages/web/vitest.config.ts, not a top-level config. - A linter check verifies the package's own
eslint.config.mjsis runnable.
If you use Turborepo's pipeline, the assessor checks whether turbo run lint /
turbo run test / turbo run build succeed at the root, but still reports
per-application results for clarity.
Applicability marking in monorepos
Not every criterion applies to every application. A packages/types-style
type-only package may have no runnable tests, no build output, no env vars. The
assessor marks those criteria not_applicable per-package:
- Stable. The same package shouldn't flip between applicable and not-applicable across assessments absent a structural change.
- Justified. Each
not_applicablecarries a one-line reason in the report. - Excluded.
not_applicablecriteria don't count toward the 80% gate.
See the assessment methodology.
Recommended structure
For a fresh monorepo aiming at L2 Tested:
my-monorepo/
├── pnpm-workspace.yaml # packages: ['apps/*', 'packages/*']
├── turbo.json # pipeline that chains lint/test/build
├── tsconfig.base.json # strict
├── .github/
│ ├── workflows/ci.yml # runs turbo against all packages
│ ├── CODEOWNERS # per-directory ownership
│ └── ISSUE_TEMPLATE/
├── .nvmrc # single Node version everywhere
├── CODEOWNERS # or .github/CODEOWNERS
├── CHANGELOG.md # monorepo-level changelog
├── CONTRIBUTING.md
├── AGENTS.md # root-level agent context
├── apps/
│ └── web/
│ ├── package.json
│ ├── tsconfig.json # extends ../../tsconfig.base.json
│ ├── vitest.config.ts
│ ├── eslint.config.mjs
│ └── CLAUDE.md # optional per-package overrides
└── packages/
└── engine/
├── package.json
├── tsconfig.json
└── README.md
The root-level AGENTS.md sets the project-wide contract. Per-package CLAUDE.md
may add package-specific constraints but MUST NOT remove root-level
invariants.
Related
- The 8 pillars — which pillars have application-scope criteria.
- Getting to Level 2 — how to raise every application to L2 Tested in a monorepo.
- Reading your report — how
k/nscores render in the report views.