Skip to Content
GatesBEBE lintsModule-layeringen

Module-layering

The input is code that is already written — one file, one hunk of a diff. The output is a verdict: whether the file was in scope at all, which published rule fired, on which specifier, which law code that maps to, and the open hatch that would have hidden the same failure. This module chooses no layout. It refuses one, and it must be able to point at the specifier it refuses on.

Law

The law is about the seams between capabilities: what an import may name, and what a capability may say about itself. An import names the file that declares the symbol, never a folder that re-exports one; and inside a capability, imports are relative rather than routed through the capability’s own public alias.

The law states five codes. Two of them have a rule. The source publishes exactly two rules in its rules export and exactly two in its recommended export, the two lists agree, and both ask for error. Three codes are held by nothing.

One of the three is unenforced on purpose and says so in the source: deciding whether an imported module is a sibling capability or a nested child needs the module graph, and a rule reading one file at a time cannot see it. That is the honest shape of this boundary — both rules are pure string work over one specifier, which is exactly why they can be error with no false-positive budget, and exactly why everything requiring a second file is out of reach.

Published rules

RuleCodeWhat it reports
must-deep-module-importLAYERING-1barrel on an aliased specifier with no segment after the capability name — @modules/<name>, @features/<name>, @tests/<name>, the bare prefix alone, and @modules/<meta root>/<name> where the meta root is one of three listed category folders
no-self-module-aliasLAYERING-2self on a specifier that reaches the importing file’s own capability through that capability’s public alias, where “own” is derived from the file’s path

LAYERING-3 (a capability module importing a sibling capability’s module rather than wiring it at the composition root), LAYERING-4 (the composition root is the only place that knows the whole) and LAYERING-5 (a capability’s public surface is the files it means to be imported) are enforced by no rule. They are unenforced rather than covered, and a green run says nothing about any of them.

LAYERING-5 deserves the sharpest note, because it looks held and is not. must-deep-module-import polices the calling half — nobody may import a barrel through an alias. Nothing polices the declaring half: writing an index.ts that re-exports a folder is unreported, and once it exists every relative and unaliased path to it is legal. The rules make barrels inconvenient to reach; they do not make them impossible to write. A reader who concludes from the table above that barrels are forbidden has read one level wrong.

Reading a diff

  1. Decide scope before anything else, and record it. For no-self-module-alias, a file whose path contains none of /src/modules/, /src/features/, /src/tests/ gets an empty visitor — out of scope does not mean the file passed, it means the rule did not exist for that file. must-deep-module-import never reads context.filename and is in scope everywhere.
  2. Check the exemptions. A local export { X } with no source is skipped. A specifier that is not a string — a template literal, a computed source — is skipped. A file at the composition root, outside every capability tree, is released from no-self-module-alias entirely.
  3. Read the nodes. Only three static specifier positions exist for either rule: ImportDeclaration, ExportNamedDeclaration with a source, ExportAllDeclaration. Every dynamic form is outside their reach by construction, so read the form before reading the string.
  4. Emit one block per finding. One specifier can carry two findings: a self-aliased barrel is reported by both rules, independently.
  5. Write the hatch line whenever an open hatch would have hidden the same failure. A silent verdict is a real result and must be reported. “The gate is green” and “the rule looked” are different claims, and only one of them is evidence.
  6. Do not report what no rule watches. Three of the five codes have no machine; a verdict that claims otherwise is wrong about the module.

must-deep-module-import — LAYERING-1

What it reports. barrel — a specifier that names a capability and no file. @modules/ai instead of @modules/ai/ai-invoke.service. One report per offending specifier.

How it detects. Visits ImportDeclaration, plus ExportNamedDeclaration and ExportAllDeclaration when node.source exists. Reads node.source.value and returns unless typeof is string. Finds the first entry of a 3-entry ALIASES array whose prefix the specifier startsWith: @modules/, @features/, @tests/. Slices the prefix off, reports immediately when the remainder is empty, otherwise splits the remainder on / and compares parts.length against a barrelDepth of 1 — or 2 when the alias is metaAware (only @modules/) and parts[0] is in a 3-entry META_ROOTS Set: platform, lib, integrations. parts.length <= barrelDepth reports. It never reads context.filename.

What it cannot see. It counts segments, never what the last segment resolves to. @modules/ai/index is two segments and passes — naming the barrel explicitly is the cleanest way past the rule that exists to forbid barrels. @modules/ai/services where services/ has an index.ts is the same hatch and the ordinary one: a nested folder barrel is indistinguishable from a file to a rule that never resolves anything. @modules/ai/ splits into ["ai", ""] — two segments, and the empty tail is never checked. @modules//ai and @modules/./ai are the same arithmetic. A relative specifier has no alias prefix, so a cross-capability barrel reached relatively is invisible. ALIASES and META_ROOTS are three hand-written names each: a fourth path alias or a fourth category folder is unenforced from the moment it is added, silently, with no signal anywhere. And meta awareness belongs to @modules/ alone, so under @features/ and @tests/ a category folder is treated as a capability and its barrels pass.

Boundary. This rule judges one specifier’s shape. Whether the specifier points back at the importing file’s own capability is LAYERING-2. Whether the barrel may be written at all is held by nothing.

no-self-module-alias — LAYERING-2

What it reports. self — a file inside capability ai reaching for @modules/ai/.... A capability talking to itself through its own front door. One report per offending specifier.

How it detects. Reads context.filename || context.getFilename() once at create time and normalizes \ to /. Walks the same ALIASES array in order, taking lastIndexOf of each root/src/modules/, /src/features/, /src/tests/ — and the first root found wins. Splits the path tail on / to derive the self keys: [parts[0]] normally, or ["<meta>/<name>", "<name>"] when the alias is metaAware, parts[0] is a meta root and there are at least two segments. Returns an empty visitor when no root matches. Otherwise visits the same three node types, requires the specifier to startsWith that one alias prefix, and reports when the remainder === key or startsWith(key + "/") for any key.

What it cannot see. @modules//ai/x and @modules/./ai/x leave a remainder of "/ai/x" and "./ai/x", matching neither key nor key + "/". Reaching into another capability relatively — ../../billing/billing.service from inside modules/ai/ — is a boundary crossing with no alias to report, and the rule inspects only alias-prefixed specifiers. A capability tree at apps/api/modules/, src/module/, or any path without the exact /src/modules/ segment pair finds no root and is not checked at all; path shape is the cheapest thing in a repository to change, and here it is load-bearing. A file written directly at src/modules/platform/config.ts derives self keys ["platform/config.ts", "config.ts"] — the file’s own name where a capability name belongs — and since no specifier carries a .ts extension the rule is effectively off for that file while appearing to be on. The short self key under a meta root is unqualified, so from inside modules/platform/exceptions/ an import of a real, separate @modules/exceptions/... is reported as a self alias: the rule fires on correct code, and the habit it teaches is to scroll past it. And a self-import laundered through a third file that re-exports it is two correct-looking imports no single-file rule can see.

Boundary. This rule derives a capability from a path, so its correctness is exactly as good as the folder layout it was written against. Whether the specifier names a barrel is LAYERING-1.

Detection

PartMechanism
node setBoth rules see only ImportDeclaration, ExportNamedDeclaration with a source, and ExportAllDeclaration. ExportNamedDeclaration is guarded by if (node.source)
specifier readnode.source.value, and only when typeof is string
path gatemust-deep-module-import has none — it never reads context.filename. no-self-module-alias reads it once at create, normalizes \ to /, and takes lastIndexOf of /src/modules/, /src/features/, /src/tests/; the first root found wins
alias tableALIASES, three hard-coded { prefix, root, metaAware } entries scanned in declaration order: @modules/, @features/, @tests/. Only @modules/ is metaAware
meta rootsMETA_ROOTS, a closed 3-entry Set: platform, lib, integrations
depth arithmeticRemainder empty reports at once; otherwise split on / and compare parts.length with a barrelDepth of 1, or 2 under a metaAware alias whose parts[0] is a meta root
self matchRemainder === key or startsWith(key + "/"), so a capability whose name merely begins with another’s is not swept in
out of scopeno-self-module-alias returns an empty visitor object. The rule does not exist for that file rather than passing it

Both are single-file and string-only. Neither resolves an import, touches the filesystem, reads a type, or knows whether the last segment of a specifier is a file, a folder, or nothing at all.

Escape hatches

Closed — a reader might expect these to slip past, and they do not.

Written this wayWhy it still fires
import type { Ctx } from "@modules/ai"A type-only import is still an ImportDeclaration; importKind is never consulted, so the type lane is not a side door
export * from "@modules/ai" and export { X } from "@modules/ai"Both re-export forms are visited, and the second is guarded by if (node.source) so a local export { X } with no source is skipped rather than crashing
import "@modules/telemetry" for side effects onlyThe specifier is read from node.source, not from the binding list. An import with no specifiers reports exactly like one with ten
import { X } from "@modules/"The empty remainder is reported before the split, so the degenerate spelling is not an accidental pass
import { X } from "@modules/platform"A meta root on its own is one segment against a barrelDepth of 2, so naming the category folder is a barrel, not a capability
import { X } from "@modules/ai-billing/x" from inside modules/ai/The self test is rest === key || rest.startsWith(key + "/"). The trailing slash makes the boundary real, so a capability whose name merely begins with another’s is not swept in
Windows paths in no-self-module-alias\ is normalized to / before any lastIndexOf, so a backslash path derives the same capability as every other path
A file at the composition root, outside every capability treeselfAliases returns null and the rule returns {}. This is correct rather than lax: a file that belongs to no capability cannot import its own
import { AiService } from "@modules/ai" from inside modules/ai/Both rules report the same line — one for naming a barrel, one for the self alias. They are independent tests that happen to agree here

Open — shipped blindness. A verdict must not claim these were judged.

ScopeWhat passes
must-deep-module-importimport { X } from "@modules/ai/index" — the rule counts segments, not files. Two segments beat a barrelDepth of 1, so naming the barrel explicitly is the cleanest way past the rule that exists to forbid barrels
must-deep-module-importimport { X } from "@modules/ai/services" where services/ has an index.ts — same counting hatch, and the ordinary one. A nested folder barrel is indistinguishable from a file to a rule that never resolves anything; the law says name the declaring file, the rule enforces at least one segment after the capability
must-deep-module-importimport { X } from "@modules/ai/" — the trailing slash splits into ["ai", ""], two segments. The empty tail is never checked, and the resolver collapses it back to the folder
bothimport { X } from "@modules//ai" and from "@modules/./ai" — the extra segment is "" or ".", both count. The identical trick defeats no-self-module-alias, where "/ai/x" and "./ai/x" match neither key nor key + "/"
bothconst { X } = await import("@modules/ai") — an ImportExpression is not an ImportDeclaration. No visitor exists for it in either rule
bothconst { X } = require("@modules/ai"), and import X = require("@modules/ai") — a CallExpression and a TSImportEqualsDeclaration. Neither is visited, and the second is the one a config file is most likely to use
must-deep-module-importimport { X } from "../../ai" — every check begins with startsWith against an alias prefix. A relative path never has one, so a cross-capability barrel reached relatively is invisible
bothimport { X } from "../../billing/billing.service" from inside modules/ai/ — the inverse abuse of the same hole: reaching into another capability relatively is a boundary crossing with no alias to report. no-self-module-alias inspects only alias-prefixed specifiers, so the rule pair is silent on the one form that hides the seam completely
bothimport { X } from "@shared/utils", "@app/...", "src/modules/ai"ALIASES is three hand-written prefixes. A fourth path alias added to the compiler config is unenforced from the moment it is added, silently, with no signal anywhere
must-deep-module-importimport { X } from "@modules/adapters/mailer"META_ROOTS is three hand-written names. A fourth category folder makes its capabilities read as capability-plus-file, so every barrel under it passes
bothimport { X } from "@features/platform/billing" — meta awareness belongs to @modules/ alone. Under the other two aliases a category folder is treated as a capability, so its barrels pass and, in no-self-module-alias, its siblings are misread as self
no-self-module-aliasA capability tree at apps/api/modules/, src/module/, or any path without the exact /src/modules/ segment pair — no root is found, {} is returned, and the file is not partially checked, it is not checked at all. Path shape is the cheapest thing in a repository to change, and here it is load-bearing
no-self-module-aliasA file written directly at src/modules/platform/config.ts — the self keys become ["platform/config.ts", "config.ts"], the file’s own name where a capability name belongs. No specifier carries a .ts extension, so the rule is effectively off for that file while appearing to be on
no-self-module-aliasA genuine top-level capability named the same as one under a meta root — the short self key is unqualified. From inside modules/platform/exceptions/, an import of a real, separate @modules/exceptions/... is reported as a self alias. An inverted hatch: the rule fires on correct code, and the habit it teaches is to scroll past it
bothA self-import laundered through a third file that re-exports it — both rules read one specifier in one file. A capability that reaches itself through a neighbour’s re-export is two correct-looking imports, and no single-file rule can see the loop
both// eslint-disable-next-line above either rule — neither rule is unsuppressible. Every hatch above is also reachable in one line by somebody in a hurry
neitherEverything LAYERING-3, LAYERING-4 and LAYERING-5 forbid — a capability module importing a sibling’s module instead of wiring it at the composition root, a composition root that is no longer the only place that knows the whole, and an index.ts written to re-export a folder

Every open hatch above is a hatch in the rule, never a permission in the law. Code that slips through is still wrong.

Inputs

InputEvidence required
node.source.valueThe specifier as a string. A template literal or a computed source is skipped, and the verdict must say so rather than call it clean
context.filenameno-self-module-alias only. The path with \ normalized to /, and which of the three roots lastIndexOf found — or that none did
ALIASESWhich of the three { prefix, root, metaAware } entries matched, in declaration order, or none matched
META_ROOTSWhether parts[0] was in the closed 3-entry Set, because it decides the barrelDepth and the self keys

Nothing else is read. Both rules declare schema: [] and therefore take no options; there is no configuration by which a repository can add an alias, add a meta root, or move a root.

Rules

  1. A rule’s identity is its published name. There is no numeric identifier for a rule; the name is what a build prints, what a disable comment carries, and what any conversation about a failure uses.
  2. Each rule maps to exactly one code in the law, and no code is held by two rules.
  3. Both rules are meta.type: "problem" and both are error in recommended.
  4. Both see only the three static specifier positions: ImportDeclaration, ExportNamedDeclaration with a source, ExportAllDeclaration. Every dynamic form is outside their reach by construction.
  5. must-deep-module-import decides on segment count, never on what the last segment resolves to. Every sentence about “files” in its message is the law speaking, not something it measures.
  6. no-self-module-alias derives a capability from a path, so its correctness is exactly as good as the folder layout it was written against.
  7. A missing root returns an empty visitor, so an out-of-tree file is not half-checked.
  8. Every open hatch is a hatch in the rule, never a permission in the law. Code that slips through is still wrong.

Exceptions

These are exemptions already present in the source, not room to manoeuvre.

  • Meta roots. platform, lib, integrations count as containers of capabilities rather than capabilities. This releases @modules/platform/exceptions from passing as capability-plus-file — it is correctly a barrel — and it releases a file inside such a folder into two self keys, long and short. It is the most expensive exception here: the list is closed, and every open hatch it owns comes from that list missing or holding one name.
  • A file outside every capability tree. The composition root and any file belonging to no capability is released from no-self-module-alias completely. This is correct rather than lax: a file that belongs to no capability cannot import its own.
  • export { X } with no source. Only a re-export that has a source is checked; a local export { X } is not an import and is released from both rules.
  • LAYERING-3 is unenforced on purpose. The source says so directly: separating a sibling capability from a nested child needs the module graph. A repository that wants it must write a tree-walking gate, not a per-file rule that guesses.
  • error is the recommendation, but only after measuring. The source records both rules measured at zero debt in the reference repository. A repository applying them to an existing tree will not be at zero, because a barrel is the default of most code. Measure first, drop to warn with the number attached, then burn it down.

Beyond these, neither rule declares an option, an allowlist or a per-file opt-out. The only other exit is a disable comment, and this module grants none; that comment is recorded above as an open hatch, not as an exception.

Output

One block per finding:

rule: <must-deep-module-import | no-self-module-alias> code: <LAYERING-1 | LAYERING-2> file: <path as the rule normalized it, or "not read" for LAYERING-1> alias: <@modules/ | @features/ | @tests/ | none matched> self keys: <derived capability keys, or "none: file is outside every root"> segments: <parts.length vs barrelDepth, for LAYERING-1> message: <barrel | self> verdict: <fires | silent: hatch <name from the Open table>>

A clean file emits one block with message: none and verdict: silent: no hatch — every static specifier was read and none matched. A file out of scope for no-self-module-alias emits self keys: none: file is outside every root and verdict: silent: no visitor installed, which is not a pass.

Worked example

Input. src/modules/ai/ai-invoke.service.ts:

import { Injectable } from "@nestjs/common" import { AiService } from "@modules/ai" import { BillingService } from "@modules/billing" export * from "@modules/platform" @Injectable() export class AiInvokeService { constructor( private readonly ai: AiService, private readonly billing: BillingService, ) {} }

The file sits under /src/modules/, so no-self-module-alias finds a root and derives self keys ["ai"]. must-deep-module-import is in scope everywhere and never reads the path.

rule: must-deep-module-import code: LAYERING-1 file: not read alias: @modules/ self keys: n/a segments: 1 vs barrelDepth 1 message: barrel verdict: fires
rule: no-self-module-alias code: LAYERING-2 file: src/modules/ai/ai-invoke.service.ts alias: @modules/ self keys: ["ai"] segments: n/a message: self verdict: fires

Those two are the same line, @modules/ai. They are independent tests that happen to agree.

rule: must-deep-module-import code: LAYERING-1 file: not read alias: @modules/ self keys: n/a segments: 1 vs barrelDepth 1 message: barrel verdict: fires

That is @modules/billing. The export * from "@modules/platform" is visited as an ExportAllDeclaration, and platform is a meta root standing alone:

rule: must-deep-module-import code: LAYERING-1 file: not read alias: @modules/ self keys: n/a segments: 1 vs barrelDepth 2 message: barrel verdict: fires

@nestjs/common matches no alias prefix and is never examined.

Repaired. The self alias becomes relative, each cross-capability import names the declaring file, and the re-export names a file under the category folder:

import { Injectable } from "@nestjs/common" import { AiService } from "./ai.service" import { BillingService } from "@modules/billing/billing.service" export * from "@modules/platform/exceptions/domain.exception" @Injectable() export class AiInvokeService { constructor( private readonly ai: AiService, private readonly billing: BillingService, ) {} }

An open hatch survives the repair. Add one more line and the seam disappears without a finding:

import { BillingRepository } from "../billing/billing.repository"
rule: no-self-module-alias code: LAYERING-2 file: src/modules/ai/ai-invoke.service.ts alias: none matched self keys: ["ai"] segments: n/a message: none verdict: silent: hatch reaching into another capability relatively — no alias prefix, so neither rule inspects the specifier and the boundary crossing is invisible

The silence is not compliance. The same import written as @modules/billing/billing.repository crosses the same boundary and reads the same way to a human; only the spelling changed.

Scope

This module documents enforcement of two rules of one back-end law, not the law itself. It does not judge whether a barrel may be writtenLAYERING-5’s declaring half has no machine — nor whether a capability should have been wired at the composition root instead of imported, which is LAYERING-3 and LAYERING-4, held by nothing and owned by a tree-walking gate this module does not contain. It names no product, no company and no repository. Rule names, message identifiers, alias prefixes and folder names the rules match are identifiers that ship and are reproduced verbatim; that exemption covers nothing else.