Architecture rules
MapX checks Deptrac-style architecture rules for every language it indexes (milestone M5). You declare layers with selectors, and a policy of which layers may depend on which. mapx check reports every dependency that breaks the policy, in a format your CI reads, and exits non-zero when one does. The design is in docs/research/deptrac/02-abstract-feature-model.md. MapX checks its own source this way: see MapX's own rules.
Quick start
Take a small TypeScript shop with four folders under src/: controller, domain, infra and shared. Declare one layer per folder and what each may use, in .mapx/architecture.jsonc:
{
"layers": [
{ "name": "Controller", "selectors": [{ "path": "src/controller/**" }] },
{ "name": "Domain", "selectors": [{ "path": "src/domain/**" }] },
{ "name": "Infrastructure", "selectors": [{ "path": "src/infra/**" }] },
{ "name": "Shared", "selectors": [{ "path": "src/shared/**" }] }
],
"rules": {
"*": ["Shared"], // every layer may use Shared
"Controller": ["+Domain"], // Domain, and whatever Domain may use
"Infrastructure": ["Domain"],
"Domain": []
}
}Index the code, then check it:
$ mapx init && mapx scan
$ mapx check
Controller
✖ disallowed-layer [error] src/controller/OrderController.ts#OrderController → src/infra/SqlOrderRepository.ts#SqlOrderRepository (import)
src/controller/OrderController.ts:2 src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure)
✖ disallowed-layer [error] src/controller/OrderController.ts#OrderController → src/infra/SqlOrderRepository.ts#SqlOrderRepository (instantiation)
src/controller/OrderController.ts:7 src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure)
Violations: 2 (2 error, 0 warn) Uncovered: 0 Skipped: 0 Allowed: 12 Warnings: 0 Errors: 0
$ echo $?
1To adopt the rules on code that already breaks them, freeze today's violations in a baseline; only new ones fail from then on:
$ mapx check --generate-baseline
Wrote 2 baseline entries to .mapx/architecture.baseline.json.
$ mapx check
Violations: 0 (0 error, 0 warn) Uncovered: 0 Skipped: 2 Allowed: 12 Warnings: 0 Errors: 0mapx arch init writes a first configuration for you (see Getting started). The examples on this page use this shop; each was run against the CLI.
What is in it
The features, by the task that delivered them:
- The configuration document (M5-02): its schema, loading,
extends, parameters, views and validation. - The element view, the core selectors and layer membership (M5-03).
- Rule evaluation and the report (M5-04): policy expansion, the evaluation chain, findings with fingerprints, and the layer matrix.
mapx check(M5-05): the table and JSON reporters, and the exit contract. See cli-reference.md.mapx arch validate-configand the MCP toolmapx_arch_validate_config(M5-02-R1): validate the configuration alone, without an index.- Baselines and the
--compareratchet (M5-06): see Baselines and the ratchet. They are CLI only:mapx_arch_checkapplies the configuration's baseline but cannot generate one or compare.--since <ref>, the ratchet without a base report, is in both (see--since). - Introspection (M5-08):
mapx arch layers,which,unassigned,deps,unused,changedandallowed. See Introspection. - CI reports (M5-07):
--format sarif,github(automatic inside GitHub Actions),codeclimateandjunit. See CI reports. - MCP tools (M5-14, AR-19):
mapx_arch_check,mapx_arch_layers,mapx_arch_allowed,mapx_arch_explainandmapx_arch_initwrapmapx checkand the introspection functions. See mcp-integration.md. - Layer graphs (M5-13):
mapx arch graph --format dot|svg|mermaid|dsm|json. See Layer graphs. CLI only: no MCP tool draws the graph yet. - Hierarchy and member selectors, and inherited dependencies (M5-09):
extends,implements,inherits,mixes,inheritanceDepth,member, andpolicy.inheritDependencies. See Type hierarchy and members. - Smells from the rules (M5-16, AR-18): with a configuration,
mapx_smells,mapx archand the dashboard take their layer violations from the rules and gain the smells the rules make possible. See Smells. mapx arch initand the presets (M5-17, M5-17-R1, AR-15): a starting configuration inferred from roles or clusters, or from one of AR-15's 20 shipped presets, with an optional baseline. The framework presets select by ancestry and annotations where the framework defines a layer that way. See Getting started:mapx arch initand Presets.- Decorators and doc tags (M5-10): the
decoratoranddocTagselectors. See Decorators and doc tags. - Private parts and internal elements (M5-11):
privateselectors,@internaldoc tags and native module visibility. See Visibility.
Not there yet: the dashboard's Architecture view (M5-15).
Where to look up each part:
| Part | Section |
|---|---|
| Selectors | Selectors |
| Rules | Rules |
| Scope and policy | Scope and policy |
Private parts and @internal | Visibility |
| Reporters | Reporters and CI reports |
| Exit codes | Exit codes |
Baselines and --compare | Baselines and the ratchet |
| Layer graphs | Layer graphs |
| Smells | Smells |
| Questions about the configuration | Introspection |
| MCP tools | MCP tools |
Getting started: mapx arch init
mapx arch init proposes a configuration from the index, for you to review before relying on it (AR-15). It is a dry run unless you pass --write, and it never writes over an existing configuration or baseline without --force. The MCP tool mapx_arch_init runs the same engine. Options and output are in cli-reference.md.
- Map MapX roles to layers (
--from roles, the default). There is one layer per role the scan assigned, in the archetype'sLAYER_ORDER, and each may depend on the layers after it.--from clustersmakes one layer per primary cluster instead. - Or start from a preset (
--preset <id>, or--from frameworkfor the detected framework's preset: laravel, nestjs, django, symfony, nextjs, express, fastapi, rails, spring, aspnet, flutter or vue, tried in that order). The configurationextendsthe preset and sets its folder parameter, inferred from the indexed paths. - Read the observed layer graph in the comments: for each layer, its files and the dependencies on other layers the index has today, and which of them the rules refuse. With
--from clusters, the rules allow exactly today's graph. - Make day one green with
--baseline: today's violations go to.mapx/architecture.baseline.json, somapx checkexits 0 until a new one appears (see Baselines and the ratchet). - Packages get a layer. When the index has references to packages outside the workspace, every proposal (every
--fromsource and every preset) adds aPackageslayer,{ "package": "*" }, which every layer may use ("*": ["Packages"]). It is the starting point for vendor rules, and it shows the package dependencies in the layer graph. Its package selector opts the configuration in to uncovered packages, and since it holds every package, dev-only and unattributed ones included, none is uncovered:--fail-on-uncoveredpasses on day one. Narrow it, or split it into vendor layers, as the rules tighten; a package the layers then leave out is uncovered, and a baseline cannot skip an uncovered dependency. Delete the layer and packages are out of scope again. Thedev-dependency-in-prodsmell reports production code that uses a dev-only package. - No code is left out (review of M5-17-R1). Code files that no layer of the proposal selects (an
app/Enumsfolder a preset does not name, an entry point, a role the archetype's order leaves out) go into anUnclassifiedlayer, with a TODO comment. Each file goes in by the highest folder that holds no placed file (app/Enums/**), else by its own path. Its rules are a deliberate choice: it is a to-do, not a layer of the design, so it constrains nothing yet. Every layer may use it ("*": ["Unclassified"]) and it may use every layer ("Unclassified": ["*"]), so a dependency on it is never uncovered andcheck --fail-on-uncoveredpasses on day one; a stricter default would fail day one on code the preset simply does not know. Tighten it by moving its folders into layers or giving them their own. It is reported asunclassified: { name, paths, files }in the JSON, not inlayers.
mapx arch init --preset laravel # look at the proposal
mapx arch init --preset laravel --baseline --write
mapx check # exit 0; new violations failPresets
A preset is a shipped document, mapx:preset/<id>, that a configuration extends. Its source is src/arch/presets/<id>.jsonc, with a comment on each layer. A configuration that extends a preset can override its parameters, replace a layer by declaring one of the same name, and add layers and rules:
{
"extends": "mapx:preset/clean",
"parameters": { "root": "src/" },
"layers": [
// replaces the preset's Infrastructure layer: this codebase calls the folder infra/
{ "name": "Infrastructure", "selectors": [{ "path": "src/infra/**" }] },
{ "name": "Shared", "selectors": [{ "path": "src/shared/**" }] }
],
"rules": { "*": ["Shared"] }
}$ mapx arch layers
Layer Elements Files
Domain 20 7
Application 0 0
Adapters 3 1
Infrastructure 6 2
Shared 9 3Generic presets:
| Preset | Layers | Rules | Parameter |
|---|---|---|---|
layered | Presentation, Business, Persistence, Common (by folder name under root) | each layer on the one below and on Common | root, default src/ |
clean | Domain, Application, Adapters, Infrastructure | inward only; Domain on nothing | root, default src/ |
hexagonal | Domain, Application (use cases and ports), Adapters | Adapters on Application and Domain; Application on Domain | root, default src/ |
onion | DomainModel (domain/model), DomainServices (domain/services), ApplicationServices, Presentation, Infrastructure | inward only; the two outer rings never on each other; DomainModel on nothing | root, default src/ |
ddd-contexts | SharedKernel; the configuration declares one layer per context (mapx arch init adds one per folder under root) | "*": ["SharedKernel"]: a context never depends on another | root, default src/ |
mvc | Controllers, Views, Models | Controllers on Views and Models; Views on Models; Models on nothing | root, default src/ |
mvvm | Views, ViewModels, Services, Models | Views on ViewModels and Models; ViewModels on Services and Models; Services on Models | root, default src/ |
feature-sliced | App, Processes, Pages, Widgets, Features, Entities, Shared | strictly top-down | root, default src/ |
Framework presets. Where the framework defines a layer by ancestry or by an annotation, the preset selects by it too (Type hierarchy and members, Decorators and doc tags), so a class is placed wherever it lives; the folder and file-name selectors stay the fallback for code without them:
| Preset | Layers | By ancestry or annotation | Rules | Parameter |
|---|---|---|---|---|
laravel | Routes (routes/ but console.php), Http (also route handlers and middleware outside app/Http, routed actions aside), Console (with routes/console.php), Providers, Jobs, Events, Listeners, Services (Services, Actions, Domain), Models (also classes with relations), Views, Database | Models: extends Eloquent Model, Foundation\Auth\User, Pivot. Jobs: implements ShouldQueue outside app/{Listeners,Mail,Notifications}. Http: extends App\Http\Controllers\Controller or Illuminate\Routing\Controller | Models, Jobs, Listeners and Providers never on Http. Routes on Http, Services (invokable actions) and Models (route-model binding, channel authorisation); Listeners on Events; Console on Jobs and Models (the Laravel 11 scheduler); Models on Events, Jobs (dispatched from booted()) and Database (the default User names its factory). An Eloquent model in Services, Actions or Domain is in Models only. Idiomatic Laravel 11 passes clean | root, default "" (artisan at the repo root) |
symfony | Controller, Form, Command, EventSubscriber, MessageHandler, Message, Service, Repository, Entity (the src/ folders) | AbstractController, AbstractType, Command and #[AsCommand], EventSubscriberInterface and #[AsEventListener], #[AsMessageHandler], ServiceEntityRepository and EntityRepository, #[ORM\Entity] and #[ORM\Embeddable] | only Controller on Form; an entity on its repository only (repositoryClass:, as make:entity writes it); Form also on Repository (an EntityType query builder); Message on nothing | root, default "" |
nestjs | Module, Controller, Enhancer (guards, interceptors, pipes, filters, middleware, strategies), Provider, Dto, Entity, by file-name suffix | @Module, @Controller; Enhancer: implements CanActivate, NestInterceptor, PipeTransform, ExceptionFilter or NestMiddleware from @nestjs/common; Provider: @Injectable (an enhancer stays in Enhancer only); Entity: @Entity. Each annotation takes the class's members along (members: true) | Controller on Enhancer, Provider and Dto only; Entity on nothing | none |
express | App (app, server, index), Routes, Controllers, Middleware, Services, Models, Config | none: paths | Routes on Controllers and Middleware; Models on Config only | root, default src/ |
django | Urls, Views, Admin, Commands, Forms, Serializers, Migrations, Models, by module name in any app | Models: extends django.db.models.Model; Views: any base under django.views, rest_framework.views, .generics or .viewsets | Models on nothing; Urls on Views; Migrations on nothing (they use historical models, apps.get_model, as Django requires) | none |
fastapi | App (main.py), Routers (routers, routes, endpoints, api but its deps.py), Dependencies (dependencies.py, deps.py at any depth), Services (or crud), Schemas, Models, Core (core, db, config.py, database.py, security.py). The full-stack template and the SQL tutorial layouts pass clean | none: paths | Routers on Dependencies, Services, Schemas, Models (the template's SQLModel classes are its schemas) and Core; Core on Services and Models (init_db); Models on Core; Schemas on nothing | root, default app/ |
rails | Routes (config/routes.rb), Controllers, Channels, Helpers, Services, Jobs, Mailers, Models, Database (db/) | Controllers: ActionController::Base, ::API; Channels: ActionCable::Channel::Base; Jobs: ActiveJob::Base; Mailers: ActionMailer::Base; Models: ActiveRecord::Base (through ApplicationRecord) | Models on Jobs and Mailers only (callbacks); Helpers on Models | root, default "" |
spring | Controller, Config, Service, Repository, Dto, Entity, by package name | @Controller, @RestController and their advice, @Configuration, @Service, @Repository and any type inheriting from org.springframework.data, @Entity, @Embeddable, @MappedSuperclass, each with the class's members (members: true) | Controller on Service, Dto and Entity (mapping an entity to a DTO), never on Repository; Entity on nothing | none |
aspnet | Controllers, Pages, Middleware, Services, Data (Data, Repositories, Migrations), Dtos (Dtos, Contracts, ViewModels), Models (Models, Entities, Domain) | Controllers: ControllerBase or Controller, [ApiController]; Pages: PageModel; Data: DbContext | Controllers and Pages on Services, Data (the scaffolded ones inject the DbContext), Dtos and Models; Models on nothing | none |
flutter | App (main.dart, app.dart), Screens, Widgets, State, Services, Models, under lib/ | State: ChangeNotifier, ValueNotifier, Bloc, Cubit, StateNotifier, Notifier, AsyncNotifier | Screens and Widgets never on Services; Models on nothing | root, default lib/ |
nextjs | Routes (app/, pages/), Api (app/**/route.ts, pages/api/, middleware.ts), Actions (actions.ts anywhere, actions/), Components (components, ui, or in app/), Hooks, Lib (lib, utils, services, types, or in app/: the Next.js Learn layout) | none: paths | Pages and components on Actions (server actions); Api on Actions and Lib; Lib on nothing | root, default "" (src/ with the src directory) |
vue | App (main.ts, App.vue), Router, Views (or pages), Components, Composables, Stores, Services (or api), Shared (types, utils, lib, helpers, constants) | none: paths | Components on Composables and Stores; Router on Stores (a navigation guard) and Stores on Router (router.push); Services on Shared only; every layer on Shared | root, default src/ |
Limits:
- A selector by ancestry needs the base named in the declaration, or reached through a workspace base (Type hierarchy and members). An external base is a leaf: a class extending a vendor subclass of
Modelthat is not the one the preset names is placed by its folder only. Decorators are stored for PHP, TypeScript, JavaScript, Python, Java, Kotlin and C# only. - A class placed by ancestry in a folder another layer selects is in both layers (a
multi-layerwarning), except where the preset says otherwise (a Laravel model inapp/Servicesis in Models only). Move it, or narrow the folder layer in your configuration. - The Laravel preset's Database and Views layers hold
database/migrations,database/seeders,database/factories,resources/viewsand*.blade.php, which a Laravel app indexes by default (Laravel default excludes). A config written by an earliermapx initmay still list them inexcludePatterns; remove them there, or both layers stay empty (empty-layer). The files give the edges that name a class (use,new,X::m(),X::class, extends, type hints, a Blade view's@phpblocks,{{ }}echoes,@useand@inject), but a method called on a variable only when the code gives its class ($this->m(), a typed parameter or property,new X), not by name ($table->id(),$order->name()). feature-sliceddoes not forbid imports between slices of one layer, andddd-contextshas no "only through Contracts" exception: both need a layer per slice or private selectors, which you can add.- A preset layer that matches nothing on your code is an
empty-layerwarning, not an error.mapx arch initwarns when most layers are empty. It refuses to write a proposal whose every layer is empty (exit 3) unless you pass--force, since such a configuration checks nothing. - Laravel's Mail, Notifications, Policies and Enums folders, and any folder a preset does not name, are in no preset layer:
mapx arch initputs them inUnclassified(see Getting started). - An inferred
rootis written as a literal glob. Glob characters in the folder name are escaped (svc (v2)/becomessvc \(v2\)/), and%is doubled.
The configuration document
Put the configuration in .mapx/architecture.jsonc. Views go in .mapx/architecture/<view>.jsonc, and a view name cannot lead outside that folder. The format is JSON with comments and trailing commas; a UTF-8 byte order mark is ignored. Point $schema at schemas/architecture.schema.json, which the npm package ships, for editor completion:
{
"$schema": "./node_modules/@mgamil/mapx/schemas/architecture.schema.json",
"extends": ["./architecture.base.jsonc"],
"parameters": { "App": "App" },
"layers": [
{ "name": "Controller", "selectors": [{ "path": "app/Http/Controllers/**" }] },
{ "name": "Domain", "selectors": [{ "name": "^%App%\\\\Domain\\\\", "kind": ["class", "interface", "enum"] }] },
{ "name": "Infrastructure", "selectors": [{ "path": "app/Infrastructure/**" }], "private": [{ "path": "app/Infrastructure/Db/**" }] }
],
"rules": {
"Controller": ["+Domain"],
"Infrastructure": ["Domain"],
"Domain": []
}
}extends takes relative files and mapx:preset/<id> presets (see Presets). Documents load depth-first, so extended documents come before the one that extends them:
- a later document replaces a layer of the same name, which keeps its first position;
- rule lists are merged per source layer;
- other settings are overridden key by key.
%name% is replaced from the merged parameters in every string value of a selector: path, name, package, repo, cluster, workspacePackage, role, language, framework and the rest.
name,pathandpackage: a%name%without a value is an error.- Other keys: only a declared parameter is replaced. Any other
%…%stays as written, so adocTagvalue such as"%s%s"or amemberregex such as"/^%d%x$/"means what it says. %%is a literal%in every key ("^100%%"is^100%).- A parameter value can name other parameters:
"root": "%base%/app". Such a reference without a value is an error, and so is a cycle (%a% → %b% → %a%). - A
frameworkpart given as a parameter is checked after it is replaced.
Validation reports every problem at once. Each problem names its file and a JSON pointer, in document order. Validation catches:
- unknown keys (a typo such as
"paht"), wrong types, unknown edge types and symbol kinds; - a selector with no primary key, or with several primary keys (combine those with
all); - an unknown layer in rules,
layerselectors or report groups. It suggests the closest name:did you mean "Http"?; - an overlay layer used as a rule source;
+cycles, named:"+" allowances form a cycle: Service → Repo → Service;- a
visibility.internalTagsentry that is not a doc tag ("@internal"and"internal"are;"not a tag"is not); - circular
extends, a missing extended file, anextendsnaming a directory or a path that cannot be read (a symlink loop, no permission), an unknown preset, a parameter without a value or in a cycle, and a regular expression that does not compile; - an empty view name, or a view outside
.mapx/architecture/(by..or through a symlink).
mapx arch validate-config prints these problems and exits 3 when there are any; see cli-reference.md. mapx check reports them the same way, also with exit 3.
Elements and qualified names
Selectors match elements: every indexed file, and every symbol in it (classes, functions, methods and so on). A symbol's qualified name is how a name selector sees it:
| Language | Qualified name of Order.run | From |
|---|---|---|
| PHP | App\Domain\Order::run | the file's namespace |
| Java, Kotlin, Scala | com.acme.domain.Order.run | the package declaration (Scala: also nested and chained packages) |
| C# | Acme.Domain.Order.run | the enclosing namespace blocks (a file can hold several), or the file-scoped namespace |
| Dart | acme.domain.Order.run | the library name |
| C++ | acme::domain::Order::run | the enclosing namespace blocks, nested or a::b |
| Go | example.com/shop/domain.Order.Run | go.mod's module plus the package directory |
| Python | app.domain.order.Order.run | the module path from the package root (the nearest pyproject.toml, setup.py or setup.cfg, else the repo); src/ and __init__ are dropped |
| Rust | crate::domain::order::Order::run | the module path from the crate root (the nearest Cargo.toml); lib.rs, main.rs and mod.rs name their directory |
| Ruby | Billing::Core::Invoice::pay | module and class nesting, including compact class Billing::Receipt |
| Elixir | Billing.Invoice.pay | the enclosing defmodule |
| JavaScript, TypeScript, TSX, Vue, Svelte, Swift, C, Lua, Zig, Bash, Fish, Pascal, Markdown, HTML, CSS, JSON, YAML | src/domain/Order.ts#Order.run | the workspace-relative path |
A namespace language whose file declares no namespace (a PHP file with only functions, say) falls back to the path form. Kotlin's package is not captured yet, so Kotlin uses the path form.
In C++, Ruby and Elixir the nesting comes from the line ranges of the enclosing namespace, module and class symbols. A file in those languages can declare several namespaces, so the file's own qualified name is its path.
.h headers are C unless the header itself is plainly C++. That means C++-only syntax outside every preprocessor conditional: namespace X {, a class X { or class X : declaration, template <, an access label such as public:, using namespace, or extern "C++". Comments and strings don't count, and neither does anything inside #if, #ifdef, #ifndef, #elif or #else (for example, #ifdef __cplusplus glue). An include guard around the whole file is not a conditional. A C header keeps the path form.
C# and Scala take each declaration's namespace from the syntax tree:
- A file with namespace blocks
AandBgivesA.XandB.Y, even when the blocks share one line. package a { package b { class X } }givesa.b.X, and chainedpackage com.acmethenpackage domaingivecom.acme.domain.Order.- A C# type outside every block is in the global namespace, so it uses the path form.
- The file's own qualified name is its first namespace.
Selectors
A selector object has exactly one primary key, plus the modifiers that refine it. An element is in a layer when any of the layer's selectors matches it.
| Selector | Matches | Evaluated on |
|---|---|---|
path | a glob (picomatch, dotfiles included) or /regex/flags on the workspace-relative path | the file |
name (+ kind) | a regex on the qualified name: case-insensitive unless written /…/flags, optionally only these symbol kinds | symbols only |
repo | a registered repo, by name or glob | the file |
language | the file's language; ts, js, py, c# and the like are accepted, and typescript also matches .tsx files (tsx matches only those) | the file |
role (+ confidence) | the MapX role of the file, optionally at a minimum classifier confidence | the file |
cluster | a MapX cluster the file is in, by name or glob | the file |
workspacePackage | the name in the nearest package.json, composer.json, pyproject.toml, go.mod or Cargo.toml, by name or glob | the file |
framework | a framework part read from stored edges: route-handler (target of a route or http edge), middleware, model (source of a relation edge), job and event (targets of dispatch), listener (target of notify), resolver, component (target of render), http-client (source of http) | the file |
decorator | a decorator, attribute or annotation of the symbol, by name or trailing segments, or /regex/flags; with "members": true, of the type a member belongs to too (see below) | symbols only |
docTag (+ value) | a doc-block tag of the symbol, such as @deprecated, optionally with a value matching a regex (see below) | symbols only |
extends, implements, inherits, mixes | an ancestor of the type, transitively, by name or trailing segments, or /regex/flags (see Type hierarchy and members) | types only |
inheritanceDepth | a type whose longest chain of bases is at least this long | types only |
member | a type with an own member whose name matches a regex | types only |
layer | membership in another layer | the element |
all, any, not | composition |
Symbol-level selectors on files. A name, decorator, docTag or hierarchy selector does not apply to a file. It is not a match, and neither is not of it, so { "all": [{ "path": "app/**" }, { "not": { "name": "Controller$" } }] } never puts a file in a layer by itself. The file joins the layer only through a top-level symbol that is in it (see Membership). all, any and not use three-valued logic. all is inapplicable when no branch fails and one is inapplicable. any is inapplicable when no branch matches and one is inapplicable. So { "not": { "any": [{ "name": "Controller$" }, { "path": "app/Legacy/**" }] } } does not select a file either.
The package, builtin and ambient selectors select what the workspace uses from outside itself: see External dependencies.
One example of each selector, on the shop. The decorator, doc tag and hierarchy selectors have their own examples below. All of these are layers of one valid configuration, here the view .mapx/architecture/selectors.jsonc; mapx arch which <element> shows which of them an element is in, and why:
"parameters": { "src": "src" },
"layers": [
{ "name": "Http", "selectors": [{ "path": "%src%/controller/**" }] }, // a glob, with a parameter
{ "name": "Infra", "selectors": [{ "path": "/^src\\/infra\\//" }] }, // a /regex/ on the path
{ "name": "Ports", "selectors": [{ "name": "Repository$", "kind": ["interface"] }] }, // interfaces named …Repository
{ "name": "Backend", "selectors": [{ "repo": "backend-*" }] }, // a registered repo, by glob
{ "name": "TypeScript", "selectors": [{ "language": "ts" }] },
{ "name": "Data", "selectors": [{ "role": "data", "confidence": 0.2 }] }, // MapX role, at least 20% confident
{ "name": "DomainPkg", "selectors": [{ "cluster": "src.domain" }] }, // a cluster from `mapx clusters`
{ "name": "Shop", "selectors": [{ "workspacePackage": "shop" }] }, // "name" in the nearest package.json
{ "name": "Handlers", "selectors": [{ "framework": "route-handler" }] }, // route targets (none in the shop)
{ "name": "NotShared", "selectors": [{ "all": [{ "path": "src/**" }, { "not": { "path": "src/shared/**" } }] }] },
{ "name": "Either", "selectors": [{ "any": [{ "path": "src/shared/**" }, { "name": "^src/domain/Clock" }] }] },
{ "name": "AlsoInfra", "selectors": [{ "layer": "Infra" }], "overlay": true } // whatever is in Infra
]$ mapx arch which SqlOrderRepository --view selectors # the shop is the repo "backend-shop"
src/infra/SqlOrderRepository.ts#SqlOrderRepository (class, src/infra/SqlOrderRepository.ts)
Infra ← selectors[0].path
Backend ← selectors[0].repo
TypeScript ← selectors[0].language
Data ← selectors[0].role
Shop ← selectors[0].workspacePackage
NotShared ← selectors[0].all
AlsoInfra ← selectors[0].layerLayer keys. Besides name and selectors, a layer takes private (see Visibility), overlay (a layer that is only ever a rule target, such as Deprecated; it never makes an element multi-layer or covered), description, and group and color for layer graphs:
{ "name": "Deprecated", "description": "Code on its way out", "overlay": true, "color": "#f59e0b", "selectors": [{ "docTag": "@deprecated" }] }Decorators and doc tags
A scan stores each symbol's decorators, attributes or annotations, and its doc-block tags, for PHP, TypeScript, JavaScript (with Vue and Svelte script blocks), Python, Java, Kotlin and C#, and the doc tags of Go symbols. Other languages store none yet, so these selectors never match their symbols.
| Language | Decorators | Doc tags |
|---|---|---|
| PHP | #[Attr], #[Attr(…)], several per group. The name is resolved like a class name, through use and the namespace (#[ORM\Entity] → Doctrine\ORM\Mapping\Entity), and the name as written is kept too when it differs (ORM\Entity, an alias) | PHPDoc /** … */ block tags, Doctrine-style @Route(…) included |
| TypeScript, JavaScript | @dec, @dec(…), @a.b on classes (before or after export), methods, fields and accessors | JSDoc/TSDoc block tags; a function's overload signatures give theirs to the implementation |
| Python | @dec, @dec(…), @app.route(…) on def and class | docstring directives (.. deprecated:: 2.0) and @tag lines |
| Java, Kotlin | @Ann, @Ann(…), @a.b.Ann, each of Kotlin's @[A B] | Javadoc and KDoc block tags |
| C# | [Attr], [Attr(…)], several per list; not [return: …], [param: …], [field: …] and other targets that name something else | XML doc /// elements, as @remarks, @param and so on; <summary> is the description and <code> a sample, neither a tag |
| Go | none | the doc comment: the // (or /* */) comments directly above the declaration, each on its own line, with no blank line between (a detached or trailing comment is not documentation). @tag lines, and the Deprecated: paragraph as @deprecated; a value ends at the next tag or the end of its paragraph, and indented lines are code, never tags. In a type ( … ) group, each spec's own comment |
A block tag starts a line of the doc comment (after the leading *), so an inline {@link X} is not a tag. Its value runs to the next tag. Decorator names keep their qualifier as written (app.route, System.Obsolete), except in PHP, where they are fully qualified. Parameter decorators are not stored. A decorator selector matches the symbol that carries the decorator; with "members": true it also matches the members (methods, properties, fields, constants) of a type that carries it, so { "decorator": "Entity", "members": true } holds an @Entity class's getters too and a call to one is not uncovered (review of M5-17-R1). A member's own decorator matches it either way. Comments between decorators, or between the doc comment and the declaration (// eslint-disable-next-line), are passed over; code is not.
Code samples are never tags. These are text:
- lines in a
```fence, a<pre>…</pre>block or a multi-line{@code …}; - everything after
@example, which is that tag's value to the end of the comment (so put@examplelast); - C#
<code>; - in a Python docstring, a literal block (the lines indented under a paragraph ending in
::) and doctest examples (from>>>to the next blank line).
A doc comment starts its own line. A trailing comment documents the code before it, never the next declaration: in type Legacy struct{} // @internal, int count; /** @internal */ or x = 1; /** @internal */, the next declaration gets no tag. The same holds for C# ///.
A doc comment attaches to the next declaration, even across blank lines, as TypeScript and Javadoc tooling do (Go: only directly above). A file-header block (/** @license MIT */ or /** @internal */ at the top of a file) therefore becomes the first declaration's tags. Keep @internal and similar markers out of file headers, or the first class of the file is marked.
decorator. A plain value matches a stored decorator, ignoring case, when:- it equals the decorator's name, or is a suffix of it on a namespace boundary (
\or.).EntityandMapping\EntitymatchDoctrine\ORM\Mapping\Entity;titydoes not. - in PHP, the same holds for the name as written.
ORM\Entity(withuse Doctrine\ORM\Mapping as ORM) matches, and so does an alias such asBazinuse Foo\Bar as Baz. - outside PHP, where names are stored as written, the stored name is such a suffix of the value.
System.Obsoletematches[Obsolete], andjavax.persistence.Entitymatches@Entity. These languages cannot tell two libraries'@Entityapart.
A leading
@,\or#[in the value is ignored. In C#,XandXAttributematch each other, soObsoletematches[System.ObsoleteAttribute]. A value written/…/flagsis a regex on the stored name and on the name as written.- it equals the decorator's name, or is a suffix of it on a namespace boundary (
docTag, with optionalvalue. The symbol has the tag; the@is optional and case is ignored. Withvalue, one occurrence of the tag has a value matching the regex (case-insensitive unless written/…/flags). In Python a@deprecateddecorator (@deprecated,@typing_extensions.deprecated("…")) counts as the@deprecatedtag, with its arguments as the value.Overloads. Overloads that are symbols (Java, C#, TS class methods) share one element, which carries the decorators and tags of them all. TS function overload signatures are not symbols, so their doc tags are stored on the implementation.
{ "name": "Controllers", "selectors": [{ "decorator": "Controller" }] },
{ "name": "Entities", "selectors": [{ "decorator": "Doctrine\\ORM\\Mapping\\Entity" }] },
{ "name": "Deprecated", "selectors": [{ "docTag": "@deprecated" }], "overlay": true },
{ "name": "BillingAPI", "selectors": [{ "docTag": "@internal", "value": "^billing" }] }Type hierarchy and members
The hierarchy selectors judge a type (a class, interface, trait, struct, enum or module symbol) by its ancestry. A member of a type (a method, property, field or constant) is judged by its type, as path judges a symbol by its file, so a layer { "extends": "Eloquent\\Model" } holds the models' methods too, and a dependency a method has is checked. Another symbol never matches, and a file is inapplicable, as for name.
Where ancestry comes from. A type's direct bases are:
- the stored
extendsandimplementsedges, for bases the index resolved to a workspace type; - the base list its declaration's header names, for bases outside the index. The header is read from the source, from the type's keyword and name to its body: annotation and attribute lines above it (
@Transactional,[Authorize],#[Route('/admin')], a multi-line#[ORM\Entity(…)]) and comment lines are passed over, and a base list that wraps onto later lines is read whole (review of M5-17-R1). The stored signature, the declaration's first line, is the fallback when the source cannot be read. Each is named through what the file records: PHP name resolution (use, else the file's namespace:Modelafteruse Illuminate\Database\Eloquent\Model;is that class, and a barePin namespaceAppisApp\P), a TS or JS import (sequelize#Model, the module and the name), a Python import (models.Modelafterfrom django.db import modelsisdjango.db.models.Model, and an alias counts:DMafterfrom django.db.models import Model as DM,m.Modelafterfrom django.db import models as m), or a Java, Kotlin or Scalaimportline (org.springframework.data.jpa.domain.AbstractPersistable). Otherwise the base keeps the name as written, generics dropped. A base so qualified that names a workspace type links to it; - mixins: the traits a PHP class, trait or enum uses (
use HasUuid;, resolved like class names), and Scala and Dartwithclauses.
What the file says wins over a same-named class. The index may link a base to a workspace class only because the names match: import { Model } from 'sequelize' with a local src/Model.ts, from django.db import models with a local Model, or a Java import of Spring's AbstractPersistable with a local one. When the file's import says the base is outside the workspace, or the language's name resolution (PHP use and namespace, a JVM import) names another qualified name, the declared base is the ancestor and the edge is not followed. A base the file does resolve to the local class (import { Model } from './Model') stays linked to it. A Kotlin type's qualified name is its path, so a Kotlin edge is never overruled.
The base list is read for PHP, TypeScript and JavaScript, Python, Java, Kotlin, Scala, Dart, C#, Swift and Ruby. Kotlin tells a superclass by its constructor call (Base()); C# and Swift take the first base of a class as its superclass, except a C# IName. Python and Ruby bases are all extends, and an interface's bases are extends in every language, whatever kind the index stored (Kotlin and C# store interface B : A as implements). Go embedding is stored as extends and counts as such; Go's structural interfaces, Ruby include, Rust impl Trait for and C++ base lists are not read.
Closure. Ancestry is transitive. A base outside the index is a leaf: nothing is known of its own bases, so extends Model matches a class two levels below Eloquent\Model only through a workspace base in between. A cycle (class A extends B, class B extends A: invalid code, valid input) is cut: every type is visited once per kind of path, and a type is never its own ancestor. The search keeps, for each ancestor reached, only the step it was reached from, so memory grows with the number of ancestors, not with the length of their paths; a path is rebuilt only when it is shown.
| Selector | Matches a type with an ancestor … |
|---|---|
extends | reached through extends hops only (a class's superclasses, an interface's parent interfaces) |
implements | reached through a path with an implements hop: an interface it implements, a parent of one, or one a superclass implements |
mixes | reached through a path whose last hop is a mixin: a trait it uses, or one a base uses. A used trait is not extends |
inherits | reached through any path |
Values. An ancestor value is a name or its trailing namespace segments, case-insensitive: Model, Eloquent\Model and Illuminate\Database\Eloquent\Model all match Illuminate\Database\Eloquent\Model, and sequelize#Model matches the TS base above (segments split at \, ., #, / and ::). A base outside the index known only as written (no import qualified it) also matches a qualified value it ends: org.acme.Base matches Base. /regex/flags tests the ancestor's qualified name.
inheritanceDepth: N matches a type whose longest chain of bases is at least N long. A base outside the index counts as one level. Depth is computed once for all types, over the strongly connected components of the base graph: the types of a cycle count once each, so in A extends B extends A each has depth 1. Within a cycle this is an upper bound when no single chain passes through all of its types. The explanation shows a longest chain found from the type, never revisiting a type.
member: "regex" matches a type with an own member (method, property, field, constant) whose name matches the regex, case-insensitive unless written /…/flags. A PHP property's $ is optional. Inherited members do not count.
Explanations. A hierarchy match records its chain, from the type to the ancestor that matched (for inheritanceDepth, the longest chain; for member, the type and the member). mapx arch which and mapx arch layers <layer> --why show it: Domain ← selectors[0].extends: App\Domain\Order → App\Support\BaseModel → Illuminate\Database\Eloquent\Model, and with --format json it is chain in the trace entry or member.
{ "name": "Models", "selectors": [{ "extends": "Eloquent\\Model" }] },
{ "name": "Payables", "selectors": [{ "implements": "Payable" }] },
{ "name": "Deep", "selectors": [{ "inheritanceDepth": 4 }], "overlay": true },
{ "name": "Handlers", "selectors": [{ "all": [{ "path": "src/**" }, { "member": "^handle$" }] }] }Inherited dependencies (AR-02). With "policy": { "inheritDependencies": true }, a type also answers for the dependencies of its workspace ancestors, through every kind of hop. Each is decided again with the type as source, by the same evaluation chain and the type's own layers. Its finding carries via, the inheritance path from the type to the ancestor that has the dependency, and its message ends inherited via App\Domain\Order → App\Support\BaseModel. A dependency is derived once per type, target and edge type, through the shortest path. It is not derived when the type has it itself, or the type's file does (an import in a file with several types belongs to the file; that finding has no via), nor when its target is the type or one of its own ancestors (that is the inheritance itself, decided where it is declared). An inherited finding has no line: its edge is in the ancestor's file. A via longer than eight names shows the first five and the last two around one … N more … entry. The default, false, derives nothing.
External dependencies: packages, built-ins, ambient access
A scan records every reference to something outside the workspace (the external_refs view). It also records what each reference belongs to: a package of npm, Composer, pypi or Go modules, the language's standard library or runtime (a built-in), or ambient access. For each package it records whether the manifest lists it directly and whether it is dev-only. The rules see these as external elements. There is one per ecosystem and name: the package (pg, guzzlehttp/guzzle, golang.org/x/sys), a built-in's package (node:fs, ext-pdo, python:os, net/http), or for a package no manifest or lockfile provides, the name as written (Acme\Missing\Widget). A package that is a direct dependency for one file and a transitive one for another is two elements of the same name.
Where the attribution comes from, per ecosystem. MapX reads these files even though the default excludePatterns keep them out of the index, and a change to any of them makes the index stale:
| Ecosystem | References | Read from | Built-ins |
|---|---|---|---|
| npm | JS/TS/Vue/Svelte imports and require of bare specifiers | package.json (dependencies, devDependencies, …), package-lock.json, pnpm-lock.yaml, yarn.lock | Node built-ins: fs, node:fs, fs/promises |
| Composer | PHP classes, functions and use imports outside the workspace | composer.json and composer.lock (require, require-dev, autoload sections); when present, Composer's generated vendor/composer/autoload_classmap.php, autoload_files.php and installed.json, read as data and never executed (a class the classmap names is the package installed where its file is; package sources are never read) | PHP extension classes, functions and constants (DateTime, PDO, str_contains, PHP_EOL), as ext-<name>; a PECL extension's (Redis, Imagick, apcu_fetch) is ambient, as ext-<name> |
| pypi | Python imports | pyproject.toml (PEP 621, dependency groups, Poetry), requirements*.txt, uv.lock, poetry.lock | the standard library (os, json, tomllib), as python:<module> |
| Go modules | Go imports | go.mod (direct and // indirect requirements, replace), go.sum, go.work; the longest module path wins, /v2 majors included; dev-only means only _test.go files use it | the standard library (fmt, net/http); cgo's C is ambient |
| Selector | Matches |
|---|---|
package (+ dev, direct) | A package by name: "pg", "guzzlehttp/*", "@acme/*", "*". A package no manifest or lockfile provides is matched by its name as written (github.com/pkg/errors), so "*" covers every package. With dev: true, only dev-only packages; with dev: false, only production ones. With direct: true, only packages a manifest lists directly; with direct: false, only transitive ones and those no manifest lists. A package whose status is unknown (no lockfile says) matches neither value. |
builtin | A built-in: true, or a name such as "node:*", "ext-pdo", "python:os", "net/http" |
ambient | Ambient access: true, or an element name (below). Ambient access is never a package. |
Ambient access (M5-24-R1) is what a file reads from the process it runs in, with no import: the environment, the command line, browser globals, PHP superglobals. A scan records each access as a reference of origin ambient, and there is one external element per name, the same in every language where the concept is the same:
| Element | Recorded for |
|---|---|
env | JS/TS process.env; PHP $_ENV and getenv(); Python os.environ and os.getenv; Go os.Getenv, os.LookupEnv, os.Environ, os.Setenv, os.Unsetenv, os.ExpandEnv and os.Clearenv |
argv | Python sys.argv; Go os.Args |
window, document, globalThis, localStorage | the same JS/TS browser globals |
$_GET, $_POST, $_SERVER, $_SESSION, $_COOKIE, $_FILES, $_REQUEST, $GLOBALS | the PHP superglobals, with their sigil |
C | Go's cgo pseudo-package (M5-23) |
ext-redis, ext-imagick, ext-apcu … | PHP's PECL extensions (M5-21-R1): the runtime provides them, neither PHP nor a package |
A selector names the element, not the expression that reads it: { "ambient": "env" } selects process.env, $_ENV, getenv(), os.environ and os.Getenv alike, { "ambient": "$_*" } the PHP superglobals, { "ambient": true } every ambient element. { "ambient": "process.env" } matches nothing: mapx check says so in an unsupported-selector warning that names the element you probably meant and lists the element names. The row's specifier is the canonical access (process.env, os.environ, $_GET, getenv) whatever the import alias, and its kind is call, so the default scope.types covers it. Ambient access is never uncovered by default; it follows policy.uncovered.builtins, as built-ins do.
// Only Infrastructure may read the environment.
{
"layers": [
{ "name": "Domain", "selectors": [{ "path": "src/domain/**" }] },
{ "name": "Infrastructure", "selectors": [{ "path": "src/infrastructure/**" }] },
{ "name": "Environment", "selectors": [{ "ambient": "env" }] }
],
"rules": { "Domain": [], "Infrastructure": ["Domain", "Environment"] }
}What counts, and what does not:
A name the file binds itself is not the global: a parameter, local, loop or catch variable, import, function or class named
window,document,process,localStorageorglobalThis(JS/TS); a Go parameter or local namedos. Python'sosandsysmust be bound by an import of that module (import os as oandfrom os import environcount; a parameter namedosdoes not).JS/TS
processimported fromprocessornode:processis the same ambient as the global. A comment, a string, a property name (foo.window) and a type position (typeof window) are no access.PHP reads a superglobal in a double-quoted string, a heredoc and a backtick string (
"id=$_GET[id]","{$_SERVER['HOST']}"), so those count. A single-quoted string, a nowdoc, an escaped\$_GETand a comment only name it and do not.PHP
getenv()is the global function: agetenvthe workspace declares in the file's namespace, or ause functionof anothergetenv, is not the environment.\getenv()always is.JS/TS:
process.env,process["env"],const { env } = processandimport { env } from 'node:process'are the environment (the last two where they are written; later uses of the localenvare not followed). A parameter name in an overload, abstract method or signature is a declaration.Not extracted yet: Vue and Svelte single-file components,
process.argv,sessionStorage,$argv, other languages, and a Pythonglobaldeclaration.Names. In these names
*matches any run of characters,/included, because package names are not paths./regex/flagsis a regular expression. Names compare as their ecosystem compares them: npm, Composer, built-ins and ambient names ignore case; a pypi package name is normalised as PEP 503 says (case, and each run of-,_and.as one-), so"Flask_SQLAlchemy"and"zope.interface"matchflask-sqlalchemyandzope-interface; a Go module or package path is exact.Where they apply. These selectors match external elements only. On a file or symbol they are inapplicable, like
nameon a file. Conversely, nothing but these selectors andlayerapplies to an external element. So{ "not": { "path": "src/**" } }never selects a package, and{ "not": { "package": "*" } }never selects a file.Membership. An external element is a member of the layers whose selectors match it. A vendor layer is therefore not an
empty-layer, and itsfilescount is 0. A layer'sprivateselectors can name packages, which makes a package private to that layer.Several layers. An external element in more than one non-overlay layer gets the same
multi-layerwarning as a symbol (an error underpolicy.strictLayers), naming the layers:pg is in 2 layers: Database, Drivers. It is reported once per element, when a source that is in a layer depends on it, and its source end has an emptyfileandexternal. Each of its layers is still decided, as for any target.
Evaluation. Each recorded reference whose source file is in scope (scope.include, scope.exclude) and of a type in scope.types is decided like an edge whose target is the external element (the evaluation chain). The reference kind (import, require, call, instantiation, extends, param_type …) is the edge type. A target in no non-overlay layer is uncovered only as policy.uncovered says:
externalsfor packages, including those no input provides. Packages are opt-in (D039, since M5-24-R4). Unset,externalsistrueonly when a layer covers packages: a non-overlay layer with a positivepackageselector (anydevordirectfilter) in itsselectorsorprivate, directly or insideallorany. A package selector only undernot, or only in overlay layers (which never cover), does not opt in. A configuration without one leaves packages out of scope: no uncovered finding, so--fail-on-uncoveredignores them. An explicittrueorfalsealways wins.- The check JSON report (
policy.uncovered.externals:valueandsource, one ofexplicitwith itsfile,package-selectorwith the first suchlayer, ordefault) andmapx arch validate-config(the same in--format json; a "Packages in no layer" line in text) say which applies and why. builtins(defaultfalse) for built-ins and ambient access.
// Paths only: an import of express in src/infrastructure is out of scope, and check --fail-on-uncovered exits 0.
{ "layers": [{ "name": "Infrastructure", "selectors": [{ "path": "src/infrastructure/**" }] }] }
// The same with "policy": { "uncovered": { "externals": true } }, or with a package layer such as
// { "name": "Database", "selectors": [{ "package": "pg" }] }: express is uncovered, and the check exits 2.
// A dev-only overlay alone, { "name": "DevOnly", "overlay": true, "selectors": [{ "package": "*", "dev": true }] },
// does not opt in: express stays out of scope.A finding's target names the external ("element": "pg", an empty file) and carries external: { ecosystem, origin, package, direct, dev }. origin is package, builtin or ambient. Under policy.inheritDependencies, a type also answers for the external dependencies of its workspace ancestors: a subclass of a base that imports a forbidden package gets the same finding, with via naming the inheritance path (once per type, package and reference kind; not when the type or its file has the reference itself; no line). A base class outside the workspace is the inheritance itself, so extends and implements references are not inherited.
Vendor confinement. A layer of a package, and rules that let only some layers use it:
{
"layers": [
{ "name": "Domain", "selectors": [{ "path": "src/domain/**" }] },
{ "name": "Infrastructure", "selectors": [{ "path": "src/infrastructure/**" }] },
{ "name": "Database", "selectors": [{ "package": "pg" }] },
// Every dev-only package, denied to every layer: production code must not use them.
{ "name": "DevOnly", "overlay": true, "selectors": [{ "package": "*", "dev": true }] }
],
"rules": { "*": ["!DevOnly"], "Domain": [], "Infrastructure": ["Domain", "Database"] }
}Here src/domain/Order.ts importing pg is a disallowed-layer violation (Domain on Database), and importing lodash (a dev dependency) is one on DevOnly. A package used from Infrastructure but in no layer, such as express, is uncovered: the package selectors opt the configuration in. A built-in is uncovered only under uncovered.builtins. Alternatively, private: [{ "package": "pg" }] on a layer that also selects pg lets other layers use the layer, but not pg (a private-layer violation, and a vendor-leak smell).
Go and dev. Go has no dev requirements: a module is dev-only when only _test.go files use it. A test file inside a layer's path is that layer's code, so with the DevOnly overlay above, its use of testify is a violation. Leave tests out of the layer ({ "not": { "path": "**/*_test.go" } }) if they may use test-only modules.
Introspection. mapx arch which and mapx arch allowed accept an external by name (pg, node:fs) or by ecosystem and name (npm:pg, go:fmt). A bare name that is a direct dependency names the package, before a workspace symbol of the same name (a local express()), which its file or qualified name then names (review of M5-24-R3). Before any file uses it, allowed still decides a package a manifest near the source lists (package.json, composer.json, requirements.txt, pyproject.toml, go.mod; lodash, npm:lodash) and a built-in (node:path, python:os, go:net/http), as the first reference would be attributed, by the selectors and policy.uncovered; a package no manifest lists is still exit 3. allowed decides as check does: an external in no layer that policy.uncovered does not report (a built-in by default, and a package unless the configuration opts in) is out-of-scope, allowed, at info, with a reason that names why (an explicit false, or no opt-in). The edge types the index records for the pair are still listed as observed. mapx arch layer lists a layer's externals with kind: "external" and an empty file. mapx arch unassigned lists files and symbols only.
Membership
- Symbols. A symbol is in the layers whose selectors match it. File-level selectors (
path,repoand the like) judge a symbol by its file, so apathlayer covers every symbol under its directory. - Files (AR-01). A file is in the layers whose selectors match the file, plus the layers of its top-level symbols. A
namelayer can therefore single out one class of a multi-class file, and still claim the file for dependencies that only name the file. WithControlleras{ "name": "Controller$" }andDomainas everything underapp/except names ending inController,app/Controller/OrderController.phpis in Controller only andapp/Domain/Order.phpis in Domain throughApp\Domain\Order. - Top-level symbols are those declared at file level: not inside a type, and not a member. A declaration directly inside a C++ namespace or a Ruby module is still top-level. An Elixir module is a type, so its functions are members, while a nested
defmoduleis top-level. - No flow between symbols. One symbol's layer never passes to another symbol.
- Several layers. An element in more than one non-overlay layer will get a
multi-layerwarning. A non-overlay layer that matches nothing will get anempty-layerwarning. - Private parts. A layer's
privateselectors mark the elements only that layer may reach. A file is private to a layer when itsprivateselectors match the file or one of its top-level symbols. - Explanations. Membership records why an element is in a layer: the selector position (
selectors[0].name), for a file the top-level symbol that put it there, and for a hierarchy match the inheritance chain. - Caching. Membership is computed once per scan and configuration.
Rules
rules maps each non-overlay source layer to the layers it may depend on. Dependencies between different layers are denied unless allowed; dependencies within one layer are always allowed.
| Form | Meaning |
|---|---|
"L" | may depend on L |
"+L" | may depend on L, and on everything L may depend on, transitively |
"!L" | may never depend on L |
"*" | may depend on every non-overlay layer |
{ "allow": ["L"], "types": ["param_type"] } or { "to": "L", "types": [...] } | only over those edge types |
{ "allow": ["L"], "severity": "warn" } | advisory: such dependencies are reported as warnings instead of errors |
A "*" key gives defaults to every source layer: "*": ["Shared", "!Legacy"].
Precedence. ! beats explicit allowances, which beat what + brings, which beats *. For one target, only the strongest form's allowances count, so an explicit { "to": "C", "types": ["import"] } limits C to imports even if a + would bring C for calls. Allowances brought through + keep that +'s edge types and advisory severity. Each + path counts on its own, so the order of rules never matters.
Every form, on the shop, with a Deprecated overlay ({ "docTag": "@deprecated" }) added to the quick-start layers:
"rules": {
"*": ["Shared", "!Deprecated"], // defaults for every layer
"Controller": ["+Infrastructure"], // Infrastructure, and what it may use
"Infrastructure": [{ "to": "Domain", "types": ["import", "implements"] }], // only these edge types
"Domain": [{ "allow": ["Infrastructure"], "severity": "warn" }], // advisory
"Shared": ["*"] // every non-overlay layer
}$ mapx check --view rules
Controller
✖ disallowed-layer [error] src/controller/OrderController.ts#OrderController → src/domain/LegacyPricing.ts#LegacyPricing (import)
src/controller/OrderController.ts:3 … must not depend on src/domain/LegacyPricing.ts#LegacyPricing (Controller on Deprecated)
✖ disallowed-layer [error] src/controller/OrderController.ts#OrderController → src/domain/Order.ts#Order (instantiation)
src/controller/OrderController.ts:8 … must not depend on src/domain/Order.ts#Order (Controller on Domain)
✖ disallowed-layer [warn] src/controller/OrderController.ts#OrderController → src/domain/OrderRepository.ts#OrderRepository (call, inferred)
src/controller/OrderController.ts:8 … must not depend on src/domain/OrderRepository.ts#OrderRepository (Controller on Domain)
Domain
✖ disallowed-layer [warn] src/domain/Audit.ts#Audit → src/infra/SqlOrderRepository.ts#SqlOrderRepository (import)
src/domain/Audit.ts:1 … must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Domain on Infrastructure)
…
Violations: 8 (5 error, 3 warn) Uncovered: 0 Skipped: 0 Allowed: 14 Warnings: 0 Errors: 0!Deprecatedrefuses the deprecatedLegacyPricing, although+Infrastructurebrings Domain.+Infrastructurebrings Domain only overimportandimplements, Infrastructure's own types, so the controller'snew Order()(aninstantiation) is refused; itsimportofOrderis allowed.- The inferred call is a
warn(seepolicy.inferred). - Domain on Infrastructure is advisory:
warn, which fails only under--fail-on warn. "Shared": ["*"]lets Shared use every layer; the shop'sAuditable(Shared) onLedgerWriter(Infrastructure) is allowed.
Scope and policy
scope says which dependencies are checked; policy says how they are judged. Every key is optional:
| Key | Default | Meaning |
|---|---|---|
scope.types | import, require, extends, implements, call, instantiation, param_type, return_type | The edge types checked. The framework edges (route, http, dispatch, render …) are opt-in |
scope.include | everything | Globs: an edge is checked only when both its ends are in a matching file |
scope.exclude | nothing | Globs: an edge with an end in a matching file is never checked |
policy.inferred | "warn" | Edges MapX matched by name alone: "error" judges them like verified ones, "warn" reports their violations as warn, "ignore" skips them |
policy.inheritDependencies | false | A type answers for its workspace ancestors' dependencies too (see Type hierarchy and members) |
policy.strictLayers | false | An element in several non-overlay layers is an error (multi-layer), not a warning |
policy.alsoInferred | false | Keep the role-order layer violations in the smells beside the rules' own (see Smells) |
policy.uncovered | { "builtins": false }; externals unset is true only when a non-overlay layer has a positive package selector | Whether dependencies on packages and on the standard library count as uncovered. Packages under externals, opt-in (D039); built-ins and ambient access under builtins (see External dependencies) |
"scope": { "types": ["import", "extends", "implements", "instantiation"], "exclude": ["src/legacy/**"] },
"policy": {
"inferred": "ignore",
"inheritDependencies": true,
"strictLayers": true,
"alsoInferred": true,
"uncovered": { "externals": true, "builtins": false }
}With these, and a Models layer ({ "extends": "BaseModel" }) that overlaps Domain, the shop reports an inherited dependency and a strict multi-layer error. Invoice extends the Shared class Auditable, which uses Infrastructure:
$ mapx check --view policy
Domain
…
✖ disallowed-layer [error] src/domain/Invoice.ts#Invoice → src/infra/LedgerWriter.ts#LedgerWriter (import)
src/domain/Invoice.ts src/domain/Invoice.ts#Invoice must not depend on src/infra/LedgerWriter.ts#LedgerWriter (Domain on Infrastructure), inherited via src/domain/Invoice.ts#Invoice → src/shared/Auditable.ts#Auditable
…
Domain, Models
✖ multi-layer src/domain/Order.ts#Order is in 2 layers: Domain, Models
src/domain/Order.ts
Violations: 7 (7 error, 0 warn) Uncovered: 0 Skipped: 0 Allowed: 11 Warnings: 0 Errors: 1Visibility
Two mechanisms hide a layer's implementation from other layers, even ones its rules allow (AR-07). Both apply only after the rules allow a dependency, so a forbidden one stays a disallowed-layer violation.
- Private parts (rule
private-layer). A layer'sprivateselectors mark elements only that layer may reach."private": [{ "path": "app/Infrastructure/Db/**" }]lets other layers use the Infrastructure layer but not its database code. - Internal elements (rule
internal-element). An internal element is reachable only from its own layers. An element is internal when:- it is a symbol with a doc tag listed in
visibility.internalTags(default@internaland@mapx-internal). Tags compare like thedocTagselector: case is ignored and the@is optional. A list replaces the default, so["@mapx-internal"]stops@internalfrom counting; - it is a member of an internal type, since the type is how it is reached;
- it is a file whose top-level symbols are all internal (an import of the file names them), or a whole file a native marker covers;
- for a Go import, every in-scope file of the package is internal;
- with
visibility.nativeMarkers: true, its language's own module visibility makes it internal (below).
- it is a symbol with a doc tag listed in
Native markers are off by default, because layer boundaries rarely match compiler boundaries. "visibility": { "nativeMarkers": true } turns on all of them. A native marker counts only for a reference the language itself refuses, so code that compiles (or follows the language's convention) is never flagged. Where MapX cannot see the boundary (no project, module or package file), it reports nothing:
| Language | Internal | Refused (an internal-element finding) when the referrer… |
|---|---|---|
| Go | a file under a directory named internal | is not rooted at the parent of the (deepest) internal directory, by import path. internal/http may use internal/domain, and a module-root internal/ is open to the whole module |
| Python | a module or package whose name starts with _ (not a dunder such as __init__) | is outside the package that holds it and imports the private module itself. A name the package re-exports (from ._journal import Journal in __init__.py, used as from app.domain import Journal) is public through that path |
| Python | a symbol whose name starts with _ (not a dunder) | is in another module. from m import _x names it, so that import is reported on _x too |
| Dart | a symbol whose name starts with _ | is in another file that is not a part of |
| Dart | a file under lib/src/ | is in another package (nearest pubspec.yaml) |
| Kotlin | internal | is in another module (nearest build.gradle, build.gradle.kts or pom.xml). A module's tests are in the module |
| C# | internal (not protected internal), private protected, and a type declared in a file or namespace without an access modifier. A partial type is as accessible as its most accessible part | is in another project (nearest .csproj) that the target's project does not name in InternalsVisibleTo (a .csproj item or [assembly: InternalsVisibleTo("…")]) |
| Java | package-private: a type or member without public, protected or private, except interface and annotation members | is in another package. A test in the same package under another source root (src/test/java) may use it |
| Rust | pub(crate), pub(self), pub(super), pub(in path) | is in another crate (nearest Cargo.toml), or outside the item's module, its parent module, or the path. A plain item without pub is not marked: methods of a trait impl have no pub |
| TypeScript, TSX, JavaScript, Vue, Svelte | the @internal doc tag, even when internalTags leaves it out | is in another workspace package (nearest package.json name): stripInternal removes it from the published types |
| PHP | none | PHP has no module visibility. Its @internal convention is a doc tag: keep it in internalTags |
| Others | none | Swift's internal is its default; Scala private[pkg] is not read yet |
The scan stores the Kotlin, C#, Java and Rust visibility on each symbol as metadata.visibility (internal, package, pub(crate) …), only when it is restricted, as each declaration (or part of a partial type) writes it. Overloads that share one element are restricted only when every one of them is.
A finding names the marker: App\Application\PlaceOrder must not depend on App\Domain\LedgerHelper: it is internal to Domain (@internal) (Application on Domain). When a member carries the marker, the finding (reported on its type) names the member: … Account: core/Ledger.kt#Account.audit is internal to Core (Kotlin internal) ….
On the shop, LegacyPricing is private to Domain and Ledger is marked /** @internal */. The rules allow Controller and Infrastructure to use Domain:
"visibility": { "internalTags": ["@internal"], "nativeMarkers": false },
"layers": [
{ "name": "Domain", "selectors": [{ "path": "src/domain/**" }], "private": [{ "path": "src/domain/LegacyPricing.ts" }] },
…
]$ mapx check --view visibility
Infrastructure
✖ internal-element [error] src/infra/LedgerWriter.ts#LedgerWriter → src/domain/Ledger.ts#Ledger (import)
src/infra/LedgerWriter.ts:1 src/infra/LedgerWriter.ts#LedgerWriter must not depend on src/domain/Ledger.ts#Ledger: it is internal to Domain (@internal) (Infrastructure on Domain)
…
Controller
✖ private-layer [error] src/controller/OrderController.ts#OrderController → src/domain/LegacyPricing.ts#LegacyPricing (import)
src/controller/OrderController.ts:3 src/controller/OrderController.ts#OrderController must not depend on src/domain/LegacyPricing.ts#LegacyPricing: it is private to Domain (Controller on Domain)
…Evaluation
Each stored edge in scope.types (default: import, require, extends, implements, call, instantiation, param_type, return_type) is checked in this order. The first step that applies decides:
- Out of scope: the edge is skipped when its source or target path fails
scope.include/scope.exclude, when it is inferred andpolicy.inferredis"ignore", or when both ends are the same element. - No source layer: the source is in no layer, so there is nothing to check.
- Then, once for each non-overlay layer of the source:
- Denied overlay: the target is in an overlay layer the source layer denies (
"*": ["!Deprecated"]). This is adisallowed-layerviolation, whatever the target's other layers. Overlays matter only as denied targets:*and default-deny never involve them. - Uncovered: the target is in no non-overlay layer.
- Same layer: every non-overlay layer of the target is the source layer. Nothing is recorded.
disallowed-layer: one of the target's other layers is not allowed for this edge type, or is denied.internal-element: the target is internal (see Visibility) and the source layer is not one of its layers.private-layer: the target is private to a layer other than the source layer (aprivateselector).- Allowed.
- Denied overlay: the target is in an overlay layer the source layer denies (
Severity. A violation is an error. It is a warn when the edge is inferred (under the default policy.inferred: "warn") or when the pair is advisory. Uncovered dependencies are warn.
Granularity. Layers are decided on the finest element an edge names: the method, or the file for an import. An edge end names a symbol only when the file has exactly one symbol of that name; an import path names the file. A finding then names the top-level declaration: a method's dependency is reported on its class, even when the method is declared in another file of the class's namespace or package (Go, C# partial classes). An import is reported on the file's only top-level symbol, or on the file when it has several. So one class-to-class dependency is one finding per edge type, however many edges carry it. There are two exceptions:
- the target of an
httporrouteedge stays the handler method; - a Go import names a package, so its target is the package (its import path, with the layers of its directory's in-scope files). It is dropped when every importing file already has a finding of the same kind and rule on a type of that package. An allowed import is also dropped when every importing file has a finding of any kind on a type of that package: when that type is internal, the import is how the violation is reached.
Reporters
mapx check --format <format> picks the reporter; -o <file> writes it to a file. All six list the same findings, in the same order, and each is deterministic: the same findings give the same bytes.
| Format | For | Example |
|---|---|---|
table (default) | people, in a terminal | Quick start |
json | tools, --compare, the dashboard | The report |
sarif | GitHub code scanning, IDE SARIF viewers | CI reports |
github | GitHub Actions annotations (the default inside GitHub Actions) | CI reports |
codeclimate | GitLab Code Quality | CI reports |
junit | GitLab and Jenkins test reports | CI reports |
--report-uncovered, --report-skipped and --report-allowed add those findings to every format; they are always counted. The table marks each finding by kind: ✖ a violation or an error, ! a warning, ? an uncovered dependency, - a skipped violation ([baseline] or [compare]), ✓ an allowed dependency:
$ mapx check --report-skipped --report-allowed # after --generate-baseline
Controller
- disallowed-layer [baseline] src/controller/OrderController.ts#OrderController → src/infra/SqlOrderRepository.ts#SqlOrderRepository (import)
src/controller/OrderController.ts:2 src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure)
- disallowed-layer [baseline] src/controller/OrderController.ts#OrderController → src/infra/SqlOrderRepository.ts#SqlOrderRepository (instantiation)
…
✓ allowed src/controller/OrderController.ts#OrderController → src/domain/LegacyPricing.ts#LegacyPricing (import)
src/controller/OrderController.ts:3 src/controller/OrderController.ts#OrderController may depend on src/domain/LegacyPricing.ts#LegacyPricing (Controller on Domain)
…Warnings (!) and uncovered dependencies (?) look like this:
layer Empty
! empty-layer layer Empty matches no element
Domain
? uncovered src/domain/Order.ts#Order → src/shared/BaseModel.ts#BaseModel (extends)
src/domain/Order.ts:4 src/domain/Order.ts#Order depends on src/shared/BaseModel.ts#BaseModel, which is in no layerThe report
mapx check --format json prints this report, described by schemas/arch-report.schema.json. It comes from runArchitecture(dir) in src/arch/report.ts. Uncovered, skipped and allowed findings are listed only with --report-uncovered, --report-skipped or --report-allowed; counts always includes them:
findings: one per dependency, edge type and rule. A source in several layers gets one finding whosesource.layerlists them. Each has akind(violation,skipped,uncovered,allowed,warning,error), arule, aseverity, thesourceandtarget(element, file, layer, repo, first line), theedgeType,verifiability, the number ofedgesit stands for, afingerprintand amessage. Askippedfinding says why inskippedBy:baselineorcompare. A finding whose fingerprint inputs differ from the elements it shows has anidentity; see Fingerprints.fingerprint: a hash of the rule, both elements and the edge type, with no line number, so it survives edits that move code. See Fingerprints.- Warnings:
multi-layer, for a top-level declaration in several non-overlay layers. It is an error underpolicy.strictLayers. A symbol-less file gets one only when it is a dependency end. An external element in several layers gets one too, when a source in a layer depends on it;empty-layer, for a non-overlay layer that matches nothing;unsupported-selector, for a selector this version cannot evaluate yet. It matches nothing.
- Errors: an invalid configuration gives
errorfindings,plus-cyclefor a+cycle (naming its first layer) andconfigfor the rest. A baseline entry that matches nothing is astale-baselineerror. layers: elements and files per layer, anduncovered: the uncovered dependencies whose source is in the layer.layerEdges: allowed, violating and skipped dependencies per layer pair, andwarnings: how many of the violations are reported atwarn(an advisory allowance, or an inferred edge underpolicy.inferred: "warn"). A pair keeps its own severity, so a source in two layers can be advisory on one pair and an error on the other.counts: per kind.selection(frommapx check): theview,layerandrulethe run evaluated and kept,nullwhen not given, andsince(the ref of--since) only when given.policy(M5-24-R4):policy: { uncovered: { externals: { value, source, file?, layer? }, builtins } }, the resolved uncovered policy.externals.sourcesays why packages in no layer are or are not uncovered:explicit(set infile),package-selector(layeropts in) ordefault(out of scope). Absent when the configuration cannot be loaded.
A workspace with a configuration but no index is an error (run mapx scan first), never an empty report.
The quick-start violation, as JSON (one finding, layers and layerEdges shortened):
$ mapx check --format json --rule disallowed-layer
{
"version": 1,
"counts": { "violation": 2, "skipped": 0, "uncovered": 0, "allowed": 0, "warning": 0, "error": 0 },
"findings": [
{
"kind": "violation",
"rule": "disallowed-layer",
"severity": "error",
"source": { "element": "src/controller/OrderController.ts#OrderController", "file": "src/controller/OrderController.ts", "layer": "Controller", "layers": ["Controller"], "repo": "backend-shop", "line": 2 },
"target": { "element": "src/infra/SqlOrderRepository.ts#SqlOrderRepository", "file": "src/infra/SqlOrderRepository.ts", "layer": "Infrastructure", "layers": ["Infrastructure"], "repo": "backend-shop" },
"edgeType": "import",
"verifiability": "verified",
"edges": 1,
"fingerprint": "d9471194ce2c8212",
"message": "src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure)",
"identity": { "source": "src/controller/OrderController.ts", "target": "src/infra/SqlOrderRepository.ts" }
},
…
],
"layers": [{ "name": "Controller", "elements": 3, "files": 1, "overlay": false, "uncovered": 0 }, …],
"layerEdges": [{ "from": "Controller", "to": "Infrastructure", "allowed": 0, "violations": 2, "warnings": 0, "skipped": 0 }, …],
"configErrors": [],
"selection": { "view": null, "layer": null, "rule": "disallowed-layer" }
}CI reports
mapx check --format also writes the formats CI systems read natively. The code is in src/arch/reporters/; the mapping of every finding kind is in cli-reference.md.
| Format | For | Notes |
|---|---|---|
sarif | GitHub code scanning (github/codeql-action/upload-sarif), IDE SARIF viewers | SARIF 2.1.0; skipped violations are suppressed (external, accepted); partialFingerprints["mapx/v1"] is the finding's fingerprint. GitHub accepts up to 10 MB (compressed) and 25,000 results per run |
github | GitHub Actions annotations on the pull request | Chosen automatically when GITHUB_ACTIONS is true, no --format is given and the report goes to stdout. The run's causes are annotated first (GitHub shows 10 per level and step); every finding is also a log line |
codeclimate | GitLab Code Quality (artifacts:reports:codequality) | A violation's issue fingerprint is the finding's fingerprint; other kinds derive one from kind and rule; severities from report.codeclimate |
junit | GitLab and Jenkins test reports | One suite per layer, one testcase per finding; a testcase fails exactly when its finding fails the run |
- Failing level: every format marks exactly the findings that fail the run (the exit-code rule, with
--fail-on,--fail-on-uncoveredand--compare) at its failing level: SARIFerror,::error, the CodeClimateerrorseverity, a JUnit failure or error. So the cause of exit 1, 2 or 3 is always shown as an error. - Paths use
/in every format; a finding without a file is located on the configuration file. The CI formats give them from the root of the git repository that holds the workspace (services/api/app/…for a workspace inservices/api), so CI links the right file;tableandjsonkeep them workspace-relative. - A file outside the repository (
--baseline ../../shared/bl.json, or abaselinethere in the configuration) is never a location: a..path would leave%SRCROOT%, and code scanning requires a file in the repository. Every CI format locates such a finding on the configuration file instead, at the line of its"baseline"key (line 1 when--baselinenames it), and the message names the outside file (… (in ../shared/bl.json, outside the repository)); SARIF also carries it inproperties.outsideRoot(M5-07-R2). - Submodules: a workspace inside a git submodule names files from the submodule's root, not the superproject's. The files belong to the submodule's repository, which is where code scanning and GitLab look them up; the superproject only records the submodule's commit, so a
mods/lib/…path would point at no file in either (M5-07-R2). - Determinism: the same findings give the same bytes: no timestamps, durations, host names or absolute paths. Only SARIF's
tool.driver.versionchanges with the MapX version. - Baselines and
--compare: a skipped violation is a SARIF suppression, a::notice, a CodeClimate issue at theskippedseverity and a JUnit<skipped>, listed only with--report-skipped. A stale baseline entry is an error located on the baseline file (relative to the workspace, never absolute), its message naming the entry by JSON pointer (.mapx/architecture.baseline.json#/entries/0); the report gives it aslocation.
The quick-start violation (the import; the instantiation is alike) in each CI format:
$ mapx check --format github
[error] src/controller/OrderController.ts:2 disallowed-layer: src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure) [import]
…
::error file=src/controller/OrderController.ts,line=2,title=disallowed-layer::src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure) [import]
…
Violations: 2 (2 error, 0 warn) Uncovered: 0 Skipped: 0 Allowed: 12 Warnings: 0 Errors: 0$ mapx check --format sarif # one result of runs[0].results
{
"ruleId": "disallowed-layer",
"ruleIndex": 0,
"level": "error",
"message": { "text": "src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure) [import]" },
"locations": [{
"physicalLocation": { "artifactLocation": { "uri": "src/controller/OrderController.ts", "uriBaseId": "%SRCROOT%" }, "region": { "startLine": 2 } },
"logicalLocations": [{ "fullyQualifiedName": "src/controller/OrderController.ts#OrderController" }]
}],
"partialFingerprints": { "mapx/v1": "d9471194ce2c8212" },
"properties": { "kind": "violation", "sourceLayer": "Controller", "target": "src/infra/SqlOrderRepository.ts#SqlOrderRepository",
"targetFile": "src/infra/SqlOrderRepository.ts", "targetLayer": "Infrastructure", "edgeType": "import", "verifiability": "verified", "edges": 1 }
}$ mapx check --format codeclimate # one issue of the array
{
"type": "issue",
"check_name": "disallowed-layer",
"description": "src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure) [import]",
"categories": ["Complexity"],
"severity": "major",
"fingerprint": "d9471194ce2c8212",
"location": { "path": "src/controller/OrderController.ts", "lines": { "begin": 2 } }
}$ mapx check --format junit
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="mapx check" tests="5" failures="2" errors="0">
<testsuite name="Controller" tests="2" failures="2" errors="0" skipped="0">
<testcase name="src/controller/OrderController.ts#OrderController → src/infra/SqlOrderRepository.ts#SqlOrderRepository (import)" classname="disallowed-layer">
<failure type="disallowed-layer" message="src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure) [import]">src/controller/OrderController.ts:2
src/controller/OrderController.ts#OrderController must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Controller on Infrastructure) [import]
fingerprint: d9471194ce2c8212</failure>
</testcase>
…
</testsuite>
<testsuite name="Domain" tests="1" failures="0" errors="0" skipped="0">
<testcase name="no findings" classname="layer"/>
</testsuite>
…
</testsuites>The CodeClimate severity map. report.codeclimate maps what a finding is to a CodeClimate severity (info, minor, major, critical, blocker). error is for the findings that fail the run. Other violations and warnings (an advisory rule, "severity": "warn", or an inferred edge under policy.inferred: "warn", without --fail-on warn) are warn; uncovered dependencies (without --fail-on-uncovered) and skipped violations are keyed by kind. Keys left out keep the defaults below, and an extends chain merges the map key by key. An unknown key or severity is a configuration error (exit 3).
{
"report": {
// the defaults: a failure is major, a skipped violation minor, an uncovered dependency info (as Deptrac)
"codeclimate": { "error": "major", "warn": "minor", "uncovered": "info", "skipped": "minor" }
}
}A GitHub Actions job, as an example. The index is not committed, so the job scans first:
- run: npx @mgamil/mapx init . --no-agents --no-suggestions --no-mcp-configs && npx @mgamil/mapx scan .
- run: npx @mgamil/mapx check # annotations, since GITHUB_ACTIONS is set
- run: npx @mgamil/mapx check --format sarif -o mapx.sarif
if: always()
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: mapx.sarifUploading to code scanning needs permissions: security-events: write, which a pull request from a fork never gets, so on a public repository that takes fork PRs, keep the annotations as the gate and the SARIF file as a build artifact (actions/upload-artifact) instead, as MapX's own CI does. Write reports outside the workspace or under .mapx/ when another mapx command runs after them in the same job: a new file of a type MapX indexes (.json, .md …) elsewhere in the workspace makes the index stale (exit 4); .sarif, .xml and .txt files are not indexed.
Exit codes
mapx check exits with the most basic problem it found. When several apply, 4 wins over 3, 3 over 1, and 1 over 2:
| Code | Meaning | Example on the shop |
|---|---|---|
| 0 | No finding fails the run. Skipped (baselined) violations, warnings, uncovered dependencies and warn-level violations do not fail it by default | mapx check after --generate-baseline |
| 1 | A violation of severity error; with --fail-on warn, also a warn-level violation or a warning; under policy.strictLayers, a multi-layer error | the quick start; an advisory rule with --fail-on warn |
| 2 | An uncovered dependency, with --fail-on-uncovered | below |
| 3 | A configuration, usage or baseline error: an invalid document, a + cycle, no configuration or no such view, an unknown option, --layer or --rule, a stale baseline entry, a missing or invalid baseline or --compare report | below |
| 4 | No index, a repo never scanned, an index older than the files (pass --sync) or than this MapX, or an internal error | below |
$ mapx check --view core --fail-on-uncovered # a view without the Shared and Infrastructure layers
…
Violations: 0 (0 error, 0 warn) Uncovered: 6 Skipped: 0 Allowed: 5 Warnings: 0 Errors: 0
$ echo $?
2
$ mapx check --view broken # "paht" and "Servce" are typos
configuration
✖ config .mapx/architecture/broken.jsonc#/layers/0/selectors/0/paht: unknown selector key "paht"; expected one of path, name, …
✖ config .mapx/architecture/broken.jsonc#/rules/Http/0: unknown layer "Servce"; did you mean "Service"?
Violations: 0 (0 error, 0 warn) Uncovered: 0 Skipped: 0 Allowed: 0 Warnings: 0 Errors: 2
$ echo $?
3
$ mapx check --baseline ../stale.json --rule stale-baseline
configuration
✖ stale-baseline src/domain/Order.ts#Order → src/infra/SqlOrderRepository.ts#SqlOrderRepository
src/domain/Order.ts ../stale.json#/skip_violations/src~1domain~1Order.ts#Order/0: skip_violations src/domain/Order.ts#Order → src/infra/SqlOrderRepository.ts#SqlOrderRepository matches no violation; delete it
…
$ echo $?
3
$ touch src/domain/Discount.ts && mapx check # a new file the index does not have
The index is stale: 1 file(s) changed since the last scan (src/domain/Discount.ts (new)). Run `mapx update`, or `mapx check --sync`.
$ echo $?
4The mapx arch subcommands follow the same codes: arch allowed exits 1 when mapx check would fail on the dependency, arch unassigned exits 2 when an element is in no layer, arch validate-config and arch init exit 3 on a configuration or usage error, and every subcommand exits 4 on an index problem. arch graph is not a gate: it exits 0 whatever the graph shows. The MCP tools return the same code as exitCode (see MCP tools).
Layer graphs
mapx arch graph draws the layer graph (Deptrac's graphviz-* and mermaidjs formatters) as dot (the default), svg, mermaid, dsm or json; its options are in cli-reference.md. It is built from the report mapx check builds, baseline included, so every count and status is check's own decision. layerGraph(report, config) in src/arch/reporters/graph.ts returns the graph, which --format json prints and the dashboard (M5-15) reads.
- Nodes: one per layer, in configuration order. A layer's
colorfills its node when it is#rrggbb,#rrggbbaaor a colour name; other values are ignored. Overlay layers have a dashed border. - Edges: for each layer pair, the dependencies counted in
layerEdges(one per element pair and edge type), one edge per status: allowed (grey), violations (red), warn-level violations (amber) and skipped, that is baselined, violations (dashed). The label is the count. - Groups are subgraphs. A layer's group is the first group of
report.groupsthat lists it, else its owngroupkey. - Hidden layers (
report.hidden) are analysed but not drawn: neither the node nor its edges appear. The totals still count them; they name the number of hidden layers, never their names. - Point to group (
report.pointToGroups: true, Deptrac'spointToGroups): an edge into the layer named like its group points at the group, which shows a module's public API. An edge from inside the group still reaches the layer. DOT useslhead, Mermaid an edge to the subgraph. - The layer DSM (
--format dsm) is a matrix of the visible layers, rows depending on columns, printed like the cluster DSM ofmapx arch --dsm. A cell is its pair's count, marked by its worst status:!violations,~warn-level violations only,sskipped violations only. Columns widen to the widest cell. - Totals are
mapx check's counts (counts, one per finding), withwarningsthe violations atwarn. They are not sums over the pairs: a dependency on an element in both Domain and Shared counts on both pairs but once in the totals. - Escaping: DOT node and cluster IDs are synthetic (
n0,n1… andcluster_0…, numbered in name order), so no name can mean something to Graphviz as an ID (%Domain). Labels are double-quoted with"and\escaped and&written&, so Graphviz shows<,\Nor\Gin a name as written. Mermaid IDs are derived from the names (layer_Http_Api, then_2… on a clash) and labels use Mermaid entity codes (#quot;,#lt;…). SVG text is XML-escaped. - Determinism: nodes and edges follow the configuration's layer order, so the same index and configuration give the same bytes. SVG through Graphviz depends on the Graphviz version;
--no-graphvizis byte-stable everywhere.
{
"layers": [
{ "name": "Controller", "group": "Web", "color": "#fde68a", "selectors": [{ "path": "src/controller/**" }] },
// …
],
"report": {
"groups": { "Domain": ["Domain", "Shared"] }, // edges into Domain from outside point at the group
"pointToGroups": true,
"hidden": ["Infrastructure"] // counted, not drawn
}
}{ version: 1, view,
layers: [{ name, overlay, elements, files, group, color, hidden, uncovered }],
groups: [{ name, layers, pointTo }],
edges: [{ from, to, allowed, violations, warnings, skipped, hidden }],
totals: { allowed, violations, warnings, skipped, uncovered, hiddenLayers } }uncovered counts the source layer's dependencies on elements in no layer, which have no edge to draw (the report's layers[].uncovered). pointTo is the layer whose incoming edges point at the group, or null.
The shop's graph, with the baselined Controller → Infrastructure dependencies (dashed) and the new Domain → Infrastructure violation (red):
$ mapx arch graph --format mermaid
flowchart TB
%% Dependencies: 12 allowed, 2 violations (0 at warn), 2 skipped, 0 uncovered
layer_Controller["Controller"]
layer_Domain["Domain"]
layer_Infrastructure["Infrastructure"]
layer_Shared["Shared"]
layer_Controller -->|5| layer_Domain
layer_Controller -.->|2| layer_Infrastructure
layer_Domain -->|2| layer_Infrastructure
layer_Domain -->|4| layer_Shared
layer_Infrastructure -->|3| layer_Domain
linkStyle 1 stroke:#94a3b8,color:#94a3b8
linkStyle 2 stroke:#dc2626,stroke-width:2px,color:#dc2626
$ mapx arch graph --format dsm
── Layer Dependency Structure Matrix (DSM) ────────────
L1 L2 L3 L4
-------------------------------------
L1 Controller * 5 2s .
L2 Domain . * 2! 4
L3 Infrastructure . 3 * .
L4 Shared . . . *
…Smells
mapx_smells, mapx arch (with --smells and --violations) and the dashboard's /api/smells list the smells the last scan found: cycles, hubs, cluster smells, and layer violations inferred from file roles (the role order of the codebase's archetype). Without .mapx/architecture.jsonc, that list is exactly as before. With one, the configuration is the one source of layer violations (docs/research/deptrac/03-mapx-gap-and-integration.md §3.3):
layer-violationsmells come frommapx check's report, its baseline applied, one per source file → target file. Each line names the elements, the layers, the rule and the edge types. A violation of severityerrorisCRITICAL, awarnoneWARNING.- The role-order violations are dropped, so an edge is never reported twice. With
"policy": { "alsoInferred": true }they are kept too, markedSource: inferred-roles, but only on edges the rules decide in no way: from a file in no layer, outside the paths the scope excludes. An edge the rules allow, report, baseline or exclude is theirs, and never comes back as inferred. - Errors that make
mapx checkexit 3 (a stale baseline entry, a+cycle …) arearchitecture-configsmells (CRITICAL). - The rules add these smells, each marked
Source: rules:
| Smell | When |
|---|---|
layer-cycle | Layers form a cycle in the observed layer graph: every pair with an allowed or a reported violating dependency, after the baseline. CRITICAL. |
layer-skip | AR-18: in a strictly ordered chain, a layer bypasses the next one down. The chain is written with +: each layer's one + names the next layer down (Controller: ["+Service"], Service: ["+Repository"]). A dependency on a layer of the source's chain past its next one skips the layers between that have elements, and the message names them all (bypassing Service, Repository). A layer the source's own rules or the * defaults name is no skip. A layer off the chain is no skip either, such as a shared kernel some layer allows plainly and the source reaches through +. A layer with several + ends the chain. One per source file → target file. |
dead-allowance | An allowance the rules write for the pair (explicit, or the layer a + names) that no dependency uses, as mapx arch unused counts uses. Inherited grants and the * wildcard are not reported. A "*" default ("*": ["Util"]) is dead only when no layer uses it, and is then reported once, as * → Util. |
weak-allowance | The same, used by only 1 or 2 stored edges (mapx arch unused --limit 2; a "*" default, by all layers together): a candidate for removing the coupling. |
unstable-layer-dependency | Martin's Stable Dependencies Principle per layer pair: the source layer is more stable than its target, I(source) < I(target). |
abstractness-distance | A layer with types and dependencies is far from the main sequence: D = |A + I − 1| > 0.7, in the zone of pain (concrete and stable) or of uselessness (abstract and unstable). |
deprecated-usage | An edge in the configuration's scope into an element tagged @deprecated (a Python @deprecated decorator counts), or a member of one, from outside it. Deprecated code using deprecated code is not reported. |
Instability is I = Ce / (Ca + Ce) per layer, counted in files. Ca counts the files outside the layer with a dependency on it, and Ce the files of the layer with a dependency outside it, allowed or violating. A file counts once, whatever it declares. Abstractness A is the share of interfaces and traits among the layer's top-level types.
On the shop with the quick-start rules, its baseline, and a new Audit class in Domain that uses SqlOrderRepository:
$ mapx arch --smells
── Architectural Smells & Violations (5) ─────────────────
[CRITICAL] layer-violation
Description: Layer violation: src/domain/Audit.ts#Audit must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Domain on Infrastructure) [disallowed-layer: import, instantiation]
Files: src/domain/Audit.ts, src/infra/SqlOrderRepository.ts
Suggestion: Remove the dependency, or allow Domain → Infrastructure in .mapx/architecture.jsonc if it is intended (`mapx arch allowed` explains the decision).
Source: rules
[CRITICAL] layer-cycle
Description: Layer cycle: Domain -> Infrastructure -> Domain
Layers: Domain, Infrastructure
…
Source: rules
[WARNING] deprecated-usage
Description: Deprecated usage: src/controller/OrderController.ts#OrderController uses src/domain/LegacyPricing.ts#LegacyPricing, which is deprecated (use Money)
…The baselined Controller → Infrastructure violations are neither a layer-violation nor part of a cycle. With "policy": { "alsoInferred": true }, role-order violations on edges the rules do not decide join the list, marked Source: inferred-roles.
The baseline. The layer graph (cycles, instability, distance) is the one after the baseline. A baselined violation is acknowledged debt: it is neither a smell nor a coupling, so it makes no cycle. Tracking that debt is a separate smell (baseline-debt, not built yet).
Composed when read. Rule smells are computed each time they are listed, from the index and the configuration as they are, so an edited configuration applies without a rescan. The edges are evaluated once per listing. In a multi-repo workspace, layer-level smells (cycles, allowances, stability, distance) belong to the workspace's primary repo, and the others to their source file's repo.
A configuration that cannot be loaded (or an invalid baseline) keeps the role-order violations, marked inferred-roles, and adds an architecture-config smell naming the first problem. mapx arch validate-config lists them all.
Package smells (M5-24), also marked Source: rules:
| Smell | When |
|---|---|
vendor-leak | A private-layer violation on an external package: a package a layer's private selectors confine to it is used from another layer. It replaces the layer-violation smell for that pair. |
undeclared-dependency | Code in scope (any layer, or none) uses a known package that a readable manifest positively does not list directly: a transitive one, or one in no manifest (direct false). The rule is the same for npm, Composer, pypi and Go: a package no input provides is never one, since there is no name the manifest could lack. One per source file and package. |
dev-dependency-in-prod | Code in scope uses a dev-only package from a file that is not a test, configuration, script or tooling file: by its role (test, config, scripts), or by a conventional name (*_test.go, test_*.py, *.spec.ts, OrderTest.php, *.config.ts, *.conf.js, .eslintrc.cjs, conftest.py, setup.py, noxfile.py, gulpfile.js …). One per source file and package. |
A package whose direct or dev status is unknown gives neither of the last two.
The uncovered-package opt-in (D039) does not apply to them. vendor-leak is any private-layer violation on an external element: a package, but also a built-in or ambient access that a layer selects and keeps private ("selectors": [{ "builtin": true }], "private": [{ "builtin": true }], used from another layer, is a vendor leak on node:fs). It needs a private selector that matches the external, not the opt-in. undeclared-dependency and dev-dependency-in-prod compare the code with its manifests rather than with the layers, and they never change mapx check's exit code, so they are reported with or without a package selector.
Coverage and debt smells (M5-16-R2), also marked Source: rules:
| Smell | When |
|---|---|
orphan-element | A file in no layer (non-overlay), in the scope's paths, that declares a top-level symbol and that no other file depends on, by any stored edge. One per file, severity info. Not orphans, because nothing is meant to import them: a file whose role is entry, cli, api (routes, controllers, handlers), middleware, plugins, pages, test, config, scripts, docs, styles or assets; a test by the role classifier's own definition (*.test.ts, *.spec.ts, *.cy.ts, e2e/, testing/, *_test.go, test_*.py, *Test.php, *IT.java, test-utils, vitest.setup …); a file in a bin/, scripts/, routes/, controllers/, migrations/, plugins/ or cmd/ folder; server.ts, *.d.ts, manage.py, wsgi.py, asgi.py, __main__.py, __init__.py; a conventional configuration file name; a file that declares a main function or method, or a Spring Boot application (@SpringBootApplication); every file of a Go package main (a directory with a top-level func main); files that declare nothing. A Go package is judged as a whole, since an import names the package, not one of its files. The unit is the file, because a symbol's callers are not always resolved. A module that nothing imports but a runtime loads by a convention this list does not know (a Django views.py wired by a string, a plugin found by glob) is still reported: give it a layer, or an entry role in settings.architecture.overrides. |
baseline-debt | One per source layer that has violations the baseline skips: Baseline debt: layer Repository has 2 baselined violations (disallowed-layer: 2). A source in several layers counts for each. Severity info. A baseline entry records no date, so the debt is not aged. Without a baseline there is none. |
Baselines and the ratchet
A baseline lets you adopt rules on code that already breaks them. It freezes today's violations, so only new ones fail. It can only shrink: an entry that no longer matches a violation is an error, and you delete it. The code is in src/arch/baseline.ts.
The baseline file
{
"version": 1,
"entries": [
{
"fingerprint": "0816fbd611f92a81",
"rule": "disallowed-layer",
"source": "App\\Domain\\Order",
"target": "App\\Infrastructure\\SqlOrderRepository",
"edgeType": "instantiation"
}
],
"skip_violations": {
"App\\Domain\\Order": ["App\\Infrastructure\\SqlOrderRepository"]
}
}entriesare whatmapx check --generate-baselinewrites. There is one per violation, sorted by rule, source, target and edge type, with no timestamp. The same violations always give the same bytes, so a diff of the baseline is a review of the debt.- Fields: an entry needs a
fingerprint, or all ofrule,source,targetandedgeType, from which the fingerprint is computed. When both are given, they must agree. - Repos: an entry in a multi-repo workspace also has
sourceRepoandtargetRepowhen an end is outside the root repo. - Ends:
sourceandtargetare the fingerprint's ends, so an import'ssourceis the importing file (app/Domain/Order.php).
- Fields: an entry needs a
skip_violationsis Deptrac's form: source element → target elements. A pair matches every violation between the two elements, whatever its rule or edge type. Use it to carry a Deptrac baseline over.- Matching: a violation whose fingerprint, or element pair, is in the baseline is reported as
skipped. It never fails the run and is counted as skipped inlayerEdges. - Stale entries: every entry or pair that matched no violation is an
errorfinding with rulestale-baseline, and the run exits 3. Its ends name the files when the elements still exist. Regenerating drops it. - Which file:
--baseline <file>, else the configuration's"baseline"(relative to the workspace), else.mapx/architecture.baseline.jsonif it exists.- Views: a view uses
.mapx/architecture/<view>.baseline.json, unless the view's own document sets"baseline". Abaselineit inherits throughextendsis ignored, so a view never reads or overwrites the main baseline. - Errors: a file named by the option or the configuration must exist. A missing file, invalid JSON, an unknown key or a bad entry is a configuration error (exit 3), reported with a JSON pointer. A UTF-8 byte order mark is ignored.
- Where to keep it: under
.mapx/. MapX indexes JSON files, so a baseline elsewhere in the workspace makes the index stale (exit 4) until the next scan;--generate-baselinewarns and suggests a.mapx/path.
- Views: a view uses
Fingerprints
A fingerprint is the first 16 hex characters of sha256 over [rule, source, target, edge type]. When an end is outside the workspace's root repo, it is taken over [rule, source, target, edge type, source repo, target repo].
- The ends:
- Symbol-level ends are the reported element: a qualified name (
App\Domain\Order,app.domain.order.Order,src/domain/order.ts#Order), with a method lifted to its class. - File-level ends are the workspace-relative file path. These are the ends an edge names at file level: an import's importing file, and an import path's target file. The report shows such an end as the file's only top-level declaration when it has one, but the fingerprint keeps the file. So adding a function or a constant to either file does not re-key the dependency. The finding's
identitygives the ends when they differ from those shown. A file-level and a symbol-level dependency between the same two declarations, on the same edge type, are two findings.
- Symbol-level ends are the reported element: a qualified name (
- Repos: a repo is named by its workspace-relative path (
backend), and the root repo by nothing. A single-repo workspace never names its repo, and in a multi-repo workspace two Laravel apps'App\Domain\Orderare different dependencies. - Stable across runs and machines: it hashes no line number, no layer, and nothing absolute or time-dependent.
- Stable across path separators: the store records paths with
/on every OS. A hand-written entry with Windows separators in a path (src\a.ts) is normalised before it is fingerprinted, while a PHP name keeps its backslashes. - A moved line still matches, since the line is not part of it. So does a re-scan of an unchanged tree, or a file edited elsewhere.
- What re-keys it:
- A renamed or moved class is a new dependency.
- A moved file re-keys its file-level ends everywhere. It also re-keys every element of a language whose qualified names are paths (TypeScript, JavaScript and the other path-form languages:
src/domain/order.ts#Order). Moving such a file is, to the baseline, removing a dependency and adding one: regenerate. - Another edge type is another dependency: an
importand aninstantiationbetween the same two classes are two entries. A Deptrac pair covers both.
--compare: the ratchet without a committed baseline
CI checks the merge base and keeps its JSON report. It then checks the head against it:
mapx check <base checkout> --format json --report-skipped --report-uncovered -o /tmp/base.json
mapx check --compare /tmp/base.json- What it skips: a violation whose fingerprint is anywhere in the base report is skipped (
skippedBy: "compare"), so only violations new since the base fail. - Other failing findings: a warning under
--fail-on warn, an uncovered dependency under--fail-on-uncoveredor a strictmulti-layererror does not fail when the base report has its fingerprint. - What always fails: configuration errors and stale baseline entries.
- Fixed violations are not errors: paying debt off is the point.
- Producing the base report: produce it with
--report-skippedand--report-uncovered, because hidden findings are not in the report and so cannot be recognised. A baseline still applies under--compare. - Like with like: the report records its
selection. A base report of another view, or one narrowed by a--layeror--rulethat the head does not share, is refused (exit 3). Its missing findings would look new, or one view's findings would excuse another's. A base report made with--sinceis refused too: it lacks the findings of the files that had not changed then. A narrower head against a whole base is fine.
--since: the ratchet over the files changed
mapx check --since <ref> judges only the dependencies written in files that changed since the git ref, so CI can ratchet without a baseline and without a base report. Old violations in files nobody touched are not reported and do not fail the run.
mapx check --since origin/main # exit 1 only on a violation in a file this branch changed
mapx check --since HEAD~1 --format sarif -o mapx.sarif- "Changed" is what this branch changed since it left
<ref>: the files ingit diff --name-status <base>where<base>isgit merge-base <ref> HEAD(committed since the branch point, staged and unstaged, with both paths of a rename), plus untracked files git does not ignore. That is three-dot semantics plus the working tree, so--since mainon a feature branch ignores whatmaingained after the branch left it (a two-dotgit diff mainwould call those files changed). When<ref>and HEAD share no history, there is no merge base and<ref>itself is the base.<ref>may be any revision git parses to a commit: a branch, a tag,HEAD~3, an abbreviated id or:/text..mapx/is never source. A workspace whose root is no repository works through its registered repos. - A finding counts when its source file changed. A dependency is written in its source file, so a changed target alone does not make an existing dependency new: editing a class does not re-judge everyone who imports it. The source of a new import is always a changed file, so the ratchet cannot miss one. Deleting a target breaks its importers' files, which then change.
- Findings without a source (
empty-layer, an unsupported selector) concern the configuration, not changed code, and are not reported under--since. Configuration and baseline errors always are (exit 3). - Combinations: the filter runs after the baseline and
--compareskipped what they know, then--layer,--rule,--fail-onand--fail-on-uncoveredapply to what is left. Every--formatrenders the filtered report, and the exit code follows the exit contract on it.--report-skippedlists only skipped violations in changed files. The JSON report'sselection.sincenames the ref. - Limits: the index is the working tree's, not the ref's: it must be current (exit 4, or
--sync), and the check does not re-scan the ref. A change to the configuration or to the layers' membership is not a changed file: it does not re-judge old code, so combine--sincewith a fullmapx checkon the default branch.--generate-baselinetakes no--since(exit 3). - Refs are never a shell command or an option. MapX runs git with an argument list. A ref that is empty, starts with
-, or contains whitespace, quotes or shell characters is refused (exit 3). So is a ref that is no commit of the repository, and a workspace in no git repository. A git command that fails after the ref resolved (a damaged repository, say) is exit 3 with git's message, never an empty list of changes that would pass the gate. - MCP:
mapx_arch_checktakessincewith the same meaning (source file changed).
On the shop, the base report holds the two baselined Controller violations; the head adds Audit, a Domain class that uses SqlOrderRepository:
$ mapx check --format json --report-skipped --report-uncovered -o ../base.json
$ mapx check --sync --compare ../base.json
Domain
✖ disallowed-layer [error] src/domain/Audit.ts#Audit → src/infra/SqlOrderRepository.ts#SqlOrderRepository (import)
src/domain/Audit.ts:1 src/domain/Audit.ts#Audit must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Domain on Infrastructure)
✖ disallowed-layer [error] src/domain/Audit.ts#Audit → src/infra/SqlOrderRepository.ts#SqlOrderRepository (instantiation)
src/domain/Audit.ts:4 src/domain/Audit.ts#Audit must not depend on src/infra/SqlOrderRepository.ts#SqlOrderRepository (Domain on Infrastructure)
Violations: 2 (2 error, 0 warn) Uncovered: 0 Skipped: 2 Allowed: 12 Warnings: 0 Errors: 0
$ echo $?
1--since <git-ref>, which would scan the ref itself, is deferred (REQ-ARCH-021).
Introspection
The mapx arch subcommands answer questions about the configuration from the index (AR-13, Deptrac's debug:* commands). Their options and exit codes are in cli-reference.md. With --format json each prints the object the matching function of src/arch/introspect.ts returns. The mapx_arch_layers, mapx_arch_allowed and mapx_arch_explain MCP tools return the same objects (M5-14).
- Elements. An element is
{ element, kind, symbolKind?, file }:elementis a symbol's qualified name or a file's path, andkindisfileorsymbol. - Errors. Every failure prints
{ "error": "…" }on stdout in JSON, whatever its exit code. An ambiguous name or an unknown layer addscandidates, and an invalid configuration addsissues: [{ file, pointer, message }]. - Scope.
scope.includeandscope.excludeapply as inmapx check. An element outside them takes part in no evaluated edge.
| Subcommand | Question | JSON |
|---|---|---|
layers | Which layers are there, and how big? | { layers: [{ name, overlay, elements, files }] }, counted as mapx check counts them: every element, and the files among them |
layers <layer> [--why] | Which elements are in this layer? | { name, overlay, elements, files, total, members: [element] }. See below |
which <element> | Which layers is this element in, and why? | { query, matches: [{ …element, layers, private, trace: [{ layer, selector, via, chain? }], inScope }] }. See below |
unassigned | What is in no layer? | { total, elements: [element] }. See below. Exit 2 when total is not 0 |
deps <from> [to] | Which concrete edges go from one layer to another? | { from, to, counts: { allowed, violation, uncovered }, dependencies: [{ source: { …element, line }, target, targetLayers, edgeType, verifiability, kind, rule?, severity, violatedLayers?, via? }] }. See below |
unused [--limit N] | Which allowances are used at most N times (default 0)? | { limit, total, allowances: [{ from, to, rule, types, severity, uses }] }. See below |
changed [files…] [--since <ref>] [--with-dependents] | Which layers do these files touch, and which layers depend on them? | { files: [{ file, indexed, inScope, layers }], layers, dependents?: [{ layer, files }], unassignedDependents? }. See below |
allowed <from> <to> [--type T] | Would a dependency from from to to pass mapx check? | Described below. Exit 1 when mapx check would fail on it |
layers <layer>:- Members are files and top-level symbols;
--nestedadds every symbol. --whyaddsselector, the position that matched (selectors[0].all), andvia:self, or the top-level symbol that put a file in the layer. A hierarchy match addschain, the inheritance path that matched.
- Members are files and top-level symbols;
which:layersincludes overlays, andprivatelists the layers the element is private to.- A name that matches several elements lists them all.
unassigned:- It lists elements in no non-overlay layer that a dependency can involve, within the scope's paths. These are symbols (top-level ones, unless
--nested) and files that declare a symbol or are an end of an evaluated edge. So the filesmapx initwrites (AGENTS.md and the like), JSON configs and other files nothing depends on are not listed, asmapx checknever reports them uncovered. --kindtakesfile,symbolor a symbol kind, and--patha path prefix or a glob.- An unknown kind, or a path that matches no indexed file, is a usage error (exit 3), so a typo cannot pass the gate.
- It lists elements in no non-overlay layer that a dependency can involve, within the scope's paths. These are symbols (top-level ones, unless
deps:- There is one entry per stored edge, at the finest element the edge names (a method, or a file for an import), with the decision
mapx checkmakes before any baseline. - Without
to, it lists every decided edge offrom, uncovered ones included. - An edge counts for each other layer of its target, as in the report's
layerEdges. An edge into an element in both Domain and Shared is a Domain → Shared edge, sodeps L Llists nothing.
- There is one entry per stored edge, at the finest element the edge names (a method, or a file for an import), with the decision
unused:totalcounts every allowance of the expanded policy, after+,*and!.rulesays how the pair is granted:explicit,+L(brought by that+) or*.typesis null when the allowance covers every type in scope, andseverityiswarnfor an advisory allowance.usescounts the stored edges the allowance decides: allowed ones, and the warnings an advisory allowance reports. A private-layer violation does not use the allowance of its layer.
changed:- Paths are workspace-relative. The index is read as it is, never re-scanned, so a file it does not have is
indexed: false. - Only in-scope files contribute to
layers. --since <ref>adds the files git reports changed since the ref: committed, staged, unstaged and untracked, both paths of a rename,.mapx/excluded. Git is asked in the workspace folder and in every registered repo inside it that has the ref, so a workspace whose root is no repository works through its repos. It is an error only when no repo has the ref.- A dependent is another file with an edge
mapx checkevaluates into one of the files.
- Paths are workspace-relative. The index is read as it is, never re-scanned, so a file it does not have is
allowed
allowed is the agent pre-flight check. Its answer is what mapx check reports for the same pair, in severity and exit code: both decide through the same function (decideEdge). Each end is a layer name or an element.
- Observed dependencies. When the index already connects the two elements, their stored edges are decided as they are, with their types and verifiability, so
policy.inferredapplies. Without--type, every type they have is decided. A file stands for everything it declares, and a top-level symbol for its members, as findings name them. - Hypothetical dependencies. Otherwise the dependency is hypothetical and verified, over
--type, or without one over every type inscope.types. - Decisions. There is one decision per edge type and source layer (AR-12: every layer of the source is checked).
- The answer is the strictest decision: by severity (error, warn, info), then a violation before anything else. One error-level decision makes it exit 1, even when another source layer or edge type is only advisory.
{ from, to, edgeType, allowed, rule, severity, reason, exitCode,
decisions: [{ edgeType, observed, verifiability, sourceLayer, allowed, rule, severity, via?, violatedLayers?, reason }],
allowedTypes, allowedLayers,
alternatives: [{ layer, total, symbols: [{ element, kind, symbolKind?, file, match: supertype|name }] }] }from,to:{ query, kind: layer|file|symbol, element?, file?, layers, overlays, private, inScope }.allowed: no decision is a violation, of any severity.exitCode: 1 when a decision is an error-level violation, asmapx checkexits.rule: one of the following.allowed(withvia:explicit,+Lor*).same-layer.uncovered: the target is in no layer. It is allowed, with severitywarn.overlay: the target is an overlay layer that the source layer does not deny.disallowed-layerorprivate-layer: a violation, with severityerror. It iswarnfor an advisory allowance, or for an inferred edge underpolicy.inferred: "warn".unassigned-source: the source is in no layer, so no rule applies.out-of-scope: an end outside the scope's paths, or a--typeoutsidescope.types.
decisions[]:edgeType,verifiabilityandsourceLayerare null only for an answer that involves no edge type (unassigned-source, or an end out of scope).observedis true when the decision is on edges the index has.
allowedTypes: the edge types decided without any violation.allowedLayers: the layers every source layer may depend on, through full (non-advisory) allowances for--type.alternatives(M5-14, AR-19): for a refused dependency on a file or symbol, the layers that already expose a matching symbol, in configuration order. The layers are the source's own layers andallowedLayers. A file target stands for its top-level symbols, and a member for its type.- Matches. A symbol matches as a
supertype(a class or interface the target extends or implements, transitively, through stored edges) or byname. A name match is a top-level symbol with the target's name, or with the end of it, at least 4 characters long:OrderRepositoryforSqlOrderRepository. - Eligible symbols. A symbol is listed only when
allowedfrom the same source to it (for--type, or every type in scope) is itself allowed: layers, private parts,@internalmarkers and scope all apply. - The cap. At most 10 symbols per layer are listed;
totalcounts them all. - When it is empty. An allowed answer, or a layer target, has none. The text output prints them as
instead, in <layer>: ….
- Matches. A symbol matches as a
- No baseline. No baseline applies: a baselined violation is still reported.
- Elements must be in the index. A file that does not exist yet cannot be named, so name its layer instead.
MCP tools
An agent reaches the same engine through the MCP server (mapx serve, AR-19). Each tool answers from the functions mapx check and mapx arch use, so the CLI and MCP agree. The arguments and result shapes are in mcp-integration.md.
| Tool | CLI equivalent | Example arguments |
|---|---|---|
mapx_arch_check | mapx check --format json | { "rule": "disallowed-layer", "limit": 1 } |
mapx_arch_layers | mapx arch layers, mapx arch unassigned | {}, { "layer": "Domain", "why": true }, { "unassigned": true } |
mapx_arch_allowed | mapx arch allowed | { "from": "OrderController", "to": "SqlOrderRepository" } |
mapx_arch_explain | mapx arch which (and allowed with to) | { "element": "Order", "layer": "Domain" } |
mapx_arch_init | mapx arch init --json | { "preset": "clean" } (a dry run unless write: true) |
mapx_arch_validate_config | mapx arch validate-config --format json | {} |
Results are JSON. A list is capped by limit, and the result says so: mapx_arch_check with { "rule": "disallowed-layer", "limit": 1 } on the shop returns one finding with "exitCode": 1, "total": 4, "limit": 1, "truncated": true. A failure is a tool result with isError: true and the exit code as exitCode (3 for a configuration or usage error, 4 for an index problem), never a JSON-RPC error.
The pre-flight check an agent runs before adding an import:
mapx_arch_allowed { "from": "OrderController", "to": "SqlOrderRepository" }
→ { "allowed": false, "rule": "disallowed-layer", "severity": "error", "exitCode": 1,
"reason": "Controller may not depend on Infrastructure over import: no rule allows it",
"allowedLayers": ["Domain", "Shared"],
"alternatives": [{ "layer": "Domain", "total": 1, "symbols": [{ "element": "src/domain/OrderRepository.ts#OrderRepository", "symbolKind": "interface", "match": "supertype", … }] }], … }The answer names what the rules allow instead: the OrderRepository interface in Domain, which SqlOrderRepository implements.
MapX's own rules
MapX checks its own src/ in CI (M5-18). The configuration is .mapx/architecture.jsonc, with a comment on each layer and rule. It is the module map of docs/research/deptrac/03-mapx-gap-and-integration.md §3.9, with the Arch layer added:
| Layer | Path | May use |
|---|---|---|
| Entry | src/{main,index,cli,mcp,ui-server,ui-events,ui-arch,branding}.ts | Agents, Arch, Exporters, +Core |
| UI | src/ui/** (the dashboard's browser client) | nothing |
| Agents | src/agents/** | Languages |
| Arch | src/arch/** | +Core, Exporters |
| Exporters | src/exporters/** | Core |
| Core | src/core/** | Frameworks, Parsers, Languages, Arch |
| Frameworks | src/frameworks/** | nothing |
| Parsers | src/parsers/** | Languages |
| Languages | src/languages/** | nothing |
| Shared | src/{types,version}.ts | every layer may use it ("*": ["Shared", "Packages", "!Outside"]) |
| Packages | { "package": "*", "dev": false }: npm packages package.json lists for production (M5-24) | every layer may use it; a dev-only package is in no layer, so importing one from src/ is uncovered and fails the check |
| Outside (overlay) | {tests,scripts}/** | no layer may use it: production code never imports tests, fixtures or scripts |
- Scope:
src/**, plustests/**andscripts/**as targets only (theOutsideoverlay, which every layer denies; a file in no other layer is never a source, so tests' and scripts' own dependencies are not judged), every dependency type (the defaultscope.types: imports, calls, instantiations, inheritance and type references), andpolicy.inferred: "ignore". M5-18 judged imports only while names imported fromnode:pathor the DOM resolved to project symbols; since M2-08-R3, M2-12 and M2-13 removed those edges, calls are judged too (M5-18-R1).tests/fixtures/**is in scope, so production code importing a fixture is judged (M5-18-R2; M5-18-R1 had excluded it while a destructured local or a loop variable linked a same-named fixture class or function, fixed by M2-25 and M2-26). - Core and Arch depend on each other on purpose: with a configuration,
core/architecture-analyzer.tstakes its layer violations from the rules (see Smells).mapx arch --smellsreports this as alayer-cycle. - The baseline,
.mapx/architecture.baseline.json, holds the 8 imports that broke the rules when they were written, and the 29 calls behind them, so CI fails only on new ones: Languages → Parsers (languages/installer.tsimports and callsfindAssetRootinparsers/assets.ts), Agents → Parsers (agents/generator.tsimportsparsers/static-file-parser.ts), Parsers ↔ Frameworks (the Vue extractors import and callmaskCodeandmatchBracketfromframeworks/http/client-calls.ts, andframeworks/detectors/vue-router.tsimports the Vue extractors), Frameworks → Core (client-calls.tsimportscore/js-aliases.ts) and Core → Exporters (core/workspace-summary.tsimports theLLMExportertype). A call has a baseline entry of its own, per calling function, so a new call of a baselined helper elsewhere fails CI. Fixing one leaves a stale entry, and the check fails until the baseline is regenerated:mapx check --generate-baseline. - Only the configuration and the baseline are tracked. The rest of
.mapx/, the index included, stays ignored (D030) through a narrow negation in.gitignore.
The CI job (Architecture in .github/workflows/ci.yml) builds MapX, indexes the checkout and runs the check:
- run: npm run build:npm
- run: |
node dist/main.js init . --no-agents --no-suggestions --no-mcp-configs
node dist/main.js scan .
- run: node dist/main.js check --fail-on-uncovered --format github
- run: node dist/main.js check --fail-on-uncovered --report-skipped --format sarif -o mapx-architecture.sarif
continue-on-error: true
- uses: actions/upload-artifact@v4
with:
name: architecture-sarif
path: mapx-architecture.sarif--format github annotates a new violation on the pull request, and --fail-on-uncovered also fails a dependency on src/ code in no layer (a new top-level folder, say), so the layers keep covering the source. The SARIF report is kept as an artifact rather than uploaded to code scanning, which would need security-events: write, a permission pull requests from forks never get. A forbidden import fails the job:
[error] src/frameworks/utils.ts:3 disallowed-layer: src/frameworks/utils.ts must not depend on src/exporters/llm-exporter.ts (Frameworks on Exporters) [import]
::error file=src/frameworks/utils.ts,line=3,title=disallowed-layer::src/frameworks/utils.ts must not depend on src/exporters/llm-exporter.ts (Frameworks on Exporters) [import]
Violations: 1 (1 error, 0 warn) Uncovered: 0 Skipped: 8 Allowed: 227 Warnings: 0 Errors: 0To run it locally: npm run build:npm, node dist/main.js init . --no-agents --no-suggestions --no-mcp-configs, node dist/main.js scan ., then node dist/main.js check (or check --sync after editing).
