12k
All articles

Automated Checks That Cut Your Code Review Load

Automate code review with OpenAPI codegen, dependency-cruiser and Stryker mutation testing. Set up CI checks that catch mechanical defects and reduce reviewer load.

OpenReplay Team
OpenReplay Team
Automated Checks That Cut Your Code Review Load

Automated code review, in its deterministic form, is a set of CI checks that fail a pull request when the code breaks a rule. Reviewers then spend their time on design, intent and product fit, not on mechanical errors.

Coding agents now open pull requests faster than anyone can read them line by line. If you’re a lead, you end up rubber-stamping diffs or becoming the bottleneck, and neither lasts.

The fix is to have machines catch the mechanical problems and save human attention for everything else. These checks are worth running whoever wrote the code. This article covers three of them, adds two cheap extras, shows how to wire everything into CI, and ends with what none of it can catch. You already run lint, formatting and unit tests. If not, start with this ESLint and Prettier setup.

CheckWhat it catchesWhat the reviewer no longer has to checkTool
Contract codegenFields the API never returnsWhether each response field existsHey API openapi-ts
Layer boundariesImports that cross architectural layersWhether every import is in the right directiondependency-cruiser
Mutation testingTests that run code without asserting on itWhether each new test actually checks behaviourStryker
Dead codeUnused files, exports and dependenciesWhether anything became orphanedKnip
DuplicationCopy-pasted blocksWhether this logic already existsjscpd

Key Takeaways

  • If the TypeScript client is generated from an OpenAPI spec, reading a field the API never returns, such as user.fullName, is a compile error rather than a review comment.
  • A dependency-cruiser forbidden rule with severity error fails CI when a service imports a component. Run it with the err-long reporter and the rule’s comment appears next to each violation, telling the developer where the import should go.
  • Line coverage shows that code ran. Mutation testing shows whether any test fails when that code is deliberately broken.
  • Treat the mutation score as a detector and leave Stryker’s thresholds.break unset, because equivalent mutants make a hard threshold misleading.
  • Roll out new rules as warnings on changed files. Promote the ones that catch real bugs to errors, and make a job required only once it has settled.

Generate Your API Client From the OpenAPI Spec

If the TypeScript client is generated from an OpenAPI spec, reading a field the API never returns, such as user.fullName, is a compile error rather than a review comment. A hand-written interface can drift from the backend without anyone noticing. A generated one can’t.

A hand-written type compiles even when it’s wrong:

// Hand-written: compiles, but the backend never sends fullName
interface User { id: string; fullName: string }
declare const user: User;

const label = user.fullName;

Generate the type from the spec instead, and the same read fails at compile time:

// Generated from the spec
type User = { id: string; firstName: string; lastName: string };
declare const user: User;

const label = user.fullName;
// error TS2339: Property 'fullName' does not exist on type 'User'.
const fixed = `${user.firstName} ${user.lastName}`;

Tools such as Hey API openapi-ts read an OpenAPI document and produce types and a client. Once the client is generated, review the spec instead. Treat the generated code as build output: don’t edit it by hand, and don’t review it line by line.

Types disappear at compile time, so add a runtime validator such as Zod for responses you can’t afford to trust. None of this works without an authoritative spec that the backend keeps up to date. The end-to-end setup is covered in building type-safe API clients with OpenAPI and TypeScript.

Enforce Layer Boundaries With dependency-cruiser

dependency-cruiser checks every import in a JavaScript or TypeScript project against the forbidden rules in its config file. Violations print in a style much like ESLint output, and any violation of a rule set to error makes the command exit with a non-zero code, which fails the CI job. That takes “should this service really import a component?” off the reviewer’s list.

npm install --save-dev dependency-cruiser
npx dependency-cruiser --init

--init generates .dependency-cruiser.js, or .dependency-cruiser.cjs when package.json sets "type": "module". The file it creates comes with a set of default rules. They flag import cycles, orphan modules, packages you import but never listed in package.json, and production code that pulls in dev dependencies. Add your layer rule alongside them:

module.exports = {
  forbidden: [
    {
      name: "services-not-to-components",
      comment:
        "Services must not import UI. Move shared types to src/types or next to the service.",
      severity: "error",
      from: { path: "^src/services" },
      to: { path: "^src/components" },
    },
  ],
};

Write the comment as instructions, not as a label. The default err reporter leaves it out, but the err-long reporter prints it with every violation. The developer or agent that triggered the failure then reads the fix, not just the error:

npx dependency-cruiser --output-type err-long src

eslint-plugin-boundaries does the same job inside ESLint, if you’d rather keep this in your linter. oxlint has no built-in rule for architectural layers, but its no-restricted-imports rule can block imports that match a pattern.

Use Mutation Testing to Check Whether Tests Catch Breakage

Mutation testing checks test quality: line coverage shows that code ran, while mutation testing shows whether any test fails when that code is deliberately changed. A suite can reach 100% coverage and still assert almost nothing, and mutation testing is how you find out.

Stryker makes small changes to your source code, and each changed version is called a mutant. It then runs your tests against every mutant. A mutant is killed when any test fails against it, and it survives when the whole suite still passes.

Stryker’s own example mutates return user.age >= 18 four ways: > 18, < 18, return false and return true.

export function isUserOldEnough(user: { age: number }): boolean {
  return user.age >= 18;
}

// Weak: 100% line coverage, two survivors
it("lets adults in", () => {
  expect(isUserOldEnough({ age: 25 })).toBe(true);
});

// Added: boundary cases
it("lets in someone who is exactly 18", () => {
  expect(isUserOldEnough({ age: 18 })).toBe(true);
});
it("keeps out someone who is 17", () => {
  expect(isUserOldEnough({ age: 17 })).toBe(false);
});

A test that only checks a 25-year-old passes against both the > 18 mutant and the return true mutant, so both survive even though line coverage is 100%. That test kills only < 18 and return false. The age-18 case kills > 18, and the age-17 case kills return true. With all three tests, every mutant is killed.

Surviving mutants usually come from a few recognisable weak-test patterns:

  • Checking that a function was called without checking what it was called with.
  • Checking that a result exists, or how long it is, without looking at what it contains.
  • Committing a snapshot that nobody checked against expected output.
  • Computing the expected value with the same logic as the code under test.
  • Checking what a mock returns instead of what the code does with it.

A surviving mutant points to behaviour that no test pins down. Some mutants are equivalent to the original code, though, so the mutation score works better as a detector than as a pass/fail threshold. A hard threshold also rewards tests written to kill mutants rather than to check behaviour. Stryker’s thresholds.break fails the build below a set score. It defaults to null, and you should leave it there. Read the survivors on the PR instead.

To keep runs short, mutate only the files a PR changed with npx stryker run --mutate <comma-separated files>. Incremental mode (--incremental) keeps the last run’s results in reports/stryker-incremental.json and reuses what it can, but in CI that only saves time if the file is cached between runs. Install the test-runner plugin for your framework too (@stryker-mutator/vitest-runner or @stryker-mutator/jest-runner). Without one, Stryker falls back to its command runner, which runs the whole suite for every mutant. For the judgement side of test quality, which is deciding what deserves a test at all, see our piece on testing in the AI era.

Run Each Check as a Separate Required CI Job

A check you can skip isn’t enforced. Give each check its own job so a failure is obvious at a glance, then mark each job as a required status check in your branch protection or ruleset. If GitHub Actions is new to you, start with this practical guide to GitHub Actions.

on: pull_request
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx eslint . && npx prettier --check .
      - run: npx dependency-cruiser --output-type err-long src
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx tsc --noEmit
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm test
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - name: Mutate changed files only
        run: |
          FILES=$(git diff --name-only --diff-filter=AMR "origin/${{ github.base_ref }}...HEAD" -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' \
            | grep -vE '\.(test|spec)\.tsx?$' | paste -sd, -)
          if [ -z "$FILES" ]; then echo "No source changes"; exit 0; fi
          npx stryker run --mutate "$FILES"
  build:
    needs: [lint, typecheck, test, mutation]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run build

The mutation job fetches full history (fetch-depth: 0) so it can diff against the base branch. It then passes only the changed source files to Stryker, leaving test files out.

Roll new rules out gradually:

  1. Add each new rule as a warning. In dependency-cruiser, severity: "warn" reports violations but doesn’t fail the job. Only error does.
  2. Run the checks only on changed files.
  3. Promote a warning to an error once it catches a real bug, and delete rules that only produce noise.
  4. Make a job required only after it has settled.

Two more cheap automated checks are worth adding: Knip for dead code and jscpd for duplication. Knip reports files nothing imports, exports nobody uses and packages you no longer need. jscpd flags duplicated code blocks.

What Can’t Automated Code Review Checks Tell You?

Deterministic checks can’t tell you whether the feature is the right one, whether an abstraction came too early, or whether anyone on the team understands the code. Those are judgement calls, and they need a person.

A pull request that passes every deterministic check has only been shown to be free of the defects those checks look for. For the human side of review, see our testing-in-the-AI-era piece.

Start With the Contract

Every mechanical question you hand to a machine gives human review time back for questions only humans can answer. If you add just one check this sprint, generate your API client from the spec, because it stops a whole class of defect before anyone writes it. Add mutation testing on the PR diff next, as a warning-only job, and read what survives.

FAQs

How do I add dependency-cruiser rules to an existing codebase that already has hundreds of violations?

Record the existing violations as a baseline and fail only on new ones. As of dependency-cruiser 18.3.0, the recommended way is the --baseline option. By default it writes .dependency-cruiser-known-violations.json to the project root, and running with --ignore-known lowers every violation listed there to ignore. Any new import that breaks the same rule still fails. Because the match ignores severity, changing a rule's severity leaves its baseline entries in place.

Does Stryker mutation testing work with Vitest?

Yes. StrykerJS added Vitest support in version 7.0 through the @stryker-mutator/vitest-runner plugin, and Jest projects use @stryker-mutator/jest-runner. The Vitest runner always uses perTest coverage analysis and ignores any coverageAnalysis setting you configure. Vitest Browser Mode is not supported. Stryker runs tests in a single thread per worker and parallelises across its own workers instead.

What is an equivalent mutant in mutation testing?

An equivalent mutant changes the source code without changing what the program does, so no test can ever kill it. A classic example is replacing i < length with i != length in a loop that counts up by one from zero: both stop at the same point. Because equivalent mutants always survive, a 100 percent mutation score is often unreachable, and survivors need a human to read them.

What happens when Stryker runs on changed files that have no tests?

By default, Stryker stops with a No tests found error when its initial dry run executes no tests, which fails a CI job that only mutates the PR diff. From StrykerJS 7.0, the --allowEmpty flag (or allowEmpty: true in the config file) stops that from being an error. Mutants in code that no test executes are reported as having no coverage, which points straight at the missing tests.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.