Convention as Code: Enforcing Architecture with Scripts, CI, and AI Agents

programming, ai, architecture

Conventions Drift

Vibe coding gets it 80% right, but the remaining 20% needs enforcement via some mechanical method.

Every codebase has unwritten rules. "Schemas go in their own files." "Always be explicit about strictness." They live in someone's head, or on a wiki page nobody reads. New code breaks them.

Agents break such conventions very quickly — because agents don't attend standups, don't read wikis, and don't absorb tribal knowledge through osmosis.

We hit this on a real project — an Atlassian Cloud integration with 19 tools across Jira, Confluence, and Bitbucket. We asked a simple question: are any of our validation schemas too loose?

The answer was yes. Everywhere.

Our validation library, Zod, silently strips unknown keys by default. Pass { name: "Alice", role: "admin" } to a schema that only knows about name, and role vanishes. No error, no warning, just gone.

We found 233 problems across 20 files:

  1. No explicit strictness. 119 schemas silently stripping unknown keys because nobody told them not to.
  2. No separation. 114 schemas buried inline inside handler functions — unnamed, unsearchable, invisible.

ESLint can't catch either of these. We needed something stronger.

Development Time: 28 Minutes. Enforcement Period: Forever.

We fixed all 233 violations in 28 minutes of wall-clock time — two custom rules, seven AI agents running in parallel. That's the fast part.

The lasting part: those same rules now run on every commit. Any future code that breaks the convention breaks the build. No code review needed. No tribal knowledge. The convention went from "something we try to remember" to something the machine enforces — permanently.

That's the pattern: encode a convention as a script, use agents to fix existing violations, then let CI enforce the rule forever.

Writing the Rules

We wrote custom rules using ts-morph, which gives you access to TypeScript's full compiler API. Unlike ESLint, it can follow method chains, track imports across files, and understand types. That's what we needed.

A rule receives the full project and returns violations. Simple. We called the rule runner topology — a small directory of scripts, each taking a ts-morph project and returning a list of violations.

The first rule: every z.object() must be followed by .strict() (reject unknown keys) or .loose() (allow them) — no silent defaults. The point isn't which one you pick; it's that the choice is explicit. We ran it:

$ tsx topology/run.ts

topology: 119 violation(s) found

  src/ipc/routes.ts:45 — z.object() must be followed by .strict() or .loose()
  src/ipc/routes.ts:52 — z.object() must be followed by .strict() or .loose()
  src/ipc/routes.ts:78 — z.object() must be followed by .strict() or .loose()
  ...
  src/web/chat/tools/workspace/readFile.ts:10 — z.object() must be followed by .strict() or .loose()
  src/web/chat/tools/workspace/writeFile.ts:11 — z.object() must be followed by .strict() or .loose()
  src/web/chat/tools/workspace/deleteFile.ts:10 — z.object() must be followed by .strict() or .loose()

119 hits. Each one a file, a line number, and a message. Exit code 1.

The second rule — every schema must live in a .schema.ts file — caught 114 more. 233 total: a precise, machine-readable list of everything that needed fixing.

Dispatching Agents — In Parallel

This is where agents earn their keep. Not one agent doing everything, but multiple agents working simultaneously on partitioned work — the same way you'd split tasks across a team, except the team spins up in seconds and doesn't need onboarding.

We used Claude Sonnet 4.5 in parallel sessions to do the refactoring.

Strictness fixes: One agent added .strict() (or .loose() where appropriate) to all 119 schemas. Each change was a single method call — one agent handled it easily.

Schema extraction: This was bigger — creating new files, naming schemas, rewriting imports. We split the 20 violating files into 6 non-overlapping groups (by directory, so no shared imports crossed boundaries) and gave each group to a separate agent. No two agents touched the same file, so there were zero merge conflicts. Seven agents total, running in parallel, done in under half an hour.

Here's what a single file looked like before and after:

Before — inline, no strictness:

// readFile.ts
import { z } from "zod";

export function createReadFileTool(rootPath: string) {
  return buildTool({
    inputSchema: z.object({
      path: z.string().describe("Path to the file"),
      startLine: z.number().int().min(1).optional(),
      endLine: z.number().int().min(1).optional(),
    }),
    execute: async ({ path, startLine, endLine }) => {
      /* ... */
    },
  });
}

After — extracted, strict, named:

// readFile.schema.ts (new)
export const ReadFileInputSchema = z
  .object({
    path: z.string().describe("Path to the file"),
    startLine: z.number().int().min(1).optional(),
    endLine: z.number().int().min(1).optional(),
  })
  .strict();

// readFile.ts
import { ReadFileInputSchema } from "./readFile.schema";

export function createReadFileTool(rootPath: string) {
  return buildTool({
    inputSchema: ReadFileInputSchema,
    execute: async ({ path, startLine, endLine }) => {
      /* ... */
    },
  });
}

The schema gets a name, explicit tightness, and a dedicated home.

Agents Verify Their Own Work

This is the key insight for anyone building agent workflows: agents don't need to understand why a convention exists. They just need a pass/fail signal — exactly like a test suite.

Each agent was told to run the rules as a final step:

After completing all changes, run tsx topology/run.ts and confirm the output is topology: 2 rule(s) passed. If any violations remain, fix them before reporting completion.

We also ran 6 verification agents that compared each git diff against the original file, confirming schemas were extracted verbatim and no behavior changed. All 6 passed.

Merge and Enforce

The rules go into the lint script:

{
  "lint": "tsc --noEmit && prettier --check . && eslint . && tsx topology/run.ts"
}

During the refactoring, this script found problems. After the merge, the same script prevents them. CI runs it on every commit — no agents involved, just a pass/fail check. Either topology: 2 rule(s) passed or the build breaks with exact file and line numbers.

A Few Things We Learned

The Pattern

This was a small project — two rules, 233 violations, 28 minutes. But the pattern is general and repeatable:

  1. Identify a convention that lives in someone's head.
  2. Encode it as a script that returns violations with file and line numbers.
  3. Dispatch agents to fix the violations, using the script as their success criterion.
  4. Enforce the script in CI so the convention can never drift again.

Every architectural convention that lives only in someone's head is a convention that agents will violate and humans will forget to enforce. Making it executable changes both problems at once. And it gives agents the same pass/fail signal they get from tests — they don't need to understand the reasoning, they just need to make the check pass.

Scripts solve the knowing. Agents solve the doing. CI solves the forgetting.