Coding agents can cross module boundaries: make a check fail
Give coding agents module boundaries a tool can check: a public surface, one dependency direction, owners in CODEOWNERS and folder-scoped instructions.
Last verified against official docs on
Contents
Code boundaries a coding agent can work within have three parts: a public surface for each module, one dependency direction and a named owner. Check each part with a tool, such as dependency-cruiser, import-linter, ArchUnit or CODEOWNERS, and put the module’s rules in a folder-scoped instructions file. Then a wrong import fails the build, though no boundary can tell you the change is correct.
The tool behaviour below comes from each tool’s official docs, last verified on 11 October 2026; none of it was tested for this post. This is part 24 of the series on AI-assisted engineering. Mapping an existing system before a change is Part 8, and splitting work between parallel agents is Part 13.
A boundary says where code lives and what it may import
A code boundary is a rule about where code belongs and what may depend on it. It has three parts, and the public surface comes with its opposite, the internal code. Each needs a place where it is written down and, ideally, something that checks it:
| Part | The question it answers | Where it is written down | What can check it |
|---|---|---|---|
| Public surface | What may other modules import? | An index.ts, a package’s exports, a Go package outside internal/ |
The Go command, Node.js, a lint rule |
| Internal code | What is private to the module? | An internal/ folder |
The same as above |
| Dependency direction | Which way may imports point? | A list of layers or allowed dependencies | dependency-cruiser, import-linter, ArchUnit, Nx |
| Owner | Who reviews a change here? | CODEOWNERS |
GitHub review requests and branch protection |
A made-up layout shows all of them. Billing has one public file and a private
folder, and imports point one way: ui uses billing, and billing uses
shared.
src/
├── ui/
├── billing/
│ ├── index.ts # public surface
│ ├── internal/ # private to billing
│ └── AGENTS.md # billing's rules
└── shared/ # used by every module
Boundaries matter more with an agent in the codebase. An agent finds what to read by searching, and a search can land on an internal file as easily as a public one. In a TypeScript or Python codebase, an import that reaches into another module’s private folder still runs, and its tests can pass. Unless a rule says no, the shortcut looks like working code.
Make the public surface the only way in
The public surface of a module is the set of files and exports other modules may use. Some languages and runtimes can enforce it for you.
Go does it with folder names. The
Go 1.4 release notes cover
packages in a directory named internal. Such a package “can be imported
only by code in the directory tree rooted at” the parent of internal. So
.../a/b/c/internal/d/e/f can be imported from .../a/b/c, but “cannot be
imported by code in .../a/b/g”. The go command checks this whenever it
sees such an import.
Node.js does it for packages. The
package entry points docs
say that “when the "exports" field is defined, all subpaths of the package
are encapsulated and no longer available to importers”. A require of an
unlisted subpath throws ERR_PACKAGE_PATH_NOT_EXPORTED.
Folders inside one app or package have no such rule. A lint rule closes that
gap. dependency-cruiser
takes forbidden rules, each with a from and a to that match file paths
with a regular expression. An example for the layout above, untested:
// Example rules for a made-up layout: src/ui, src/billing, src/shared
module.exports = {
forbidden: [
{
name: 'billing-internal-is-private',
comment: 'Import billing through src/billing/index.ts only',
severity: 'error',
from: { pathNot: '^src/billing/' },
to: { path: '^src/billing/internal/' },
},
{
name: 'billing-does-not-import-ui',
comment: 'ui uses billing, never the other way round',
severity: 'error',
from: { path: '^src/billing/' },
to: { path: '^src/ui/' },
},
{
name: 'shared-imports-no-feature-module',
comment: 'src/shared is used by modules and must not import them',
severity: 'error',
from: { path: '^src/shared/' },
to: { path: '^src/(billing|ui)/' },
},
],
}Write the reason into the rule. dependency-cruiser has a comment field.
ESLint’s
no-restricted-imports
takes a message, which “is appended to the default error message from the
rule”. Whoever breaks the rule sees that message, and an agent that runs the
check reads it too.
Point dependencies one way, and fail the build when they don’t
Dependency direction is the rule about which way imports may point. Higher layers use lower ones, and never the reverse. These tools turn that rule into a check that fails:
| Tool | Language | How you write the boundary | What a violation does |
|---|---|---|---|
| dependency-cruiser | JavaScript, TypeScript | forbidden rules with from and to paths, or allowed rules where anything not listed is reported |
Severity error makes some reporters, “at least the err one”, “return a non-zero exit code”; the default severity is warn |
ESLint no-restricted-imports |
JavaScript, TypeScript | paths for whole modules, patterns with gitignore-style group or a regex |
A lint problem at the level you set, with your message |
Nx @nx/enforce-module-boundaries |
JavaScript, TypeScript | Tags on each project, and depConstraints saying which tags a sourceTag may depend on |
A lint error for ESLint or Oxlint |
| import-linter | Python | Contracts: forbidden, protected, layers, independence, acyclic siblings or custom | lint-imports checks “that your project adheres to the contracts you’ve defined” (run) |
| ArchUnit | Java | Rules in a test, such as layeredArchitecture() with mayOnlyBeAccessedByLayers(...) |
A failing test “will report an AssertionError” with the violation |
Go internal packages |
Go | A folder named internal |
The go command refuses the import |
Each row comes from the linked docs, last verified on 11 October 2026; none was tested for this post.
A layers contract in import-linter is a short list, top to bottom. The
layers docs
say “higher layers may depend on lower layers, but not the other way
around”. An example in pyproject.toml, with made-up package names:
[tool.importlinter]
root_package = "shop"
[[tool.importlinter.contracts]]
name = "Shop layers"
type = "layers"
layers = [
"shop.ui",
"shop.billing",
"shop.shared",
]A check only protects you if it runs on every change. Put it in CI next to the tests, and make it a required check, as Part 10 describes. Put the same command in the module’s instructions too, so the agent runs it before it says it is done.
Name an owner for each module and for the boundary rules
An owner is the person or team who reviews changes to a module. GitHub’s code owners docs say “code owners are automatically requested for review when someone opens a pull request that modifies code that they own”. With “Require review from Code Owners” on in branch protection, the change needs their approval.
An example CODEOWNERS file for the layout above:
# Example: each module has an owner, and so do the boundary rules
/src/billing/ @your-org/billing
/src/shared/ @your-org/platform
/.dependency-cruiser.js @your-org/platform
/pyproject.toml @your-org/platformThe last two lines matter as much as the first two. The file that defines the boundaries is a file an agent can edit. If a change loosens a rule to make the check pass, the owner of that rule sees it in review.
The same docs say “the last matching pattern takes the most precedence”, so put broad paths first and narrow ones after them. Owners “must have write permissions for the repository”. The file also “must be on the base branch of the pull request”, so a new owner line starts working once it is merged.
Folder-scoped instructions in seven coding agents
A folder-scoped instructions file holds the rules for one part of the
codebase, and it loads for work in that part. The billing module’s public
surface and its check command can sit in src/billing/,
out of the context of every other task.
Part 1 listed
which agents read AGENTS.md. The table below is about scope: what loads
for one folder or path, and which file wins when two apply.
| Agent | Scoped by folder | Scoped by path pattern | When it loads, and which wins |
|---|---|---|---|
| Claude Code (memory) | A CLAUDE.md in a subdirectory; by default, a subdirectory’s AGENTS.md loads when there is no CLAUDE.md in that folder, the working directory or above |
.claude/rules/*.md with paths globs in the frontmatter |
A nested CLAUDE.md loads “once Claude reads, writes, or edits another file in that subdirectory”; a path rule loads when Claude uses Read, Write or Edit on a matching file. “If two instructions contradict each other, Claude may pick one arbitrarily” |
| OpenAI Codex (AGENTS.md) | AGENTS.md or AGENTS.override.md in each folder from the project root down to the current directory |
None described on that page | Built “once per run”; “Files closer to your current directory override earlier guidance”, and “Codex stops searching once it reaches your current directory” |
| Cursor (rules) | Nested AGENTS.md; .cursor/rules in subdirectories |
globs in a rule’s frontmatter, “Auto-attached when a matching file is in context” |
Nested AGENTS.md is “combined with parent directories, with more specific instructions taking precedence” |
| GitHub Copilot (GitHub, VS Code) | Nested AGENTS.md; in VS Code behind chat.useNestedAgentsMdFiles, experimental and off by default |
.github/instructions/*.instructions.md with an applyTo glob; on GitHub.com only for cloud agent and code review |
On GitHub, “the nearest AGENTS.md file in the directory tree will take precedence”, and repository-wide plus path-specific instructions are both used. VS Code calls its sources “additive” and says not to rely on an order |
| Gemini CLI (GEMINI.md) | GEMINI.md in any folder; context.fileName can add AGENTS.md |
None described on that page | “When a tool accesses a file or directory”, it scans that folder and its ancestors “up to a trusted root”; all found files are joined and sent with each prompt, and /memory show prints the result |
| Windsurf, now documented as Devin Desktop (AGENTS.md, rules) | A subdirectory AGENTS.md is “applied only when Cascade reads or edits files inside that directory”; rules folders in sub-directories |
Rules with trigger: glob, applied “when Cascade reads or edits a file matching the globs pattern” |
A root AGENTS.md is always on; workspace rules are capped at 12,000 characters |
| Google Antigravity (rules) | AGENTS.md, GEMINI.md or .agents/rules/*.md in the workspace folder and its parents; it “doesn’t load rules from subfolders below the workspace folder, even when the agent reads or edits files there” |
Rules with a glob trigger, which “Automatically activates when the agent interacts with files matching any comma-separated pattern in globs” |
“Rules are cumulative rather than replacement-based”, so every rule found goes into the prompt and none overrides another |
Each row comes from that agent’s official docs, last verified on 11 October 2026, and the Antigravity row on 1 November 2026, after its docs changed; none was tested for this post.
Gemini CLI’s docs also note that for unpaid tier and Google One users, Gemini CLI was replaced by Antigravity CLI on 18 June 2026.
Claude Code, Gemini CLI and Windsurf load a folder’s file when they touch a
file in that folder. Antigravity does not load rules from below the workspace
folder, so a module’s rule there needs a glob trigger instead. Codex
builds its chain
“once per run”, from the project root down to the folder you start it in.
That page describes no loading by file path, so a module’s AGENTS.md seems to reach Codex when
you start it in that module or below.
An example AGENTS.md for the billing module:
# Billing module
Owner: @your-org/billing (see CODEOWNERS)
## Public surface
- Other modules import billing only through `src/billing/index.ts`.
- `src/billing/internal/` is private to billing.
## Dependencies
- Billing may import from `src/shared/`.
- Billing must not import from `src/ui/`.
## Before you finish
- Run `npx depcruise src --config .dependency-cruiser.js`.
- If the task needs a new export or a new dependency, stop and ask.Claude Code has a trap here. By default it reads AGENTS.md files only
when there is no CLAUDE.md or CLAUDE.local.md in your working directory
or above it. In a repository with a root CLAUDE.md, the billing
AGENTS.md is skipped unless you set Project instructions to
claude-md-and-agents-md
(docs).
Every file in that table is guidance the agent reads. Claude Code’s
memory docs say Claude treats
these files “as context, not enforced configuration”. Back each boundary in
the billing AGENTS.md with a dependency-cruiser rule or a code owner. The
instructions file explains the boundary, and the tool holds it.
Two prompts: find the boundaries, then work inside one
The first prompt is for a module that has no written boundaries yet. The second is for everyday work once it has them. Neither relies on a tool-specific feature.
Prompt 1: propose boundary rules for a module
An example prompt for a read-only pass, before you write any rules.
Task: propose boundary rules for <module or folder>.
Intended dependency direction: <for example "ui uses billing,
billing uses shared, never the reverse">
Read only what this task needs. Do not edit files.
1. List the module's public surface: the files and exports that
other modules import today. Cite each importer by file and line.
2. List imports from outside the module that reach its internal
files.
3. List what the module imports, grouped by module. Flag any import
that points against the intended direction.
4. Name the owner, if CODEOWNERS covers the folder.
5. Propose rules for <dependency-cruiser, import-linter, ArchUnit,
ESLint or your tool> that would fail on the imports from steps
2 and 3, and a short folder-scoped instructions file for the
module.
Mark anything you could not confirm as unverified.
Never print, expose or commit secret values.
Do not commit, push, deploy, run destructive commands or make external
changes unless I explicitly authorise it.
If two reasonable attempts fail, stop and report the failure rather
than repeatedly guessing.Existing violations are a decision for the module’s owner: fix them first, or list them as known exceptions, before the rule turns on in CI.
Prompt 2: make a change inside a module boundary
An example prompt for a task that should stay inside one module.
Task: <describe the change>
Module you may change: <folder>
Modules you may use, through their public surface only: <list>
Before editing, read the module's instructions file and the boundary
rules that cover it.
Do not import another module's internal files, add a dependency in a
new direction, or edit boundary rules, CODEOWNERS or instructions
files.
If the task cannot be done inside these limits, stop and explain
which boundary is in the way and what you would change.
After editing, run <your boundary check command> and the tests.
Report each command and its result.
Never print, expose or commit secret values.
Do not commit, push, deploy, run destructive commands or make external
changes unless I explicitly authorise it.
If two reasonable attempts fail, stop and report the failure rather
than repeatedly guessing.A stop at a boundary is a useful result: the task needs a design decision before it needs more code.
A boundary checklist before agents edit a module
An example checklist, in the order of this post:
MODULE BOUNDARIES FOR CODING AGENTS
□ Public surface written down (index file, exports, internal/)
□ Internal files unreachable from outside, or a rule fails on it
□ Dependency direction written as rules a tool checks
□ Boundary check runs in CI as a required check
□ Each rule carries a message that says why
□ Every module has an owner in CODEOWNERS
□ Boundary config files have an owner too
□ Folder-scoped instructions file in each module
□ Instructions name the check command to run
□ Agent told to stop when a task needs to cross a boundary
□ Tests and review still decide whether the change is correctQuick answers
What is a module boundary in software?
A module boundary is a rule about what other code may use from a module and which way its dependencies may point. It usually has a public surface, such as an index file or exported package, and internal code that other modules should not import.
How do you enforce module boundaries in a codebase?
Module boundaries are enforced with tools that fail a check on a forbidden
import. For JavaScript and TypeScript, that means dependency-cruiser,
ESLint’s no-restricted-imports or Nx. Python has import-linter, and Java
has ArchUnit, which runs boundary rules as tests.
Do coding agents read AGENTS.md files in subfolders?
Coding agent support for nested AGENTS.md files varies. Cursor, Windsurf and Copilot on GitHub use a subfolder’s file, with closer files winning where the docs say so. Antigravity reads only the workspace folder and its parents. Codex reads files from the project root down to the folder where it starts.
What is an internal package in Go?
An internal package in Go is a package inside a directory named internal.
Code in the tree rooted at the parent of internal may import it. The go
command refuses imports from elsewhere in the repository or from another
repository.
What does a CODEOWNERS file do?
A CODEOWNERS file names the people or teams who own paths in a GitHub repository. They are asked to review pull requests that change those paths. With “Require review from Code Owners” on, the pull request needs one of their approvals.
More on AI coding is in the AI category.

Kousigan A
Build with AI. Ship with care.
Software engineer building real products with AI assistants. I write down what works: prompts, rules, skills and agents, and how to ship safely.