Architecture drift detection for JavaScript and TypeScript codebases.
Live demo: https://jay-m007.github.io/archguard/ (runs in your browser, nothing is uploaded)
ArchGuard reads a project's source code, builds a dependency graph of every file, and finds the structural problems that make codebases hard to change: circular dependencies, imports that break your intended layering, oversized "god files", and dead code. It gives the project a health score so you can see architecture improve or decay over time.
College project by @jay-m007. It rebuilds the idea behind CodeSee (a code-visualisation startup that shut down in 2024): static diagrams go stale, so ArchGuard focuses on detecting drift continuously.
$ archguard analyze .
ArchGuard v0.1.0 ~/projects/my-next-app
config: defaults (Next.js preset) · aliases: tsconfig.json · 54 ms
Health 42/100 grade D (15.5 drift points across 11 files)
11 files · 10 internal imports · 4 external packages · 1 unresolved
Circular dependencies (0)
✓ none
Layer violations (3)
✗ routes -> api src/app/dashboard/page.tsx:1 → src/app/api/users/route.ts
✗ components -> api src/components/Card.tsx:1 → src/app/api/users/route.ts
✗ lib -> components src/lib/format.ts:1 → src/components/Card.tsx
...
| Phase | Scope | Status |
|---|---|---|
| 1 | Core engine + CLI: parsing, alias resolution, cycles, layers, metrics, score, JSON report | ✅ Done |
| 2 | Dashboard: interactive dependency graph, issues table, history charts (Next.js + React Flow) | ✅ Done |
| 3 | GitHub Action: comments on PRs with newly introduced violations, optional LLM explanations | ✅ Done |
| 4 | Drift over time: score every commit in git history and chart it | ✅ Done |
git clone https://github.com/jay-m007/archguard.git
cd archguard
npm install
npm run build
npm test
# analyse any project
node packages/cli/dist/index.js analyze /path/to/your/project
# create a config to customise layers and rules
node packages/cli/dist/index.js init /path/to/your/projectarchguard analyze [path] [options]
-c, --config <file> Config file (default: <path>/archguard.config.json, else the Next.js preset)
-o, --out <file> JSON report location (default: <path>/archguard-report.json)
--no-report Don't write a JSON report
--json Print the JSON report to stdout
--fail-on <level> error | warning | none (default: error). Exit code 1 if issues at that level exist
--limit <n> Items listed per section (default: 10)
archguard init [path] Write a starter archguard.config.json
Exit codes: 0 passed · 1 issues at the --fail-on level · 2 could not run. That makes it usable in CI.
npm run build:dashboard # static site in apps/dashboard/out
npx serve apps/dashboard/out # or any static file serverOpen it and drop in archguard-report.json (from archguard analyze) and, optionally, a history file (from archguard history). Nothing is uploaded; the files are read in your browser. Add ?demo to the URL to explore a bundled excalidraw example.
- Graph: files or folders (with a depth control) laid out in layers with dagre. Cycles are red, layer violations dashed orange. Pick one cycle in the sidebar to see just its files, click a file to inspect its imports, importers and issues, click a folder to open it.
- Issues: sortable, searchable table of every violation. Click a row to jump to the file in the graph.
- History: health score per commit, small charts for cycles, layer violations and god files, and the commits that moved the score most. A crosshair tooltip shows each commit.
- Light and dark themes, keyboard navigation, reduced-motion support, works on a phone.
archguard history . --commits 30 # the last 30 first-parent commits
archguard history . --commits 30 --spread # 30 commits spread evenly across all history
archguard history . --ref main -o history.jsonEach commit is exported with git archive into a temporary folder, so your working tree is never touched. Every commit is scored with the current config, so the numbers are comparable. The output lists the biggest regressions and improvements.
Fail or comment on pull requests only for problems the PR introduces, so an old codebase doesn't block new work.
# .github/workflows/archguard.yml
name: ArchGuard
on: pull_request
permissions: { contents: read, pull-requests: write }
jobs:
archguard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: jay-m007/archguard/action@main
with:
fail-on: error # only NEW errors fail the check
openai-api-key: ${{ secrets.OPENAI_API_KEY }} # optionalIt analyses the PR head and its base commit, compares the two reports by each violation's stable key, and posts one comment (updated in place on later pushes) with the health change and the new and resolved issues. It also adds inline annotations and a job summary. With an openai-api-key (any OpenAI-compatible endpoint via openai-base-url), each new issue gets a short plain-English explanation and fix suggestion. The LLM is optional and never decides pass or fail. On pull requests from forks, where the token can't write comments, it falls back to a warning. Outputs: health, grade, health-delta, introduced, report-path.
You can run the same comparison locally: archguard diff base.json head.json --markdown [--explain].
| Check | Severity | Meaning |
|---|---|---|
| Circular dependency | error | A group of files that (indirectly) import each other. This causes undefined imports at runtime, breaks tree-shaking, and makes modules impossible to change in isolation. |
| Layer violation | error | An import that breaks a rule, e.g. a reusable component importing an API route handler. |
| God file | warning | A file that imports too many modules, is a high-traffic hub (high fan-in and fan-out), or is too long. |
| Orphan file | info | A file nothing imports that the framework doesn't load either. Usually dead code. |
| Unresolved import | listed | A relative path or alias that points to a file that doesn't exist. |
discover files ──► parse to AST ──► resolve specifiers ──► dependency graph ──► analyzers ──► score ──► report
(fast-glob) (TS compiler) (TS module resolver) (adjacency list) │
├─ Tarjan SCC → cycles
├─ layer rules → violations
└─ coupling metrics → god files, orphans
Each file is parsed into an Abstract Syntax Tree using the TypeScript compiler API, which also understands plain JavaScript and JSX. ArchGuard walks the tree and collects every module reference:
import x from, import "./side-effect", export * from, export { a } from, import(), require(), import x = require() and import("./x").Type.
Working on the AST means commented-out imports and strings that only look like imports are ignored. It also lets ArchGuard tell type-only imports (import type, import { type A }) apart from runtime imports. Type-only imports are erased during compilation, so by default they can't create a runtime cycle.
"@/components/Button", "../utils" and "react" are resolved with TypeScript's own module resolver, configured from the project's tsconfig.json or jsconfig.json. That gives correct handling of paths aliases, baseUrl, extends chains, extensionless imports, index files and package.json exports, the same way editors and Next.js resolve them. Each import becomes an internal edge, an external package, an asset (CSS, images…), or unresolved.
Files are nodes and imports are directed edges (A → B means "A imports B"), stored as an adjacency list (Map<file, Set<file>>). That's O(V + E) memory, and each node's neighbours can be looked up in time proportional to how many there are.
A strongly connected component (SCC) is a maximal set of nodes where every node can reach every other node. Every SCC with more than one file, or a file that imports itself, is a circular dependency.
Tarjan's algorithm finds all SCCs in a single depth-first search:
- Give every node an
indexin the order it is first visited, and push it onto a stack. - Track
lowlink[v]: the smallest index reachable fromvthrough its DFS subtree, plus at most one back edge to a node still on the stack. - When
vfinishes andlowlink[v] == index[v],vis the root of an SCC. Pop the stack down tov; those nodes form the component.
It runs in O(V + E). ArchGuard's implementation is iterative (an explicit work stack instead of recursion), so a 20,000-file import chain cannot overflow the call stack; there's a test for exactly that. For each cycle, a breadth-first search inside the component then finds the shortest concrete loop (a.ts → b.ts → a.ts), so developers see the easiest edge to cut. It runs one BFS from every file in the component. For tangles of more than 300 files it falls back to a single BFS, which still gives a valid loop but not necessarily the shortest.
Files are assigned to layers by glob patterns, where the first match wins. With no config, ArchGuard uses a Next.js preset:
| Layer | Folders (root or src/) |
Must not import |
|---|---|---|
api |
app/api/**, pages/api/** |
— |
services |
app/**/actions/**, app/**/actions.ts (Server Actions), services/**, server/**, actions/** |
routes, components, hooks |
routes |
app/**, pages/** |
api |
components |
components/** |
routes, api |
hooks |
hooks/** |
routes, api, components |
lib |
lib/**, utils/**, helpers/**, config/**, constants/**, types/** |
everything above |
Server Actions colocated under app/ count as services, not routes: components are meant to import them, so that isn't flagged. Rules can be disallow lists (blacklist) or allow lists (whitelist).
For each file, ArchGuard computes fan-in (afferent coupling, Ca: how many files depend on it) and fan-out (efferent coupling, Ce: how many files it depends on). From those it calculates Robert C. Martin's instability:
I = Ce / (Ca + Ce) 0 = stable (e.g. lib/utils.ts) 1 = unstable (e.g. a page)
In a healthy architecture, dependencies point from unstable code toward stable code.
driftPoints = 10 × (files in cycles) + 5 × (layer violations) + 3 × (god files) + 0.5 × (orphans)
density = driftPoints / (number of files)
health = 100 / (1 + density)
Dividing by the number of files makes a 20-file project and a 2,000-file project comparable. The 100 / (1 + x) curve gives 100 for a clean codebase and 50 at one drift point per file, and it never goes negative. Grades: A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40, F below that.
Run archguard init to generate archguard.config.json, then edit it:
{
"exclude": ["**/*.test.ts", "scripts/**"],
"layers": [
{ "name": "ui", "patterns": ["src/components/**"] },
{ "name": "domain", "patterns": ["src/domain/**"] },
{ "name": "infra", "patterns": ["src/db/**", "src/api/**"] }
],
"rules": [
{ "from": "domain", "allow": [], "reason": "Domain logic must be framework-independent." },
{ "from": "ui", "disallow": ["infra"], "reason": "UI talks to infra through the domain layer." }
],
"thresholds": { "maxFanOut": 15, "hubFanInOut": 10, "maxLoc": 500 },
"ignoreTypeOnlyImports": true,
"reportOrphans": true
}Arrays you provide replace the defaults, except exclude, which adds to the built-in excludes (node_modules, .next, *.d.ts…). Build-output folders (dist, build, out, coverage) are skipped only when they sit next to a package.json, so a source folder like src/lib/build/ is still analysed.
- Only the root
tsconfig.json/jsconfig.jsonis read. In a monorepo, aliases defined in a nested package's tsconfig aren't applied, so analyse that package's folder directly. - A violation's identity (
key) for a cycle is its whole set of files. If a cycle grows by one file, comparing two reports shows it as one resolved cycle plus one new cycle. - Shadowed
requireis detected per file, not per scope (a deliberate simplification). - History scores old commits with today's config and today's tsconfig, so a project that restructured heavily may show some noise.
- The dashboard draws up to 400 files at once; larger graphs switch to the folder view.
- Imports built from variables (
import(\./${name}`)`) can't be followed statically and are skipped.
| Project | Files | Imports | Time | Result |
|---|---|---|---|---|
| shadcn/taxonomy | 129 | 258 | 0.4 s | A (91). No cycles. Finds unused shadcn/ui components and an unbuilt contentlayer/generated alias. |
| vercel/commerce | 66 | 120 | 0.2 s | A (96). One hub file: lib/shopify/index.ts. |
| excalidraw | 702 | 3,732 | 2.2 s | F (24). 7 cycles, the largest spanning 105 files through barrel index.ts re-exports. Each was verified by hand. |
packages/
core/ # the analysis engine (library)
src/
analyze.ts # pipeline orchestrator
config.ts # defaults (Next.js preset), loading, validation
discover.ts # file discovery
extract.ts # AST import extraction
resolve.ts # specifier → file resolution (tsconfig aware)
graph.ts # adjacency-list dependency graph
analyzers/
cycles.ts # Tarjan SCC + shortest-loop BFS
layers.ts # layer matching + rule checking
metrics.ts # fan-in/out, instability, god files, orphans
score.ts # health score
diff.ts # compare two reports (for PR checks)
test/ # Vitest unit + fixture tests
fixtures/ # small sample projects: cycles, Next.js app, plain JS
history.ts # analyse many commits via git archive
markdown.ts # PR comment rendering
llm.ts # optional explanations (OpenAI-compatible)
cli/ # `archguard` command-line tool
action/ # GitHub Action (bundle committed in dist/)
apps/dashboard/ # Next.js static dashboard (React Flow)
docs/screenshots/
npm test # run all tests
npm run test:watch # watch mode
npm run typecheck # strict TypeScript check (core, cli, dashboard)
npm run build # compile core, cli and the action bundle
npm run build:dashboardMIT


