dev-fran

Rules as tests: write the rule once, run it on every commit

Fran
ai · testing · architecture · claude
Rules as tests: write the rule once, run it on every commit

The pattern in one picture

A test checks a piece of code. You write the code, then you write its test, one by one.

A codebase also has rules: "entities in a test reuse the slice's mocks", "every failure case asserts its error code". Those used to live in a document and in a reviewer's head. Now each rule becomes a test of its own. You write it once, and it runs against every file in its scope, including the ones an agent writes tomorrow.

Before

One piece of code, one test

  • code A

    its test A

  • code B

    its test B

  • code C

    its test C

Every file needs its own test, written by hand.

Now

One rule, one test, every file

The rule

“Entities reuse the slice’s mocks”

Its test

written once

  • user.test.ts
  • order.test.ts
  • route.test.ts
  • the file an agent writes tomorrow

Every commit, every push, every CI run, in under 500 ms.

The rule is written once. The test that enforces it reaches every file in its scope, including the ones written tomorrow.

That is the whole idea. The rest of this post is how it works, what the test looks like, and why it changes the cost of reviewing AI-written code.

Why I needed it

My AI agents commit through a gate of guardians: reviewer subagents, one per area of the codebase, that read the staged diff and grade it.

Guardians are good reviewers, and they are paid on every pass. A one-line fix reopens the round, because any edit in a guardian's area voids its score. Plans that should take minutes took an afternoon.

Most of what a guardian checks is a single judgement over a single file. That kind of question needs a model that reads the file, and only the file.

Jev, in two lines

Jev is TypeSafe's evaluation model. You give it a context and a set of yes/no questions, and it returns the probability of yes for each one, at the pace of a linter, charging only for input.

How it works

One run, per file

under 500 ms
  1. 1

    The rule

    A declarative file in the repo: what it looks at and what it asks.

  2. 2

    The pieces

    The test file, the module it tests, the mocks it could use. Built once per file.

  3. 3

    Scope, decided in code

    If the file has nothing the question judges, it is skipped with its reason.

  4. 4

    One call to Jev

    All the rule’s questions at once. One probability per question.

  5. 5

    The verdict

    Each probability against its barrier. A red names the file, the rule and the question.

  6. 6

    The record

    Stored by content hash. The commit gate reads it offline.

The model appears once, in step four. Everything around it is plain code.

A rule is a declarative file that lives in the repo. It says what to look at and what to ask:

export default {
  title: 'The test reuses the slice’s mocks and leaves them untouched',
  on: 'src/**/*.test.ts',
  // The pieces of context Jev receives for each file.
  state: ['file', 'subject', 'mocks'],
  questions: {
    reuses_slice_mocks: {
      // Below this probability the test fails. Set from a real red and a real green.
      barrier: 0.6,
      // Code decides who gets asked: no mocks, no question.
      applies: { mocks: true },
      instructions:
        'Does every entity-shaped object in `file` ' +
        'name an imported mock or spread one?',
    },
  },
}

The engine builds the pieces for each file (the test, the module it tests, the mocks it could use), skips the files outside the question's scope, and sends Jev one call per rule with all its questions.

What the test looks like

$ run.sh --rule mocks

The test reuses the slice’s mocks

src/contexts/order/order.test.ts
src/contexts/user/user.test.ts
src/contexts/route/route.test.ts

reuses_slice_mocks · below the barrier

Does every object in `file` shaped like a domain entity either name an imported mock or spread one?

src/util/cache.test.tsn/a · no subject found
barrier
A describe per file, a test per rule, a row per question. The red arrives with the exact question that failed.

It is a plain Vitest suite: a describe per file, a test per rule, a row per question. A red names the file, the rule and the exact question that failed, so whoever reads it (a person or an agent) knows what to fix.

Built once, runs everywhere

Built once

The rule’s test

A Vitest suite: a command, an exit code, rows on stdout

Runs everywhere

  • Pre-commit hook

    Blocks the commit on a red

  • Push

    Checks the whole branch

  • CI/CD

    Same command, same exit code

  • The agent, while it writes

    Checks its own test before asking to commit

  • A sweep of the tree

    Audits old code against a new rule

Same content, same questions: the answer comes from the record, with zero calls to the model.

The rule's test is built once and runs in the pre-commit hook, on push, in CI/CD, in a sweep of the whole tree, and inside the agent's own loop while it writes code. Each run takes under 500 ms.

Results are stored by content hash. The commit gate only reads that record, so it works offline and instantly, and the same file with the same questions is asked exactly once.

What it saves: time, tokens, rounds

Before

Review by a guardian agent

A reasoning subagent per area, on every commit

  1. git commit
  2. The gate denies: not reviewed
  3. Spawn the guardianagent
  4. Load brief, read diff, walk the repominutes · tokens
  5. Grade and plan of fixestokens
  6. Fix one line

Any edit in its area voids the score: the round starts over.

Now

The rule’s test with Jev

One yes/no question per rule, per file

  1. Edit the test
  2. Run the ruleunder 500 ms
  3. The red names file and questioninput tokens only
  4. Fix one line
  5. Green, recorded by hash

The agent closes this loop alone, before it ever asks to commit.

The first loop is paid on every pass. The second one costs a run, and nothing at all for content already answered.

The difference is where the loop closes. With a guardian, each fix goes back through a reasoning agent. With the rule's test, the agent that wrote the code runs the rule, reads the red, fixes the line and runs it again, all before it asks to commit.

  • Time

    With a guardian on every commit

    A round of minutes per pass

    With the rule’s test

    Under 500 ms per run

  • Tokens

    With a guardian on every commit

    A reasoning agent that reads its brief, the diff and the repo

    With the rule’s test

    One input-only call per file, and zero for content already answered

  • Rounds

    With a guardian on every commit

    Every fix in its area reopens the review

    With the rule’s test

    The agent fixes and re-checks alone, before the commit

  • Harness loops

    With a guardian on every commit

    A fixed toll on every commit

    With the rule’s test

    The guardian is summoned by a red row, with that row as its brief

Where the saving comes from: the expensive agent is kept for the questions that need reasoning.

Why the architecture works

In the repo

reviewed in PRs, like any code

rules/<scope>/*.ts

what to look at, what to ask

In the engine

outside the repo, evolves on its own

Vitest runner

a test per rule, per file

Jev, via the AI Gateway

one call per rule

Record by hash

the gate reads it offline

  • 01

    The rule is data

    Scope lives in applies and unless. A false alarm on a class of files is a one-line diff.

  • 02

    Reviewed like code

    Rules live in the repo, so changing a rule is a PR with its own review.

  • 03

    One engine, every scope

    Tests, mocks, docs: a new scope is a new folder of rules, with the engine untouched.

  • 04

    Asking is separate from blocking

    The gate reads a record keyed by content hash: offline, instant, reproducible.

  • 05

    Vitest for free

    Exit codes, filters, bail and reporters come with it, and so does CI.

  • 06

    Calibrate before it blocks

    observe prints the answer next to its barrier without failing anything.

The split that makes it cheap to grow: rules change in PRs, the engine changes on its own.

The key design choice is the split. Rules live in the repo and get reviewed like code; the engine that runs them lives outside and evolves on its own. Adding a question is one entry, adding a scope is a folder of rules, and a false alarm is fixed with one line of scope.

Where the line is

Jev catches it

One judgement over one file

  • Could a change in the code make these assertions fail?
  • Does every failure case assert its error code?
  • Does the test assert the calls, as well as the value?
  • Is every entity an imported mock, or a spread of one?

Code does it better

Compare, count, cross-reference

  • expect.assertions(N) against the real count
  • Does beforeEach restore every mock a case overrides?
  • Would each case pass in any order?
  • Does the test connect to a real database?
From the first sweep over a real test suite. The left column found real defects a reviewer agent had let through.

Jev is strong at one judgement over one file. Anything that compares two lists, counts, or depends on order belongs to plain code. The rule of thumb that came out of calibrating: code enumerates, Jev judges one thing at a time.

How to start

  1. List what your reviewer checks. Every guardian, every review checklist.
  2. Sort each item into code (a grep, an AST check), one judgement over one file, or real reasoning.
  3. Write the middle bucket as rules. One yes/no question each, with the mechanism visible in the text.
  4. Calibrate each question with a real red and a real green before it can block anything.
  5. Wire the suite into the hook and CI, and keep the reviewer agent for the last bucket.

The reviewer agent stays, and it gets called for the questions that need reasoning, with a red row as its brief. Every other rule becomes a test you write once.

About me

Written by Fran — fifteen years building software and running businesses, now with AI at every stage. These articles are the engineering lessons I pick up along the way.