@sidcorp/archmap
v0.1.5
Published
Architecture conformance from a declared manifest: layers, forbidden, independence, boundary and fan-out contracts checked against the real dependency graph
Readme
archmap
Check a codebase against the architecture it declares.
You write .arch.json — which paths are which module, and how those modules may depend on each
other. archmap check builds the real dependency graph and reports where code and declaration
disagree.
It ships no rules of its own.
┌─────────────┐
.arch.json ─────►│ │
│ archmap │──► 0 clean
real import ────►│ check │──► 1 blocking violations
graph │ │──► 2 could not run
└─────────────┘Why not a linter. A linter judges a statement in one file. This judges a relationship — whether a file's position in the structure obeys what the project declared. A rule like "B must not depend on A" is broken by a path, not by an import:
A ──────► shared ──────► B each edge: legal
▲ │
└────────────────────────┘ the path: a violationno-restricted-imports matches path strings and sees only the edges. That is the whole reason
archmap exists — if a path matcher can catch it, archmap is redundant.
This is a Software Reflexion Model: declare a model,
map it onto the source, compute the difference. SPEC.md is the contract.
NORTH-STAR.md is what it will and will not become.
Status: early. TypeScript works end to end; Go and PHP providers are specified, not written.
SPEC.md marks every section [declared] that is not live — nothing silently passes a check it did
not run.
Quick start
npm i -D @sidcorp/archmap dependency-cruiser{
"version": 1,
"modules": {
"api": ["apps/api/**"],
"engine": ["packages/engine/**"],
"core": ["packages/core/**"]
},
"tests": { "globs": ["**/*.test.ts"], "relax": ["forbidden", "boundary"] },
"contracts": [
{
"id": "core-is-pure",
"type": "forbidden",
"from": "core",
"to": "*",
"status": "locked",
"description": "core defines the contracts everything else depends on, and depends on nothing itself."
}
]
}npx archmap checkDo not invent the model. Run with "contracts": [] first, read the coverage number and the
module graph you actually have, then write contracts that are already true — those lock
immediately at zero debt. A contract nobody has been burned by has not earned its place.
What a run looks like
archmap · 722 files · 1477 edges · 6 contracts
mapped 99.9% (source 507 · test 206 · unmapped 1 · generated 2)
690 unresolvable of 2167 possible edges (31.8%) — not counted as violations
WARNING 690 of 2167 possible edges (31.8%) could not be resolved — above 15%.
A contract over this graph can pass because its edges are ABSENT, not because
the code obeys it. Check that tsconfig `paths` aliases resolve, and use
--strict-unresolvable to make this fail the build.
0 blocking · 0 advisory · 0 frozen by baseline · 0 absent
over a graph with holes — 31.8% of edges unresolvable (threshold 15%)The ratio is printed because the count alone is not a signal: a green verdict over a graph whose edges are mostly missing looks exactly like a green verdict over compliant code.
Contracts
| Type | Says |
|---|---|
| layers | ordered tiers; a module may depend on later entries, never earlier |
| forbidden | from must not depend on to; "*" means nothing outside itself |
| independence | these modules must not depend on each other — checked transitively |
| boundary | a module's public surface; cross-module edges must land on it |
| fan-out | a file may reach at most N distinct modules |
| absence | this dependency should exist — report only, never fails the build |
Six types, and that is the whole list. Every type the schema accepts has a real evaluator; a type without one fails to load and exits 2.
fan-out answers "is too much crammed in here" without counting lines — 900 lines of one coherent
state machine is fine, 200 lines of HTTP parsing plus business rules plus SQL is not, and a line
threshold is silent on the second.
Where it runs
The CLI is the product. Everything else is a thin caller, and none of them knows what a violation is.
| Caller | Command | Role |
|---|---|---|
| CI | archmap check | the authority |
| build / test | an npm script, a Make target | recommended |
| pre-commit | archmap check via husky or lefthook | convenience — whole tree, not the staged diff |
| agent, pre-write | the claude-plugin/ PreToolUse hook | blocks before the bytes land |
| editor | archmap check --json <file> | optional |
There is no staged-diff scoping, deliberately: scoping to the middle file of a transitive violation hides the very edit that completes it.
Installed per repo, never per device — a dev dependency or a vendored copy. A tool installed per machine only enforces rules for whoever installed it.
The vendored copy in .forge/archmap/ is what actually runs, so archmap doctor verifies its
contents, not its version stamp — a stamp cannot move on its own when a source module changes. A
copy with no MANIFEST.json reads as unverifiable, never clean. SPEC §10.5.
The pre-write hook
CI is right too late — by then the code is written, reviewed, often merged. The plugin denies a write that would introduce a locked violation, including one that exists only transitively:
pending edit ──► splice into the FULL graph ──► re-evaluate ──► new locked finding?
│ │
yes no
│ │
deny allowA scoped scan cannot do this — the write that completes a transitive violation often names neither end of it.
Add ./claude-plugin as a Claude Code plugin, then work in any repo with a committed .arch.json.
Escape hatch: ARCHMAP_HOOK=offMeasured end to end: 1.4s over 753 files, 2.0s over 2253 files, inside a 12s budget. A write
that adds no import exits before any of it. claude-plugin/README.md · SPEC §9.3, §10.2.
The graph as data
archmap check keeps only graph: { edgeCount } — a ~900 KB edge list would otherwise ride on every
per-file editor invocation. The graph is a separate, versioned surface other tools read:
archmap graph --json # the export document, indented
archmap graph --json --compact # one line, for a pipe
archmap graph # a human summaryOne list, one shape: an unresolvable import stays in edges with resolved: false and a null target
— never dropped, so a consumer cannot mistake could not see it for does not exist. complete:
false is the only thing standing between a consumer and asserting absence for a language whose
provider never started. Byte-identical across runs. SPEC §10.4.
Exit codes
| Code | Means | |---|---| | 0 | ran, nothing blocking | | 1 | ran, found blocking violations | | 2 | could not run — bad flag, unreadable manifest, a scope matching no files |
Never conflate 1 and 2 in CI. A scope that cannot be computed is not an empty scope.
What it will not do
- Resolve TypeScript itself.
dependency-cruiseralready handles tsconfig paths, pnpm layout and barrel re-exports. One exception: the pre-write hook resolves specifiers a pending edit introduces, because no resolver can see bytes not yet on disk. What it cannot place is reported as a blind spot, never assumed absent. - Catch every write. The hook matches
Edit,Write,MultiEdit. A write throughBash—sed -i, a heredoc,git apply— bypasses it entirely. CI is the authority; the hook is the cheap early answer, not a perimeter. - Count lines.
sizeis accepted by the schema and read by nothing. SPEC §5.1 is[declared]. - Judge naming or file placement. That is single-file lint, and admitting it turns the manifest into a program.
- Pretend an unresolvable edge is absent. Dynamic
import(), Laravel facades, container lookups by string class name are undecidable statically — counted and reported, never swallowed.
Development
npm test # golden corpus: synthetic graphs in, expected findings out