nested-boundaries
v0.2.0
Published
Enforce the Recursive Deep Modules convention from paths alone: nested module boundaries for ESLint, oxlint, and Biome, plus cycle detection and structural checks.
Downloads
120
Maintainers
Readme
nested-boundaries
Enforce the Recursive Deep Modules convention from paths alone: modules nest
fractally under modules/ folders, every module exposes only its root-level files as
entry points, visibility follows the directory tree, tests are black-box, and nothing
imports upward — at unbounded nesting depth, with zero per-module configuration.
Works with ESLint, oxlint, or Biome.
The convention itself — vocabulary, rules, and fix guidance — lives in
MODULE-BOUNDARIES.md, which ships inside the package. Every
error message points at node_modules/nested-boundaries/MODULE-BOUNDARIES.md, so the
doc is always the version you installed. Tip: reference that path from your
CLAUDE.md / AGENTS.md so coding agents onboard to the rules too.
What you get
One package. Four things need enforcing, and no single tool can see all of them — wire the ones that match your toolchain:
| What gets enforced | How |
| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Entry-point boundaries, visibility, upward imports, black-box test imports | the nested-boundaries/imports rule (ESLint or oxlint), or the boundaries.grit plugin (Biome) — with editor feedback either way |
| No file or Home-Module dependency cycles (type-only cycles exempt) | the nested-boundaries check-structure CLI, using dependency-cruiser's resolved project graph |
| The filesystem grammar (misplaced modules//tests/ folders, stray test files, reserved names) | that same check-structure command — one project-wide check for every linter choice |
| The humans and LLMs who hit the errors | MODULE-BOUNDARIES.md, shipped in the package; every diagnostic points at it |
All convention settings and policies are baked into the plugin — adoption is wiring, not configuration.
Setup
Install (
dependency-cruisersupplies the resolved graph used by the project-wide structure check):npm install -D nested-boundaries dependency-cruiserWire your linter — whichever you run, or several:
ESLint (
eslint.config.mjs):import nestedBoundaries from "nested-boundaries"; export default [ { files: ["src/**/*.ts"], plugins: { "nested-boundaries": nestedBoundaries }, rules: { "nested-boundaries/imports": "error" }, }, // ...the rest of your config ];oxlint (
.oxlintrc.json):{ "jsPlugins": [{ "name": "nested-boundaries", "specifier": "nested-boundaries" }], "rules": { "nested-boundaries/imports": "error" }, }Biome (
biome.json) — Biome references plugins by file path:{ "plugins": [ { "path": "./node_modules/nested-boundaries/boundaries.grit", "includes": ["**/src/**"], }, ], }If that
node_modulespath doesn't exist in your layout (npm/yarn hoisted workspaces, Yarn PnP), commit a copy instead:npx nested-boundaries emit-biomewritesboundaries.gritnext to your config, andcheck-structureverifies the copy against the installed package on every run so it can never silently go stale. In a Biome monorepo, put the plugin in the package-level config ({ "root": false, "extends": "//" }) so it applies to that package only.Wire the project-wide structure check into your scripts/CI. It checks both the filesystem grammar and file/Home-Module cycles:
// package.json "scripts": { "lint:structure": "nested-boundaries check-structure" // defaults to src }
Then organize code under src/modules/… per the convention. That's the whole
contract: boundaries come from where files sit.
Monorepos
Each workspace package is an independent enforcement universe: its lowercase
src/ has its own Root Scope and Modules, and the tsconfig.json beside that
src/ owns alias resolution. The ESLint/oxlint rule derives this package root
from each linted file's last exact lowercase src path segment, so the same
process can enforce many packages without treating the monorepo working directory
as a shared source root. Earlier ancestor directories also named src are harmless;
an illegal src nested inside a real source tree is still rejected by
check-structure.
With centralized configuration, put the snippets above at the monorepo root and
widen their file scopes to the workspace trees (for example,
files: ["packages/*/src/**/*.ts"] in ESLint and includes: ["**/src/**"] in
Biome). Both styles of invocation are supported:
# From the monorepo root
eslint packages && oxlint packages && biome lint packages
# From one workspace package; the tools discover the ancestor config
cd packages/catalog
eslint src && oxlint src && biome lint srcWith package-local configuration, put the snippets in each configured package
(keep ESLint's files: ["src/**/*.ts"]; for a nested Biome config use
{ "root": false, "extends": "//" }). You can still lint all configured packages
from the monorepo root or run the same src commands inside one package. Run the
project-wide check once per source tree, either inside the package with no argument
or from the root with its path:
nested-boundaries check-structure packages/catalog/srcImport another workspace package by its bare package name. That import uses the package's declared public interface; nested-boundaries does not reinterpret the other workspace's internal Modules as relationships in the importing package.
Adopting in an existing codebase
You don't need a green field. With no modules/ folder yet, the lint rule has
nothing to say — enforcement grows exactly as fast as you carve modules out, and
the untouched remainder can consume each new module immediately.
ADOPTION.md (shipped in the package) is the incremental
migration playbook: triage, a bottom-up carving loop, and the anti-patterns that
technically comply but defeat the point. It is written to be handed to an AI
agent working on your behalf.
Assumptions and requirements
TypeScript codebase with a
tsconfig.jsonbeside each enforcedsrc/tree; path aliases are resolved through that source tree's own config automatically, regardless of where the linter command starts. A root alias is optional but recommended (imports read the same from any depth); the package does not create one — declare it in your tsconfig:// tsconfig.json "compilerOptions": { "paths": { "@/*": ["./src/*"] } }Source root named exactly lowercase
src/. Public source-root configurability is not part of this release; the internal inference seam keeps it additive later.ESLint ≥ 10 (flat config), oxlint ≥ 1.73, and/or Biome ≥ 2.5. Note oxlint's JS-plugin support is alpha — behavior differences between the two ESLint-API linters are oxlint bugs. The Biome plugin is a separate GritQL implementation of the same policies (Biome cannot load ESLint plugins); this package's own violation suite runs every check under all three linters so they cannot drift. One Biome caveat: because GritQL cannot touch the filesystem, a directory import of an internals folder (
@/modules/checkout/libresolving tolib/index.ts) is misread as an entry point namedlib— ESLint/oxlint catch that case.Node ≥ 20.
check-structureuses dependency-cruiser's JavaScript interface rather than its CLI; an even-numbered Node release remains the tested setup.
How it works (short version)
For every linted file, the rule infers an absolute workspace-package root from the
last lowercase src segment and hands that root—and the adjacent tsconfig.json—to
@boundaries/eslint-plugin. It then supplies two
path-suffix element descriptors (modules/*, and the source root itself) plus a
policy list over the computed relationship between importer and target: allow
internal, child, sibling, and uncle targets — entry points only for the last
three — forbid parent and ancestor, deny everything else, with black-box-test
overrides keyed off tests/ paths. Suffix matching is what makes two descriptors
cover unbounded depth; the structure CLI keeps the grammar unambiguous so path
matching stays sound.
check-structure asks dependency-cruiser for the resolved local file graph, checks
file cycles, then groups every file by its innermost Home Module and checks that
collapsed graph too. This catches cycles distributed across unrelated files that a
file-only cycle rule cannot see. Test-file dependencies do not contribute to the
Home-Module graph, and cycles whose every edge is type-only remain legal.
