Where it fits
The same drawing serves every stage of delivery. Each stage gets something it can act on, from the same build step.
- Plan
Start from the building as it is
Every main-branch build publishes the as-built blueprint. A feature is planned against the real layers and outside systems, not last year’s diagram.
--as-built → after.svg, blueprint.json
- Code
Give the agent the rules before it writes
agent-brief.md lists the layers, which way imports may point, and what the last run flagged. Put it in the agent’s instructions; it runs the CLI itself and fixes what it introduced before anyone reviews.
agent-brief.md
- Build
Fail the job on a design regression
The CI step reads the shape of both revisions where the code already is. An import that runs the wrong way, a new package cycle or a grade drop fails the gates you set, and --fail-on hold exits 1.
exit code, job summary
- Review
Open the pull request on what moved
The comment leads with the design delta: packages added, imports against the layer order, new outside systems, findings introduced and resolved, and the drawing one click away.
PR comment, changes.svg
- Release
Keep a design record per release
Upload the blueprint with the build’s artifacts. Every tag keeps its drawing and its JSON, so “when did billing start calling Meterly?” has an answer.
build artifact per tag
- Operate
Know what the code reaches
Beyond the boundary lists every datastore, SaaS, service and hard-coded host the code calls, and which packages call it: the inventory on-call, vendor review and ArchCanvas start from.
boundary in blueprint.json
At build time
The CLI that reviews your pull requests also reads the shape of both revisions: the head from the checkout, the base through one git cat-file call. It sends that shape with the diff. The server builds a design model for each side: packages are the directories under the source root, placed in layers from the application shell down to configuration. It compares the two models and checks the result against architecture in your .visualdiff.json. A failed block gate holds the pull request, so --fail-on hold fails the job.
Run it on your machine first. Nothing is stored without a key:
curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o visualdiff.mjs
node visualdiff.mjs --blueprint-out visualdiff-blueprint| Option | What it does |
|---|---|
--blueprint-out <dir> | Write changes.svg, before.svg, after.svg, blueprint.json and agent-brief.md. |
--as-built | No pull request: draw HEAD as built, into --blueprint-out (default visualdiff-blueprint). |
--no-blueprint | Send no revision facts: no blueprint and no architecture gates. |
--dry-run | Send nothing; with --blueprint-out, also write facts.json. |
--fail-on hold | Exit 1 when the verdict is hold, which a failed block gate causes. |
What leaves the build
Structure, never source. For each source file of each revision:
| Field | Holds |
|---|---|
p, loc | The path and its lines of code (not blank, not comment-only). |
imp, timp | Module specifiers imported for values and for types only, e.g. ../../api/usage/router.js. |
rt, mnt | Routes declared ([method, path]) and routers mounted under a prefix. |
http, nto | How many outbound calls the file makes, and how many carry no timeout. |
hosts | Host names in URL literals, e.g. api.meterly.io. |
env | Environment-variable names read, e.g. METERLY_API_KEY. Never their values. |
sec | Line numbers where a secret-looking literal sits. Never the literal. |
ifc, entry, types, test | Interfaces declared, whether the file starts a process, declares only types, or is a test. |
other, manifests | Notable non-source paths (Dockerfiles, CI configs, lockfiles, .env files) and dependency names from the manifests. |
Here is the whole record for the Meterly client in the sample above:
{
"p": "src/adapters/meterly/client.ts",
"loc": 13,
"timp": [
"../../metering/types.js"
],
"http": 1,
"nto": 1,
"hosts": [
"api.meterly.io"
]
}To see exactly what would be sent from your repository, without sending it, run node visualdiff.mjs --dry-run --blueprint-out visualdiff-blueprint and read visualdiff-blueprint/facts.json. Pass --no-blueprint to send none of it.
Architecture gates
The gates live in .visualdiff.json on the default branch, next to the rest of your policy. Like the rest of the policy, they are read from the base revision, so a pull request cannot loosen the rules it is judged by. Each gate is block, warn or off. A block failure holds the pull request; a warning adds a reason to the review.
{
"architecture": {
"gates": {
"wrongWay": "block",
"newCycle": "block",
"newBoundary": "warn",
"gradeDrop": 8,
"minGrade": "B"
},
"layers": [
{ "path": "src/clients/**", "layer": "infrastructure" },
{ "path": "src/jobs/**", "layer": "app" }
],
"allow": [["infrastructure", "shared"]]
}
}| Gate | Default | Fails when |
|---|---|---|
wrongWay | warn | A new import runs against the layer order, e.g. an adapter importing a route. Type-only imports and the composition root’s injected types do not count. |
newCycle | warn | Two or more packages now import each other. |
layerSkip | off | A handler reaches the database or a client directly, past the business layer. |
newBoundary | off | The code reaches an outside system it did not reach before. |
gradeDrop | 10 | The overall design score drops by at least this many points. Always blocks; 0 turns it off. |
minGrade | D | The grade after the change is below this. Always blocks. |
layers places paths the directory names do not explain; the first match wins. allow accepts an upward import between two layers that your team has decided is fine. Layers, top to bottom: app, wiring, interface, ui, business, crosscutting, infrastructure, shared, config.
Artifacts
With --blueprint-out, the build writes a directory you can upload with its other artifacts:
| File | For |
|---|---|
changes.svg | The pull request painted over the design as it will be: rooms added, changed and removed, imports added and cut, findings introduced and resolved. |
before.svg, after.svg | Either side, plain. after.svg is the as-built record on a main-branch build. |
blueprint.json | Both design models, the delta, the gates and the verdict, for your own tooling. |
agent-brief.md | The layers, the import direction and what to fix, written for a coding agent’s next turn. |
facts.json | With --dry-run: exactly what would leave the build, to read before you trust it. |
With a key and --store, the review comment also carries the drawing, served from the stored report.
CI recipes
The pull-request job below reviews the change, fails on a block gate and keeps the blueprint. It needs the full history so the merge base is reachable.
name: visualdiff
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- run: curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o "$RUNNER_TEMP/visualdiff.mjs"
- run: node "$RUNNER_TEMP/visualdiff.mjs" --comment --store --fail-on hold --blueprint-out visualdiff-blueprint
env:
VISUALDIFF_KEY: ${{ secrets.VISUALDIFF_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: blueprint
path: visualdiff-blueprint/Add a main-branch job to publish the as-built record of every merge. This is the drawing that planning starts from.
name: blueprint
on:
push:
branches: [main]
jobs:
as-built:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o "$RUNNER_TEMP/visualdiff.mjs"
- run: node "$RUNNER_TEMP/visualdiff.mjs" --as-built
- uses: actions/upload-artifact@v4
with:
name: blueprint-${{ github.sha }}
path: visualdiff-blueprint/visualdiff:
image: node:22
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
GIT_DEPTH: "0"
script:
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
- curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
- node /tmp/visualdiff.mjs --store --comment --fail-on hold --blueprint-out visualdiff-blueprint
artifacts:
when: always
paths: [visualdiff-blueprint/]pipelines:
pull-requests:
'**':
- step:
name: visualdiff
image: node:22
clone:
depth: full
script:
- git fetch origin "$BITBUCKET_PR_DESTINATION_BRANCH"
- curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
- node /tmp/visualdiff.mjs --store --comment --fail-on hold --blueprint-out visualdiff-blueprint
artifacts:
- visualdiff-blueprint/**Other CI systems follow the same pattern: check out with full history, run the CLI with --base origin/<target>, and keep the output directory. The other hosts are covered in the docs.
Reading the drawing
- Decks are layers, read top to bottom in the order a request travels. Rooms are packages, sized by lines of code, with the routes they serve.
- Lines between decks are imports, coloured by the layer they leave. Imports that skip several decks ride the bus in the right gutter. Imports inside a deck are counted rather than drawn, and type-only imports are left out.
- The dashed red line in the left gutter is an import that runs against the layer order.
- The violet line follows one request from its route through the packages it touches to the outside system it reaches. For a pull request, it prefers a route through what changed.
- Dots are findings, coloured by the worst one. A ring marks a finding the change introduced, and a check marks one it resolved.
- Beyond the boundary lists the datastores, SaaS, services and hard-coded hosts the code reaches. Datastores are found by their drivers, services by their configuration, and hosts by URL literals.
- On Changes, green rooms are new, amber rooms changed, and dashed rooms were removed.
What it grades
Each of nine categories starts at 100 and loses points for each finding, weighted by severity. The overall grade is their mean: A from 90, B from 75, C from 60, D from 40.
| Category | Checks |
|---|---|
| Architecture | Imports that run against the layer order, package cycles, handlers that skip the business layer. |
| Dependencies | Imports the manifest does not declare, and dev dependencies used at runtime. |
| Configuration | Hard-coded hosts, and environment variables read outside the configuration module. |
| Security | Secret-looking literals (by line, never the value), tracked .env files, containers that run as root. |
| Resilience | Outbound calls without a timeout. |
| Observability | No health endpoint; no error tracking or tracing. |
| Testing | The ratio of test code to source, and packages no test imports. |
| Build | No CI pipeline in the repository; no lockfile. |
| Maintainability | Modules large enough to be doing several jobs. |
API
| Method | Path | What it does |
|---|---|---|
POST | /api/v1/analyze | With blueprint: { head, base }, the report carries the blueprint and the gates join the verdict. blueprintSvg: true also returns the drawings. |
POST | /api/v1/blueprint | Draw a revision from its facts, with no pull request and nothing stored. Takes head, optional base, policy and format: json | svg. |
GET | /api/v1/reports/:id/blueprint | A stored report’s drawing as SVG (or format=json). Takes side=changes | before | after and the share token t. |
Limits
- Imports are read with patterns, not a compiler: an import built at run time, or a framework that wires modules by convention, is not seen.
- Packages are directories two levels under the source root. A monorepo with several services draws best one service at a time (run the CLI from that service’s directory).
- Layers come from directory names (api, routes, services, domain, repositories, adapters, lib…) and what a package touches. Where your names differ, map them with architecture.layers.
- Up to 8,000 source files per revision are read; files over 400 KB are skipped.