08. August 2026
Types made “this value is a string” checkable. Tests made behaviour checkable. Nothing ever made “the API layer must not talk to the data layer” checkable — cheaply, across every language in the repository, in the second before a commit. That gap is where agents do the most damage.
I’ve spent the last few weeks building tropism, a dependency and architecture analyzer for polyglot repositories. This post is about the problem it’s aimed at, the one design decision the whole thing hangs off, and — at some length, because it’s the part most tool posts skip — what it measurably cannot do.
An agent working in a real repository has a decent set of feedback loops available to it. It runs the compiler. It runs the type checker. It runs the tests. It runs the linter and the formatter. On a good day it runs all of them before it tells you it’s done, and the ones that fail get fixed without you ever seeing them.
Every one of those signals has the same shape: it is local, and it is about correctness.
None of them knows anything about the shape of the system. A controller that reaches directly into the storage layer compiles. It passes every test. It lints clean. It reads perfectly well as a forty-line diff. The only place it is visibly wrong is from above — and an agent never sees the system from above. It sees the files it happened to open.
This is worse with agents than it is with people, for three reasons that compound:
A human accumulates an unwritten model of the codebase over months. They know that the API layer goes through the domain because they were in the argument where that was decided. An agent starts every session at zero and rebuilds a partial model from whatever it grepped in the first ninety seconds.
Context gets compacted. Architectural intent read at the start of a session is a summary by hour two and gone by hour three. The constraint doesn’t fail loudly at that point; it just stops being present.
The agent optimises for the check it can see. Give it a failing test and an import that makes it pass, and it will add the import. Nothing in the loop pushes back, so nothing pushes back.
The result is a very specific failure mode that anyone running agents at volume will recognise. The code is fine. The tests are fine. The architecture quietly erodes, one locally-reasonable import at a time, and you find out at the review three weeks later when the diff is too large to unpick.
The obvious answer, and the one the industry has converged on, is to write the intent down. AGENTS.md. CLAUDE.md. Design docs. ADRs. A design/ directory that the agent is told to read before planning any work.
I’m a believer in this — tropism itself was built that way, and its design/ directory is the specification, written before the code and corrected by it wherever building contradicted it. But it’s worth being precise about what that buys you, because the honest answer is: advice.
A spec is prose. Prose is read once, at whatever moment the agent happened to load it, and then it competes for attention with everything else in the context window. There is no gap between “written down” and “enforced” for a type — the compiler is the enforcement. There’s no gap for behaviour — the test is the enforcement. For architecture the gap is total. The document says the CLI and the MCP server must not depend on each other, and then absolutely nothing on earth checks that they don’t.
So the missing piece in spec-driven development isn’t more spec. It’s a compiler for the enforceable slice of the spec — the part that can be stated as a machine-checkable constraint, checked in milliseconds, and fed back into the loop at the moment the code is being written rather than at review.
That’s a narrower ambition than “verify the design”, and deliberately so. Most of a design document is not mechanically checkable and never will be. But the dependency structure — what may depend on what — is a large, load-bearing, and completely checkable fraction of it.
It finds import cycles, manifest problems, and duplicated packages, and it enforces the architecture rules a team actually wrote down — across Go, JavaScript/TypeScript, Rust, C#, Python, Ruby, Java, Swift, and C++, through one CLI and one JSON contract.
$ tropism analyze demo/dotnet
error[module-rule:.:8743cba7]: `api` must not depend on `data` (rule: api-goes-through-the-domain)
--> Shop.Api/OrderController.cs:5:1
|
5 | using Shop.Data;
| ^^^^^^^^^^^^^^^^ imported Shop.Data
|
note: confidence: high
note: tropism.toml: The API layer talks to the domain and nothing else. A controller that calls the
data layer directly couples HTTP concerns to the storage schema, and the domain
stops being the place where the rules live.
The name is an anagram of imports, which is also the whole trick: every language provider’s hardest work is extract_imports and resolve_import, and every check in the tool is ultimately a question about what imports what. (A tropism is directed growth in response to a stimulus — phototropism, geotropism. A dependency graph is directed growth too.)
Tropism never invokes a package manager and never executes the code it analyzes. It works on a fresh checkout with no toolchain, no network, and no installed dependencies, by reading manifests, lockfiles, and source.
That sounds like an ascetic purity thing. It isn’t — it’s the entire product argument, and it cuts both ways in a way I find genuinely interesting.
The existing architecture-rule tools are all excellent and all need a built artifact. ArchUnit is a JUnit test: it needs a compiled classpath. NDepend needs a built solution. dependency-cruiser needs node_modules. Every one of them is a CI-time tool by construction, because the thing it needs doesn’t exist until CI has run.
Tropism needs a directory. Which means it can run:
The measured cost of a scoped check is 0.02s on a 107-file repository and 0.05s on 3,000 files, down from 0.37s before extraction was scoped to the changed set. That’s inside the budget where a check is something the agent just does, rather than something it decides whether to bother with.
And the reversal that makes the design worth writing about: the hermetic constraint that makes the weakest check unreliable is exactly what makes the strongest one deployable. More on the weak check shortly.
This is the idea I’d keep if I threw the rest of the tool away.
Checks divide cleanly into two classes depending on whether they detect the presence of something or its absence, and the two classes have completely different reliability characteristics offline.
| Input | Detects | Sound offline? | |
|---|---|---|---|
unused-dep |
absence of imports | a negative | no — 63% FP |
cycle |
presence of imports | a positive | yes |
| module rules | presence of imports | a positive | yes |
| package rules | presence of a declaration or import | a positive | yes |
“lodash is never used” requires having seen every possible use — and uses hide in HTML <script> tags, config files, framework strings, spawn arguments, tsconfig extends chains. Absence is unprovable without an installed tree.
“The CLI imports the MCP server” is a fact about a line of source that either exists or doesn’t. There is no hidden channel through which a violation could occur invisibly, because a violation is an import, and imports are unambiguous syntax.
That’s why architecture rules landed as the product rather than the generic dependency checks. It got there by elimination, and each elimination was measured rather than argued:
go mod tidy finds everything tropism finds and then fixes it, resolving the correct version to add. Same for cargo-machete, depcheck, knip, deptry. All free, all already installed. Tropism cannot win on detection quality.A tropism.toml at the root, and the rules are enforced on every run. Here’s a trimmed version of tropism’s own ruleset, which is enforced against itself on every commit:
[modules]
core = "crates/tropism-core/**"
lang = "crates/tropism-lang/**"
cli = "crates/tropism/**"
mcp = "crates/tropism-mcp/**"
[[module_rules]]
id = "surfaces-are-independent"
independent = ["cli", "mcp"]
reason = """
The CLI and MCP server are independent adapters over one analysis core. Both may
depend on core; neither may depend on the other. Shared behaviour belongs in core,
not in a dependency between the two surfaces.
"""
[[module_rules]]
id = "core-is-a-leaf"
allow_only = { from = "core", to = [] }
reason = "tropism-core must stay free of rendering, CLI, and transport concerns."
[[package_rules]]
id = "tui-stays-in-the-cli"
packages = ["ratatui"]
allowed_in = ["cli"]
reason = "The interactive browser is a CLI concern; core stays renderer-agnostic."
Six design choices in there are worth pulling out, because they’re the ones that make this different from a linter config.
A module is a glob, not a build unit. Modules need not correspond to projects, packages, or crates. They can be coarser (a whole service) or finer (one directory). This decouples the architecture you’re describing from however the build system happens to be split, which matters enormously in a repository that has been reorganised twice.
One ruleset spans every language. A repository with a Go backend, a TypeScript frontend, and a .NET service gets one file and one hook. Every prior-art rule engine — NDepend, JDepend, ArchUnit, import-linter, dependency-cruiser — is single-language. This is the capability that has no substitute rather than a cheaper version of one.
Rules carry a reason, rendered verbatim in the finding. This is the part that makes the tool useful to an agent specifically. A finding that says api must not depend on data tells the agent that it broke something. A finding that also explains that the domain is where the rules live tells it what to write instead. The single highest-leverage change available in this space is turning tool errors into prompts, and a hand-written reason is the one thing no inferred finding can ever supply.
Violations are caught at both the declaration and the import level. A rule broken in a manifest is still broken; the coupling is real and the import is one commit away.
Rules that rot are reported. A rule whose modules match nothing is emitted as an Info finding. Rulesets decay — someone renames a directory and the rule protecting it silently stops doing anything — and a ruleset that appears to enforce something while enforcing nothing is worse than having none. In the same spirit, crosses_workspace = false is a parse error rather than a no-op, and an unimplemented rule kind is rejected at parse time naming the field rather than ignored.
Silence never means compliance. Every check reports ran, unavailable, or failed, with a reason:
unavailable version-conflict — go.sum records hashes for the whole module graph, not the
versions MVS selected, and carries no edges; a resolved tree
needs the Go resolver
Zero findings is not the same as checked and clean. This matters more for agents than for humans: a human skims the summary line and notices something is off. An agent reads an empty findings array, concludes success, and tells you the work is done.
There’s a sharper version of this trap in the tool itself, and it’s worth stating because it’s the kind of thing that quietly defeats a whole ruleset. A tropism.toml that fails to parse is rejected entirely — so one malformed rule disarms every other rule in the file, and tropism check still exits 0 while protecting nothing. The output says against 0 rule(s), and the rule count is the number to look at. tropism analyze . --fail-on error is the more reliable gate for that specific failure, because it exits non-zero on an unparseable ruleset.
The adoption problem for any architecture rule engine is that teams adopt it on codebases that already violate the rules. The first run is a wall of errors, and the ruleset gets deleted that afternoon.
The usual answer is a baseline file — a snapshot of the violations you’re choosing to live with, which then has to be maintained, regenerated after every refactor, and inevitably drifts from reality.
A violation is an edge, an edge has two ends, and it belongs to the file at its source end. So checking only the changed files gives ratcheting for free:
tropism check src/api/user.ts # the files you touched
tropism check --staged # what is staged
tropism check --since origin/main # what this branch introduced
tropism check # everything
A repository with two hundred existing violations passes every commit that doesn’t add a two-hundred-and-first. No state file, nothing to regenerate, and no way for the baseline to drift from the code.
The honest cost, which the tool prints rather than hides: once extraction is scoped to the changed files, a scoped run cannot count the pre-existing backlog, because counting it means parsing everything. So it says so in words rather than printing a zero:
checked 1 changed file(s) against 3 rule(s) — 0 violation(s)
pre-existing violations elsewhere were not counted — only the changed files were
parsed; run `tropism check` for the whole repository
None is not zero, and the renderer never lets them look alike.
Tropism ships skills in-repo — one for authoring rulesets, one called tropism-in-the-loop for using it while writing code. The interesting content in the second one isn’t how to run the tool. It’s a table telling the agent which findings to act on and which to ignore, including the explicit instruction never to delete a dependency on unused-dep’s say-so.
That’s an odd thing to ship, and I think it’s correct. An agent handed a tool assumes the tool is right. If 63% of one check’s output is wrong, the tool has an obligation to say so in the place the agent will read it, not only in a design document.
Measured, not asserted. Tropism ran against 24 pinned public repositories covering all ten languages, alongside native oracles (madge, cargo tree --duplicates, pylint, go list, jdeps, bundle list) for ground truth where one exists.
The parts that hold up:
elastic/elasticsearch, 31,458 source files, 42 seconds. kubernetes/kubernetes, 17,702 files, 20 seconds.The parts that don’t:
unused-dep has a 63% false-positive rate and must never gate CI. This is structural, not a bug — it’s the absence problem above. It’s capped at low confidence, defaulted off, and excluded from the pre-commit hook entirely.
version-conflict and diamond-dep describe the lockfile, not your build. Dogfooding measured the gap precisely: tropism reports 17 conflicts against its own repository, every one a correct reading of Cargo.lock, while cargo tree --duplicates finds three duplicate sets in the graph that actually compiles. A lockfile is resolved once for every feature combination and every target platform and records neither, so it contains copies no build ever links. Deciding otherwise needs the feature resolution of each dependency’s own manifest, which isn’t in the repository.
Java resolution is 68.3%, and findings there should not be trusted. The unresolved list names the cause honestly — 21,638 unresolved references to org.hamcrest.Matchers, 4,689 to org.mockito.Mockito, 3,150 to org.assertj.core.api.Assertions. Test-scope coordinates the provider isn’t matching. C# is at 92.6%, under the 95% target.
C#, C++ and Swift have no automated ground truth at all. Their numbers are unverified counts, not measured accuracy, and I won’t report them as though they were.
Accuracy for manifest hygiene beyond JavaScript is unmeasured. The 63% figure came from reading 35 findings against the source by hand. Nothing automated replaces that, and whether it’s a JavaScript number or a tropism number is still an open question with a seeded 180-finding audit sample waiting to be graded.
A first run on a large monorepo is dominated by test fixtures. 725 of 797 discovered projects in denoland/deno sit under test or fixture paths; 583 of 746 in vercel/next.js. That’s what exclude is for, but it means the out-of-box experience on those repositories is bad until someone configures it.
Manifests that are programs are read incompletely. Gemfile, build.gradle, Package.swift, and conanfile.py are code. Tropism parses the declarative subset with a grammar and contributes nothing for anything dynamic — gem "rails-#{variant}" names no gem that can be known without running the file, and a package that doesn’t exist is worse in a report than one that’s missing from it.
Four rule kinds are specified and not implemented: layers, require, transitive, and version constraints in package rules. They’re rejected at parse time with an error naming the field, rather than silently ignored.
The MCP server is a stub. Nineteen lines that print where the specification lives. The CLI is the whole interface today.
require will never be as good as the rest. “A must depend on B” is a negative assertion and inherits hygiene’s weakness — an unseen dependency mechanism could satisfy it invisibly. It’s capped at medium confidence for that reason.
Across the whole 24-repository corpus, the confidence split is 52% high, 37% medium, 9% low. A high-confidence rule violation and a low-confidence unused dependency are different claims and should never be counted together, which is why they never are.
The thesis is one sentence: one ruleset, enforced at commit time and over the whole repository, across ten languages, with no build and no install. Two scopes, one ruleset, so what CI blocks is what the hook already blocked.
That’s a much narrower claim than “finds dependency problems”, and it’s narrower than what I started with. It’s what’s left after three claims were eliminated on evidence rather than opinion, which I think is the most useful thing about the project — the design/ directory contains the reviews that killed them, kept as written.
If you want to try it, there’s a one-line installer for macOS, Linux and Windows on the releases page, and a pre-commit hook that works with pre-commit or prek:
repos:
- repo: https://github.com/grahambrooks/tropism
rev: v2026.8.4
hooks:
- id: tropism # rules, on changed files, at commit time
- id: tropism-all # every check, whole repository, at push time
The thing I’d most like to be wrong about is whether the rules survive contact with codebases I didn’t design. Everything above is measured against public repositories that have no tropism.toml — so the generic checks have been evaluated at scale and the flagship feature, by definition, has not.