Kousigan A

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

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:

.dependency-cruiser.js
// 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:

pyproject.toml
[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:

.github/CODEOWNERS
# 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/platform

The 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:

src/billing/AGENTS.md
# 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.

Prompt 1: propose boundary rules for a module
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.

Prompt 2: make a change inside a module boundary
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 boundary checklist
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 correct

Quick 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.

Cartoon of Kousigan making a heart with his hands

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.