archfence
A polyglot architecture linter. One YAML file describes your layers and which layer may depend on which; archfence parses the code with tree-sitter and fails the build when an import crosses a line it should not.
Checks: layer dependency rules, external-package bans, layer cycles, vertical slices, and API-contract-first (contract layer purity, routers speak contract types, OpenAPI drift). Supported today: C#, Python, TypeScript/JavaScript, Dart/Flutter, Rust. Adding a language means writing one extractor (about 80 lines) that reports what a file provides and what it imports in that language's own vocabulary. Everything else is shared.
Install
pip install git+https://github.com/mushhzz/archfence@v0.1.0
archfence warm # fetch the tree-sitter grammars once (also what CI should cache)
archfence init # draft archfence.yml from the tree
archfence scan # check it
Grammars come from tree-sitter-language-pack, which downloads each one on first use into
~/.cache/tree-sitter-language-pack. Run archfence warm on a fresh machine or as a cached CI
step so the first scan does not need the network.
CI and pre-commit
examples/github-action.yml is a workflow that fails on errors and uploads SARIF so findings
show up on the pull request. examples/pre-commit-config.yml wires the hook; this repository
ships .pre-commit-hooks.yaml, so repo: https://github.com/mushhzz/archfence with id: archfence
is all a project needs.
A project whose root and languages match no files is a configuration error (exit 2), not a
clean scan.
Configure
projects:
backend:
extends: clean-architecture-dotnet # optional preset; you override paths
layers:
domain: { paths: ["src/MyApp.Domain/**"] }
application: { paths: ["src/MyApp.Application/**"] }
infrastructure: { paths: ["src/MyApp.Infrastructure/**"] }
webapi: { paths: ["src/MyApp.WebApi/**"] }
ignore: ["**/Migrations/**"]
web:
languages: [dart]
root: web
layers:
domain: { paths: ["lib/domain/**"], can_only_import: [],
cannot_import_external: ["package:flutter/", "dart:io"] }
data: { paths: ["lib/data/**"], can_only_import: [domain, core] }
presentation: { paths: ["lib/presentation/**"], cannot_import: [data] }
core: { paths: ["lib/core/**"] }
Per layer:
| key | meaning |
|---|---|
paths |
globs, relative to the project root. **, * and {a,b} work. The most specific matching layer wins. |
cannot_import |
layers this layer must not depend on |
can_only_import |
the complete allow-list of layers (an empty list means "nothing but itself") |
cannot_import_external |
prefixes of third-party names that are forbidden here (Microsoft.EntityFrameworkCore, package:flutter/, sqlalchemy, tokio::) |
can_only_import_external |
allow-list version of the above |
Per project: languages, root, ignore, source_roots (Python only), no_cycles
(error, warning or off; default error), strict (promote every warning to an error),
allow (waivers, below) and extends (a preset from archfence/presets/:
clean-architecture-dotnet, flutter-clean-bloc, python-layered, rust-hexagonal).
Severity
Every rule has a severity. Errors fail the build; warnings are reported and exit 0.
layers:
domain:
paths: ["src/*.Domain/**"]
can_only_import: [] # error (the layer default)
cannot_import_external:
Microsoft.EntityFrameworkCore: error # per-rule severity
System.Text.Json: warning
services:
paths: ["src/services/**"]
severity: warning # default for every rule on this layer
cannot_import: {adapters: error} # ...which a single rule can override
The shipped presets treat layer-to-layer rules (the Dependency Rule itself) as errors and
framework bans as warnings, because strict Clean Architecture forbids them but most teams
tolerate some. Set strict: true on a project to make the purist reading the enforced one.
Waivers
Known, accepted exceptions are declared with a reason. The reason is mandatory so the exception documents itself.
allow:
- path: "lib/presentation/legacy/**"
layers: [data]
reason: "pre-BLoC screens, tracked in ARCH-12"
- path: "src/App.Domain/Json/**"
external: [System.Text.Json]
reason: "converters that the enum attributes need"
- path: "lib/core/router/**"
rules: [layer-cycle]
reason: "go_router: the router names screens and screens navigate via the router"
layers, external and rules each narrow the waiver; omit all three to waive everything
under path. archfence scan --show-waived lists what a waiver is currently hiding.
Vertical slices
slices:
roots: ["app/features/*"] # each child directory is a slice
shared: ["app/core/**"] # code every slice may use
shared_only: false # true: a slice may import nothing outside itself and `shared`
allow: { reports: [users] } # explicit exceptions, slice -> slices it may import
severity: error
shared_imports_slices: error # shared code depending on a slice
Rules: slice-coupling (a slice imports a sibling), shared-imports-slice, and with
shared_only, slice-escape. Slices coexist with layers; both are checked.
API contract first
contracts:
layer: schemas # the contract layer; it may import nothing (or only `allow`)
consumers: [routers] # every route-declaring file here must import contract types
allow: [domain]
openapi:
path: docs/contracts/openapi.yaml
prefix: /api/v1 # mounted in front of every code route
match: both # operation_id | path | both
ignore: ["/health*"]
severity: warning
Routes are read from source: FastAPI, Starlette and Flask decorators in Python, including a
module-level APIRouter(prefix=...), and ASP.NET [Route] / [HttpGet] attributes in C#.
archfence routes lists what was found. Path parameters are normalised, so {user_id}, {id},
<int:id> and :id all agree.
The comparison is deliberately asymmetric, because contract first means the spec leads:
| rule | meaning | default |
|---|---|---|
route-not-in-contract |
code declares a route the spec does not know | severity (warning) |
route-contract-mismatch |
same operation id, different method or path | severity |
contract-pending |
the spec has it, the code does not yet: expected between agreeing and implementing | pending (info) |
openapi-generated |
the spec carries framework telltales (FastAPI's HTTPValidationError, default title); a dump of the code is documentation, not a contract |
warning |
route-before-contract |
with history: true: git says the operation id reached code before it reached the spec |
history_severity (warning) |
info is a fourth severity: reported, counted separately, never fails a build and never promoted
by strict. The history check runs one git log -p over the spec and one over the project, so
it costs a few seconds; it compares commit times, so on a repo that squash-merges it can only see
contracts added in a later PR than the code, not later commits within one PR.
init proposes both blocks when it sees a features/ directory with several children, or a
contract-named layer plus an openapi.* file, and guesses the mount prefix from the spec's paths.
Baseline
Adopting a linter on a codebase with history should not require a big-bang cleanup.
archfence baseline # writes archfence-baseline.json with today's violations
archfence scan # now fails only on violations that are not in the baseline
archfence scan --no-baseline
Baseline entries ignore line numbers, so edits elsewhere in a file do not resurrect old violations. Delete entries from the file as you fix them, or regenerate it.
Starting from nothing
archfence init # drafts archfence.yml: layers, rules, and what those rules flag today
init finds the source roots (each pyproject.toml, Cargo.toml, .csproj, pubspec.yaml),
proposes layers from the directory tree, prints the layer-by-layer import matrix, and drafts rules
from two sources:
- What the names mean.
domainsits insideservices, which sits insiderouters;infrastructure,dataandpresentationare siblings in the outer ring; nothing importstests. When an inner layer such asdomainorusecasesis named, the draft also adds the Clean Architecture expectation that UI layers do not import adapter layers directly. - What the code already does. For layers whose names carry no convention, an arrow the code draws only one way (at least three imports, never the reverse) becomes a rule.
Every drafted rule is a warning, and no_cycles stays off if the layer graph has cycles
today, so the first scan reports without failing. init ends by running those rules and listing
what they flag right now. Delete the rules you disagree with, promote the ones that should fail a
build to error, and archfence baseline records the leftovers.
Commands
archfence init [path] # draft a config and show the dependency matrix
archfence warm # load every grammar now
archfence scan [-v] [-f text|json|sarif] [-o file] [-p project] [--warn-only] [--baseline f] [--show-waived]
archfence baseline # record current violations so only new ones fail
archfence routes # HTTP routes found in source, with the contract prefix applied
archfence deps [--files] # layer-to-layer dependency counts, optionally every edge
archfence unresolved # imports that are not project code (i.e. your external deps)
scan exits 1 on any error-severity violation, so it drops straight into CI. The SARIF output uploads to
GitHub code scanning and annotates the PR.
How resolution works
Each extractor emits logical names. C# files provide their declared namespaces and import
using directives. Python files provide dotted module paths and import absolute dotted names
(relative imports are resolved). TypeScript and JavaScript files provide their extension-less
path (and their directory, for index.*) and import ES imports, re-exports, require() and
dynamic import(), with tsconfig.json paths and baseUrl applied, including extends;
bare specifiers are packages. Dart files provide their path and package: URI and import
resolved URIs. Rust files provide crate::module paths and import use paths and mod
declarations, with self/super resolved, including inside inline mod tests blocks.
The core then does longest-prefix lookup: using A.B.C.Type resolves to the file declaring
namespace A.B.C, or A.B if that is all there is. When several crates or packages share
a name, a file's imports resolve within its own crate first. Anything that does not resolve
is an external dependency and is only subject to the *_external rules.
Example: kintsugi
examples/kintsugi.yml lints a real repository with an ASP.NET Core Clean Architecture
backend, a Flutter Clean Architecture + BLoC UI, and three Rust agents, in one run of about
a second for 640 files.
Tests
pip install -e '.[dev]' && pytest
Metadata
Release files for archfence 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| archfence-0.1.0.tar.gz | 54.3 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| archfence-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 102.6 kB
Release files / archfence-0.1.0.tar.gz
| Download URL | archfence-0.1.0.tar.gz |
|---|---|
| Size | 54.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b7db2c154c6e97f34a32a82559527c2aaadcf8a1b6a32e798f5589d9e1ab2b06
|
|
BLAKE2b-256 checksum How to use checksums |
d670018eb13bd223d25b482140e26d8c954f49897eae92f1e0ba9ce25269acab
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / archfence-0.1.0-py3-none-any.whl
| Download URL | archfence-0.1.0-py3-none-any.whl |
|---|---|
| Size | 48.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3dadc778a8ae47115d0dc0be48a84b96d3c7353197f2980a3d4e2bc8a23abc3d
|
|
BLAKE2b-256 checksum How to use checksums |
e443db44b0f33a225bc44e03d0fc13908bd00dd49b059ff8e2dc853fd270cb97
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logSecurity for archfence 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for archfence-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.