Architecture linting, as cheap as a typecheck
When I started coding with LLMs early on, I’d be amazed and frustrated in the same session. The change itself would look spot on, and then I’d check closer and find a server file sitting in a folder it had no business being in, or a client import pulling in something that should never leave the server. So I’d tell it, steer it, adjust it, and three turns later in the same thread it would do it again. A long thread would actually get good by the end, and then the next session would start fresh and I’d say the same thing over.
This was 2024, in Cursor, before Claude Code existed and on Claude 3.5 Sonnet, when you had to be more skillful about the context you handed it. My first fix was better docs. I wrote my conventions down and @-mentioned them at the start of a session instead of typing it all out by hand, which helped immensely and still got tiresome, because even when something is in a doc it can get missed. The LLM rereads it every session, which costs tokens and time, and it can still skip it or follow it loosely. When slash commands and skills showed up later, I tried those too. They’re a step up from a doc, but you still have to remember to run them or hope the agent reaches for one, and the model still interprets them, so the result is probabilistic. A review command that cleans up at the end has its own problem: it’s an extra pass, it’s optional, and the bad file existed the whole time anyway.
The real issue was consistency and precision. A rule in a markdown file is a suggestion, and I wanted something that would just say no. I also wanted it to not just say no, but say why, and point at the doc holding the reasoning, so it got the reasoning at the moment it hit the wall instead of up front. That’s a general principle in how I build tools for agents.
I already trusted one loop that wasn’t optional. TypeScript’s type checker, tsc, is fast, and it goes red inside a command I’d type anyway, so I never had to remember to care about it, and I wanted architecture mistakes in that same slot. It’s since broadened into one command, bun run check: lint, typecheck, tests and my own rules together. It runs wherever the typecheck does: pre-push hooks, CI where there is one, and the agent runs it after every change while it’s still working, the same way it would typecheck, not once at the end. If it finds a key architectural flaw at the end, it’s already built on top of it, and the design may have to change. Even an automated review at the end has that problem.
So I started writing scripts. If I ran into something and didn’t want it happening again, I’d have the agent write a small checker for it, starting with a test that fails until the checker works, and hang it off typecheck as a validate script. The early ones were as specific as they sound: don’t import a .server file from a component, no React in an API route, no database writes in a loader, no test files in the routes directory because the bundler will happily serve them as routes. Then another one, and another. One project ended up with a 2,000-line conventions file holding 22 rules, and another had a 1,400-line frontend validator. They got long and unwieldy, so I split them out, and then they got brittle enough that I was writing tests for the tests. None of it was shared either, so the same “don’t import the server from the client” check lived in three codebases as three slightly different scripts, and I wanted the same patterns enforced across projects.
I figured someone must have built this already. ESLint is the obvious first place to look, and it’s good at a file, but I wanted something higher level, at the conventions layer and across files.
ESLint gets closer than people assume, with rules in your repo, docs links on failures, and plugins like import/no-cycle that follow imports. But it lints code, a file at a time. I wanted to lint the architecture: how the code, configs and docs fit together, and why.
| ESLint (or Oxlint) | What I wanted | |
|---|---|---|
| What a rule looks at | One file at a time (a plugin can follow its imports) | One file, the whole repo, or the repo’s state: configs, git tags, installed packages |
| Inputs | JS/TS (markdown and JSON via plugins) | Any file: code, markdown, package.json, config |
| Testing a rule | Code snippets | A snippet, or a small fake repo |
| Other checks (dependency audit, secret scan) | Separate commands, separate output | Run as rules, in the same report |
| When it fails | A message and a docs link | A message, the rule’s rationale, and a pointer to the domain doc |
| What can’t be checked | Out of scope | Guidelines: written guidance, linked to the rules that partly enforce it, and compiled into ARCHITECTURE.md |
I looked at the dependency-graph tools too. Java has had a version of this for years with ArchUnit, and TypeScript has a few of them, which read like a test suite you write or a dependency graph you configure. I still needed content rules, project-wide rules, and a loop I’d actually run, so I took the inspiration and rolled my own, and called it archlord, because what it governs is the architecture.
Presets mean every project shares the same rules instead of its own copy of each script. Every rule carries a rationale for why that architecture exists, and a separate optional docs field pointing at the domain doc that lives next to the code, so a failure teaches instead of just failing. Severity is per project, so the same rule can be a hard failure in one repo and a nudge in another, which is how a new rule rolls out without breaking everyone.
Here is one of them in practice. It runs as part of bun run check, alongside lint and the typecheck, and this rule says that every command a CLI’s README documents has to be a command that exists.
You could make ESLint do this, but it’s built around one file at a time, and this is a README checked against the commands defined in a different file. It matters because of who reads it next: I rename a command, the doc still names the old one, and the next agent reads the doc and confidently runs something that doesn’t exist. The same shape catches a Vitest include that quietly stops matching a test directory, where the tests still sit there looking like they run and stop protecting anything. The parts I care about are the reason, the escape hatch, and that this one rule checked 1,238 files in 59 milliseconds. The full check on this blog runs in about 200 milliseconds, partly because it’s built on Oxc, a fast Rust parser.
A rule is small enough that I write one mid-task now. I hit the thing, ask for the rule and a test, and it exists before I’m done dealing with the bug that prompted it. This is the whole of the one that catches server code imported into a client component:
export default forbidImport({
id: 'no-server-in-client',
severity: 'error',
rationale:
'Remix only tree-shakes .server.ts from loader/action/headers. ' +
'Client imports cause build errors or security risks.',
include: ['app/components/**/*.tsx', 'app/hooks/**/*.ts', 'src/components/**/*.tsx'],
skipRoles: ['test', 'fixture', 'server'],
from: /\.server$/,
allowTypeImports: true,
message: '.server.ts import in client code - causes build errors',
fix: 'Use import type for types, or move logic to loader/action',
});
The rationale is a field rather than a comment, so it gets printed to whoever hits the rule.
A lot of the time I write the rule as the fix. It’s TDD at the architecture level: I write the check first, watch it fail on the code I just wrote, then fix it. Instead of testing that a function returns the right number, I’m codifying a principle and letting the architecture enforce it. It’s often easier than fixing the thing by hand, and the class stays solved the way I want it everywhere instead of in the one spot I noticed. The last rule I wrote that way turned up the same bug in a dozen places, all of which looked green: a check that printed a tick without running, a push gate that read a git failure as “clean”, a commit guard that passed because a command had failed.
What the loop looks like
Same rule, with a domain doc wired up.
Once a rule exists, every run gets it for free: the next turn while I’m still in the same thread, the next session, and the agents running in parallel that I’m not watching. It’s one of the biggest speedups I’ve gotten out of coding with LLMs, mostly because I stopped re-explaining things. I built it when the LLMs were much weaker, and I use it more now than I did then.
Everyone’s talking about loops now, and this is one. People still leave the correction in the chat, or in a markdown file the agent might skip. Anything I can check becomes a rule, and the rest stays a guideline, which is where my attention goes now. I think about AI in a product the same way: let the model decide what needs judgment, and turn the rest into deterministic rules. If coding tools absorb this and it becomes a default, good. I’d like to open up the blueprint in a follow-up, and how the domain docs stay aligned with the code is its own post. Has anyone else been doing it this structured?