Skip to content

Repository files navigation

ArchGraph

ArchGraph turns a plain-text description of your codebase architecture into an interactive dependency graph. You describe your modules, submodules, and units (functions, classes, etc.) in two files — a layer hierarchy in JSON and unit descriptions in Markdown — and ArchGraph processes them into a single result.json that a frontend can render as a navigable graph with layer-violation highlighting.

You can find an example visualization of a fictional e-commerce codebase here — click around!
This is based on the descriptions in example_data/ with 5 modules, 19 submodules, 115 units, and a few intentional layer violations:

While other tools exist to visualize codebases written in a specific programming language, since ArchGraph operates on plain text descriptions, it is language-agnostic and can also be used to visualize your design docs — which is even more helpful in the era of AI, where you plan more and code less.

How it works

  1. layers.json — defines the module hierarchy and their allowed dependency directions as a two-level layer structure (root modules → submodules). Dependencies must flow strictly downward through the layers; anything that points back up or sideways is flagged as a violation.

  2. units.md — describes every unit (function / class) in plain prose. Dependencies are declared inline using backtick-at notation: `@submodule.UnitName`. ArchGraph extracts these automatically.

  3. prepare.py — reads both files, resolves all dependencies, validates them against the layer hierarchy, and writes result.json (by default next to the input files).

  4. Frontend — reads result.json and renders the interactive graph in the browser. Two visualization modes are available:

    • Box graph — submodules as boxes with unit lists; dependency arrows appear on click. Good for seeing the contents of each submodule at a glance.
    • Pearl graph — a vertical hierarchy with collapsible modules/submodules and arc-based dependency lines (inspired by Sonargraph). Green arcs on the left show valid top-to-bottom dependencies; red arcs show violations (on the right when pointing up, on the left when pointing down, e.g., between siblings in the same layer). Good for focusing on dependency flow and layer violations.

Project structure

archgraph/
├── example_data/
│   ├── layers.json       # example e-commerce layer hierarchy
│   └── units.md          # example e-commerce unit descriptions
├── frontend/
│   ├── index.html        # interactive graph visualization
│   └── viz/              # visualization modules (box-graph, pearl-graph, shared helpers + tests)
├── src/
│   ├── archgraph.py      # one-stop script: generate + prepare + serve the visualization
│   ├── generate.py       # auto-generate units.md from a codebase via tree-sitter
│   ├── languages/        # language-specific tree-sitter configs (one file per language + shared base)
│   └── prepare.py        # data processing pipeline
├── tests/                # tests for the Python scripts
└── sketch.md             # initial design notes (outdated, the code has evolved since)

Running the data pipeline

It is recommended to use uv to run the script in a virtual environment, but python instead of uv run in the below commands should work as well, since the script only depends on standard libraries.

Process a folder containing layers.json and units.md:

uv run src/prepare.py --input example_data

The output is written to example_data/result.json; pass --output FOLDER to write it elsewhere.

Intra-submodule unit ordering: By default, units within a submodule are expected to be listed low-level first (Python convention: define helper functions at the top, compose them below). If your codebase follows the opposite convention (e.g. Java/C#: high-level entry points first, helpers below), pass --high-level-units-first. If the order in units.md carries no meaning (e.g., Svelte components listed alphabetically), pass --sort-units to reorder the units within each submodule by their dependencies on each other (in the direction set by --high-level-units-first); only dependency cycles are then still flagged.

Strict mode: Unresolvable @ references are logged as errors and dropped from the graph. Pass --strict to make the run fail instead.

Run the tests:

uv run pytest
bun test frontend

Running the frontend

Open frontend/index.html in a browser served by any static file server. It reads the file given by the data URL parameter (relative to index.html), or result.json from the same directory if there is none.

# simple local server from the repository root, no installation required
python -m http.server 8000
# then open http://localhost:8000/frontend/?data=../example_data/result.json

Using ArchGraph with your own codebase

The two input files can describe any codebase. When designing a new software project, it always helps me to sketch out the individual functions and classes this way — and now I can also visualize the result to get a better feeling for what the final implementation would look like.

If you want to visualize an existing project, you can either generate the necessary input files using the generate.py script (using the tree-sitter library; currently available for Python and TypeScript/Svelte) or with the help of an AI agent.

Quick start: archgraph.py generates the input files, runs prepare.py, and opens the visualization in your browser (served locally until you press Ctrl+C). It accepts all options of generate.py and prepare.py described here.

uv run src/archgraph.py --input /path/to/your/package --output my_project_data/

Note that this regenerates layers.json, so it overwrites any changes you made to it. After adapting layers.json by hand, run only prepare.py and the frontend as described above.

File formats

layers.json — a two-level hierarchy. root_layers is a list of rows; within each row modules are siblings (no dependency allowed between them). submodule_layers optionally breaks each root module into its own sub-rows following the same rule. A module may also list itself as one of its submodules to hold units defined directly at the module level (e.g., in a Python __init__.py), like "core": [["core.service"], ["core"], ["core.db"]].

{
  "root_layers": [
    ["api"],
    ["services"],
    ["core"]
  ],
  "submodule_layers": {
    "services": [
      ["services.orders"],
      ["services.catalog"],
      ["services.payments"]
    ]
  }
}

units.md — one ### heading per unit, named submodule.UnitName. The body is a plain-English description. Declare dependencies inline with `@submodule.OtherUnit`.

### services.orders.create_order
Creates a new order record. Calls `@services.catalog.get_product` to validate
line items and `@core.db.execute` to persist the order.

Trailing characters inside the backticks are ignored (e.g., `@core.db.execute()`), and ### lines inside fenced code blocks are not treated as unit headings.

Auto-generating input files with generate.py

generate.py uses tree-sitter to deterministically extract public functions, classes, their docstrings, and cross-module dependencies from a codebase. Dependencies include calls, callbacks, decorators, base classes, and type annotations (script entry points like if __name__ == "__main__": blocks become a __main__ unit); they are followed through re-exports (e.g., in __init__.py) and through private helpers (whose own dependencies are attributed to their public callers). It writes both units.md and a draft layers.json into the specified output folder. The (sub)modules in the layers draft are arranged in rows by dependency flow: modules that depend on others (consumers) are placed at the top, each module that is depended upon (provider) directly below its lowest consumer, and isolated modules with no connections at the very bottom. Modules that don't depend on each other share a row (members of a dependency cycle get consecutive rows), rows hold at most --max-row-width modules, and modules within a row are ordered to reduce crossing dependencies. This gives a reasonable starting point that you should then adapt to represent the target architecture.

Supported out of the box (see below for how to add other languages):

  • Python — units are public functions and classes (private = _-prefixed).
  • TypeScript (.ts) — units are exported functions (incl. const f = () => ...), classes, interfaces, type aliases, and enums (private = not exported); JSDoc or // comments directly above a definition become its description; top-level code (e.g., mount(App, ...) in main.ts) becomes a __main__ unit. Files like x.svelte.ts and x.ts are merged into one module x. Limitations:
    • Only relative imports (incl. dynamic import('./x')) are resolved, not path aliases like $lib.
    • Wildcard re-exports (export * from './x') are not followed.
    • A definition exported separately (function f() {} + export { f }) counts as private, since only the export keyword on the definition itself is checked.
    • Exported constants that aren't functions (e.g., export const LIMIT = 5) are not units.
  • Svelte (.svelte) — each component is a single unit in the submodule of its directory (e.g., lib/components/Chip.svelte → lib.components.Chip); its description is the comment at the top of its <script>, and all its imports count as dependencies (since the markup may use them).

Files in hidden directories (e.g., .venv) are skipped.

uv run src/generate.py --input /path/to/your/package --output my_project_data/

Additional options:

  • --include-private - include private symbols (e.g., _-prefixed in Python; excluded by default)
  • --exclude "test_*,conftest*" - comma-separated glob patterns for filenames to skip
  • --full-docstrings - include full docstrings in unit descriptions (by default only the first paragraph is used)
  • --max-row-width 5 - max (sub)modules per row in the layers draft (default 5; 0 = unlimited)

Adding a new language requires three steps in src/languages/:

  1. Write a config factory in a new file (e.g., make_config() in javascript.py) that returns a LanguageConfig with:
    • extensions / package_filenames — file extensions and directory-level filenames (e.g., {"js", "jsx"} / {"index"})
    • definition_kinds / name_field — tree-sitter AST node types of definitions mapped to their unit kind (e.g., {"function_definition": "function"}), and the field holding a definition's name
    • is_private — a function that tells whether a definition is private (e.g., _-prefixed in Python, not exported in TypeScript)
    • is_entry_point — a function that tells whether a top-level node is a script entry point block (e.g., if __name__ == "__main__":)
    • unwrap_definition — a function that returns the definition wrapped by a top-level node (e.g., a decorated function), or None to skip it
    • docstring_extractor — a function that extracts a docstring from a definition node (e.g., JSDoc comments, Javadoc)
    • import_extractor — a function that extracts imports as ImportInfo(local_name, qualified_name) from a module AST (given the module path and whether the file is a package file like __init__.py)
    • ref_extractor — a function that extracts referenced names and dotted chains (calls, callbacks, base classes, ...) from a definition node
    • optionally preprocess (e.g., extract the <script> blocks of a Svelte file) and file_unit_kind (each file is a single unit of this kind, e.g., a "component")
  2. Install the grammar — add the corresponding tree-sitter-<language> package to the generate dependency group in pyproject.toml.
  3. Register it — add an entry (file extension, grammar module and function, config factory) in register_languages() in __init__.py.

The main script (generate.py) is fully language-agnostic: it uses the config to parse files, then resolves dependencies by checking which calls land in the project's symbol index.

Using an AI agent

Alternatively, you can ask an AI agent to generate both files by analyzing your codebase. This can produce richer descriptions but is non-deterministic -- the AI might miss things or get things wrong, so you should double check the results.

AI prompt for generating input files from your codebase

Paste the following prompt into your AI agent of choice (Claude, Codex, Gemini, etc.) after giving it access to your repository:

I want you to analyse this codebase and produce two files for a tool called ArchGraph.

File 1: layers.json

Identify the top-level modules and how they depend on each other. Group them into a strict layered hierarchy where dependencies only flow downward (higher-level modules call lower-level ones, never the reverse). Represent this as a JSON object:

{
  "root_layers": [
    ["ModuleA"],
    ["ModuleB", "ModuleC"],
    ["ModuleD"]
  ],
  "submodule_layers": {
    "ModuleB": [
      ["ModuleB.sub1"],
      ["ModuleB.sub2", "ModuleB.sub3"]
    ]
  }
}

Rules:

  • Each inner list is a row. Modules in the same row are siblings and must not depend on each other.
  • Row 0 is the top of the hierarchy (e.g. HTTP handlers, CLI entrypoints). The last row is the bottom (e.g. database, cache, shared utilities).
  • submodule_layers follows the same row structure within a single root module. Only include a module if it has meaningful internal sub-layers; omit it otherwise and it will be treated as a leaf.
  • Submodule names must be prefixed with their parent module name and a dot (e.g. payments.gateway). A module may also list itself as one of its submodules if it contains units directly at the module level.

These layers should describe the desired dependency hierarchy; our existing codebase might violate these rules, so focus more on how things should be instead of the actual dependencies you find in the code.

File 2: units.md

For every significant unit in the codebase (public function or class worth documenting), write a ### section. The heading must be the full dot-path: submodule.UnitName. The body is one to three sentences describing what the unit does. Inline every dependency on another unit using backtick-at notation: `@submodule.OtherUnit`.

### payments.gateway.charge
Initiates a payment charge for the given amount and payment token. Calls
`@core.db.execute` to store the charge record and `@core.events.publish`
to emit a `payment.charged` event.

Rules:

  • Only reference units that are also described with their own ### heading.
  • Use the exact submodule path from layers.json as the prefix.
  • It is fine to omit purely internal helper units; focus on the public interface of each submodule.
  • Each unit heading must be exactly submodule.UnitName — two segments joined by the last dot. services.ml.Model is a valid heading (submodule services.ml, name Model). Do not write method names as headings (e.g. services.ml.Model.predict is wrong — it would be parsed as submodule services.ml.Model, which does not exist). If you want to reference a specific method as a dependency, use `@services.ml.Model.predict` in the body text; it will be resolved to the parent unit services.ml.Model automatically.
  • Do not invent dependencies that do not exist in the actual code.

Produce both files in full. Think carefully about the layer ordering before writing layers.json — the most common mistake is placing a module too high when it is actually called by others.

How I created this project with AI

I don't code a lot by hand anymore these days, but I still care deeply about well-designed software. I follow my Clarity-Driven Development approach, where you first think through the Why, What, and How and capture it in sketch documents like sketch.md before writing a single line of code.

After some minor refinements based on Claude's feedback, I implemented the project step by step with Claude Agent in the Zed IDE - tests first, then the Python script, then the frontend. The frontend in particular was a lot of fun: having AI in the loop made it easy to iterate on design ideas until we landed on something less cluttered than the typical arrow-heavy diagram. Instead of showing all connections at once, small icons on each box indicate incoming and outgoing dependencies, and the actual lines only appear when you click on a submodule or unit.

What I decided on my own:

  • Input files: External inputs are often produced by processes outside our control. I liked the markdown format I was already using for my sketches, so the implementation just had to handle that.
  • Final product: The interactive visualization has to serve my needs as a user. I explored a few options with the AI, but it was ultimately my call which one I found most useful.

Where I asked AI for feedback:

  • Interface / intermediate data structures: These are among the most important decisions in software, since changing them later — especially if you need to migrate existing data — can be a lot of work. In this case, the shape of result.json determined both how easily the frontend could render the visualization and what steps the Python script needed to take. I figured out the basics from what I knew I wanted to see, but since I don't have much web development experience, I asked my AI agent whether the format would be sufficient.
  • Software design: How you decompose software into submodules and units is another big decision. For the Python script, I drafted this in sketch.md based on the steps needed to produce the final output and what I might want to reuse later. The AI suggested better names and flagged a few details I'd missed.

What I left up to the AI (with some general guidelines):

  • Implementation details: My sketch already contained a lot of pseudocode covering invariants and edge cases, but how to translate that into actual code was left to the AI (and I'm genuinely glad I didn't have to look up how to generate n evenly-spaced colors and convert them to hex). However, I did provide general coding guidelines in an AGENTS.md file to give it some guardrails.

In multiple places, Claude's implementation was also more efficient than what I initially came up with: For example, my naive idea for checking architectural layer violations would have required $O(n^2)$ memory, while Claude later came up with a solution that only needed $O(n)$. Could I have come up with a better solution myself? Maybe. But most definitely not in the time it took me to write "Check if anything could be refactored to improve performance." And for a side project like this I probably wouldn't have bothered.

The initial implementation of the whole project took one weekend: about 1.5 days writing the sketch document and a few hours instructing Claude (Sonnet 4.6). Without a pro plan it would have cost me around $15 in tokens - definitely worth it!

About

Turn a plain-text description of your codebase architecture into an interactive dependency graph

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages