September 22, 2026

TL;DR

  • Taproot writes its coding rules down, then turns every rule it can into an automatic check.

  • When a change breaks a rule, the check fails with a message that says what to fix, so the AI tool can fix it before a person ever looks.

  • That saves human review for the decisions that need judgment.

  • A passing check still gets a look, because an AI tool can make a check pass by weakening the check instead of fixing the code.

Why I turn coding standards into automated checks

Automated checks surface questions, which pull in context. Just relying on review would allow a lack of context to introduce bugs.

One of Taproot's tests fails if our code references a design token that doesn't exist. A token is a named value like a color or a spacing step, and a name that merely looks right isn't good enough: the test checks every reference against the design system's published list.

Cartoon of a pink phone and a purple padlock walking arm in arm, the phone carrying a keyhole shield

That's the pattern. We write a standard down, then turn every rule we can check reliably into a script, lint rule or test. When a change breaks one, the check fails with a message that says what to do, the AI tool fixes it, and review time goes to the decisions that need judgment.

Start with a written rule

Taproot is a publishing platform with a frontend, a backend API, a site generator and an image processor, and one change can cross several of them. Moving to a monorepo made those changes easier, but code can work fine on its own and still clash with how the rest of the product is built.

Our rules live in one AGENTS.md that every AI tool reads. I cover that setup in one AGENTS.md for Claude Code, Codex and Copilot, and why the docs come first in documentation-first development.

A useful rule names the decision, the approved path, and what to do when the path doesn't cover the case. For example: the frontend calls the API through a client generated from the API contract, and if an operation is missing, you update the contract and regenerate.

That gives the AI tool something to act on. A check, which we call a guard, then tells us whether the code it wrote actually follows the rule.

Example: blocking raw fetch() calls in the frontend

The generated-client rule has a guard script, check-no-raw-fetch.mjs. It parses every frontend TypeScript file with the TypeScript compiler API and flags any call to fetch() unless an exception comment sits on that line or the two above it. Because it walks the syntax tree instead of grepping, a fetch in a comment or string doesn't trip it. The core of it:

const ALLOW_MARKER = "taproot-allow-raw-fetch";
// ...
function visit(node) {
  if (
    ts.isCallExpression(node)
    && ts.isIdentifier(node.expression)
    && node.expression.text === "fetch"
  ) {
    const position = sourceFile.getLineAndCharacterOfPosition(
      node.expression.getStart(sourceFile),
    );
    const line = position.line + 1;
    const commentWindow = lines.slice(Math.max(0, line - 3), line).join("\n");
    if (!commentWindow.includes(ALLOW_MARKER)) {
      violations.push({ file: relative(rootDir, file), line });
    }
  }
  ts.forEachChild(node, visit);
}

It runs in npm run lint, before ESLint, and a failure prints the file and line and points to the generated SDK. Every handwritten API call is one more endpoint path to keep in sync by hand.

There are two exceptions today, both in the auth interceptor, and each says why: // taproot-allow-raw-fetch: refresh must bypass the shared SDK interceptor chain. The marker makes the departure visible; whether it's justified is still a question for review.

A good failure tells the next person, or the AI tool, where to look and which decision to reconsider. Otherwise the guard just starts another investigation.

Example: enforcing dependency direction with ESLint no-restricted-imports

Our frontend's dependency direction is pages, then components, then common code. Nothing points back up. ESLint's built-in no-restricted-imports rule (opens in a new tab) enforces it with a glob per folder, and the message states the direction:

{
  files: ["client/src/components/**/*.ts"],
  rules: {
    "no-restricted-imports": [
      "error",
      {
        patterns: [
          {
            group: ["**/pages/**"],
            message: "components/ cannot import from pages/. Dependency direction: pages → components → common.",
          },
        ],
      },
    ],
  },
},

A second block does the same for common/, banning both pages/ and components/.

It catches a shortcut that's convenient today and makes the component harder to reuse later. The fix might be moving shared behavior into common/ or passing the component what it needs.

Choosing between those still takes judgment, and the rule only sees static imports, so it isn't a full model of runtime dependencies.

Example: architecture tests in xUnit

On the backend, every mutation is supposed to go through a command pipeline that handles authorization, validation and commits. We don't use an architecture-testing library; about 30 files of plain xUnit tests use reflection and regular expressions to keep entry points from going around it. These fail if a gRPC service or controller takes the database context in its constructor, or if its source calls an EF Core save method:

private static readonly (Regex Pattern, string Description)[] BannedEntrypointSourcePatterns =
[
    (new Regex(@"\bTaprootContext\b", RegexOptions.CultureInvariant), "TaprootContext"),
    (new Regex(@"\bSaveChangesAsync\s*\(", RegexOptions.CultureInvariant), "SaveChangesAsync"),
    // ...
];

[Fact]
public void ApiEntryPoints_DoNotInjectTaprootContext()
{
    var violations = GetForbiddenConstructorInjectionViolations(
        GetApiEntrypointTypes(),
        typeof(TaprootContext),
        nameof(TaprootContext));

    AssertNoViolations(violations);
}

The source scan masks comments and string literals first, so a SaveChangesAsync() inside a comment passes and a real call fails. Both cases have [Theory] rows.

Check against a source of truth

The token test works because it has something authoritative to compare against. Espalier (opens in a new tab), our design system, ships a token manifest in its npm package, and a Vitest test scans the frontend and generator sources against it:

const manifest = require(
  "@taprootio/espalier/espalier.token-manifest.json",
) as {
  tokens: Array<{ name: string }>;
};

const VALID_TOKENS = new Set(manifest.tokens.map((t) => t.name));
const TOKEN_REF_RE = /var\(\s*(--esp-[A-Za-z0-9_-]+)/g;
const TOKEN_DECL_RE = /(--esp-[A-Za-z0-9_-]+)\s*:/g;

An unknown name fails with the token and the file. The AI tool can look up the token it meant, and if the design system really needs a new one, that work belongs in the design system, not in a made-up local name.

Generated code gets the same treatment: a check-generated-artifacts.sh script regenerates it into a temporary directory and diffs it against what's committed, which catches a skipped generation step or a hand edit.

StandardWhat checks itWhat still needs judgment
Use defined design tokensA Vitest test that compares token references with Espalier's manifestWhether the token produces the intended look
Keep frontend dependencies in the intended directionESLint no-restricted-imports patternsWhere shared behavior belongs
Use the generated API client for normal frontend callscheck-no-raw-fetch.mjs, with an explicit exception commentWhether an exception or contract change is appropriate
Keep generated artifacts consistentcheck-generated-artifacts.sh: regenerate and diffWhether the source contract is correct

How do you test an architecture test?

A review of our architecture guards found two ways a real violation could get through.

One matched namespaces exactly, so a gRPC service in a nested namespace like Taproot.API.gRPC.Admin was never inspected.

Another keyed its exception list on folder plus file name, so a nested file sharing a name with an allowed one inherited its exception and could reach into another bounded context without review. The fix keyed it on the full path.

Each fix came with a test that builds a synthetic violation and proves the guard catches it. The namespace test creates a fake type at runtime:

[Fact]
public void ApiEntrypointDiscoveryAndSourceResolution_CoverNestedNamespaces()
{
    var grpcType = CreateSyntheticType(
        "Taproot.API.gRPC.Admin.ArchitectureFixtureService",
        typeof(object),
        typeof(TaprootContext));

    Assert.True(IsApiEntrypointType(grpcType));
    Assert.Single(GetForbiddenConstructorInjectionViolations(
        [grpcType],
        typeof(TaprootContext),
        nameof(TaprootContext)));
    // ...
}

A clean run against today's code proves very little. A guard should reject a representative violation and accept a legitimate case, and a source scan has to tell code from comments.

A guard that misses violations gives you false confidence. One that rejects legitimate work teaches people to bypass it.

How the AI tool uses the checks

Checks only help if they're in the loop. Our root verification command picks formatting, lint, typecheck, test and generated-artifact checks based on the paths a change touches; slower ones, like Postgres-backed integration tests, run when the work needs them.

./scripts/verify-changed.sh         # checks scoped to your changes
./scripts/verify-changed.sh --all   # full sweep

A failure is evidence. The AI tool reads it, fixes the code and reruns the check. Scoped verification runs before independent review, and any fix from review gets verified again.

CI runs the gates too, but clearing failures locally is quicker than waiting on another pipeline, and since the checks live in the repository, people, AI tools and CI all run the same ones.

A script only enforces anything if it runs, its failure stops the work, and someone reviews any change that weakens it.

A green result still needs review

The AI tool can edit tests and config as easily as application code, so a failing check can go green because the code got fixed or because the check got deleted or its exception got wider.

That's why the whole diff matters. If the code and the rule change together, the reviewer needs to know why. Sometimes a standard really should change, but on purpose. Our backlog workflow in Git includes an independent review in a fresh context that checks the change, guards included, against the task and the repository rules.

Some questions no check will settle. Did we solve the right problem? Does an abstraction earn its upkeep? Does a permission rule match how the product should behave? A guard catches one particular mistake. I still need to understand what we're building.

How to start enforcing coding standards with AI tools

Pick one costly, repeatable mistake you can describe precisely, then:

  1. Write down the rule and the reason, and point at one example that follows it.

  2. Reuse what you have: a compiler setting, an ESLint rule like no-restricted-imports, or a unit test that reads source.

  3. Make the failure name the file and the approved fix.

  4. Prove the check rejects a real violation and accepts the valid example, and keep both as tests.

  5. Run it where your AI tool already verifies its work, and in CI.

Start small. A pile of vague or brittle checks becomes its own maintenance problem, like a test that only mirrors implementation details.

If you're a founder or business owner, the payoff is software that can keep changing without someone rediscovering every past decision.