# 10 anti-AI slop moves for frontend projects going faster than humans can review

> Ten checks that catch what AI-written frontend code hides: contract codegen, boundary linting, mutation testing, and dead-code detectors.

- Date: 2026-09-01T00:00:00.000Z
- Authors: Yuri Mikhin, Travis Turner
- Categories: DX, AI
- URL: https://evilmartians.com/chronicles/ten-anti-ai-slop-moves-for-frontend-projects-going-faster-than-humans-can-review

---

Ten thousand lines of code in a week used to mean a team. With that team came institutional memory, and every bit of code had a person who knew why it looked as it did. Now one "person" can put out ten thousand lines in a week. But our new "co-authors" don't remember what we agreed yesterday, don't have to answer for production, and won't be around in six months to explain their decisions. That responsibility is still on us, only now with a codebase that's grown several times faster. (Whether more code means more useful work is [another story](/chronicles/so-your-developers-use-ai-now-here-is-what-to-know)). In this post, 10 checks that make rapidly generated code safer to trust, cheaper to review, and harder to let decay go unnoticed.

Everything below is frontend on TypeScript and React, agents circa 2025-2026. While the model defaults will shift, and half of these tools will have different names in a year, the categories probably won't.

Understanding used to be considered a by-product of writing. Psychology calls this the **generation effect**: what you produce yourself is remembered better than something you're handed to read. This has been studied since the 70s; see this [meta-analysis](https://link.springer.com/article/10.3758/s13423-020-01762-3).

These checks are split into three kinds. First, keep the extra code from being written: contracts, types, boundaries, custom rules. Then find out what's already written: mutation testing, dead-code detectors, duplicates. And finally make all of it mandatory, because a check you can skip isn't a check.

## 1. Stop guessing API fields with OpenAPI

One of the most recognizable defects in AI-written code is a field that doesn't exist. The model sees a user nearby and writes `user.fullName`. The backend actually returns `firstName` and `lastName`. If the frontend type was defined separately from the API contract, it can be wrong in the same way. TypeScript accepts `user.fullName`, even though the backend never sends that field.

An almost-right field belongs to a larger category. In this [2025 Stack Overflow survey](https://survey.stackoverflow.co/2025/ai), 66% of respondents said their biggest frustration with AI tools was getting solutions "almost right, but not quite." This answer was the clear leader, too.

Language models are built to infer the shape of data from context. An API contract removes that guesswork. An OpenAPI spec gives both sides one description of the API: which endpoints exist, which fields go out and come back, and which are required.

(This problem predates AI. Without a contract, the same API shape often exists in multiple places: one definition on the backend, another on the frontend, with neither guaranteed to be authoritative. AI has simply made hand-written descriptions cheap on both sides.)

[Hey API](https://github.com/hey-api/openapi-ts) generates types, a client, and [Zod](https://github.com/colinhacks/zod) schemas from the spec. After that `user.fullName` doesn't compile: no such field in the contract. Zod stays as the second gate, because types live at compile time, the response arrives at runtime, and the model trusts the type.

We've covered how to build this end to end in a separate series: [the approach](/chronicles/api-contracts-and-everything-i-wish-i-knew-a-frontend-survival-guide), [documentation](/chronicles/contract-shock-therapy-the-way-to-api-first-documentation-bliss), [the frontend](/chronicles/lifes-too-short-to-hand-write-api-types-openapi-driven-react), [Fastify](/chronicles/openapi-fastify-backend-let-the-contract-build-your-server), and [NestJS](/chronicles/openapi-nestjs-type-safe-controllers-from-the-contract).
*Read also Evil Martians' article*: https://evilmartians.com/chronicles/api-contracts-and-everything-i-wish-i-knew-a-frontend-survival-guide

The generated client still lives in the repository, but nobody needs to write or review it line by line. It's regenerated from the spec, and any manual edits will be overwritten. That shifts review from hundreds of lines of generated code to the much smaller contract that produced them.

This only works if the contract is authoritative and the backend keeps it current. If there is no spec and none is coming, skip to the next check.

{% heading %}## 2. Turn on strict TypeScript{% endheading %}

Start with `strict: true`. It enables a group of checks that force the code to be more explicit about what values can exist and where assumptions are being made.

Then add `noUncheckedIndexedAccess`. Without it, TypeScript treats `arr[0]` as if an element is definitely there. With it enabled, the type becomes "element or undefined," which forces the code to account for an empty array instead of assuming one away.

`exactOptionalPropertyTypes` is another useful flag for the same reason; it narrows the difference between what the type says and what the value can actually be.

Of course, this isn't a complete tsconfig, just the part that removes some of the easiest places for generated code to make confident assumptions.

---

*We've built mutation testing, linting rules, and CI gates for teams shipping with AI. Set up the checks to catch what AI-generated code hides!* [Contact Evil Martians](https://evilmartians.com/contact-us)

---

## 3. Use the linter as a behavior filter

A linter config has stopped being a style document; it's now a filter aimed at a specific writer whose defaults are known. There are plenty of rules enabled, and going through the whole list is pointless. Here are the ones that work best against the slop.

- [**sonarjs**](https://github.com/SonarSource/SonarJS) takes up about half the set and does the heavy lifting. It sees what an individual function can't show: cognitive complexity, identical branches, duplicated string literals, nested control flow. Generated code reads beautifully line by line and piles up structure nobody chose at the file level.
- **A detector for unnecessary effects**, currently [react-you-might-not-need-an-effect](https://github.com/nickjvandyke/eslint-plugin-react-you-might-not-need-an-effect). This is the most frequent AI edit in our practice. It tends to reach for effects for state synchronization, derived values, and prop changes. That adds extra renders, more ways for state to drift, and more code to trace.
- [**@vitest/eslint-plugin**](https://github.com/vitest-dev/eslint-plugin-vitest) answers the cheap version of "does this test assert anything". `expect-expect` catches a test with no assertion, `no-focused-tests` catches a forgotten `.only` that turns one test green instead of a hundred. The linter can't see past that, a test with an assertion counts as a test. Whether the assertion claims anything is for [mutation testing](#7-use-mutation-testing-to-test-your-tests) to answer.
- [**eslint-plugin-playwright**](https://github.com/mskelton/eslint-plugin-playwright) does the same for end-to-end: conditionals inside a test, a forgotten `await` on an assertion, `.only`.
- [**jsx-a11y**](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) catches what a screenshot can't show. When AI optimizes for "looks right", accessibility is the first thing to go. Although, it works one file at a time. Drift across components and the bugs that only show up in composition need a different surface, which is what [Storybook Workbench](/chronicles/storybook-workbench-audit-vibe-coded-uis-and-find-hidden-bugs-in-hours) audits.

*Read also Evil Martians' article*: https://evilmartians.com/chronicles/storybook-workbench-audit-vibe-coded-uis-and-find-hidden-bugs-in-hours

Add the TypeScript rules that block common shortcuts: `no-explicit-any`, `no-non-null-assertion`, explicit return types on exports, and `consistent-type-definitions`. A non-null assertion is especially risky because it tells TypeScript to ignore a possible null or undefined value at one specific point. `consistent-type-definitions` is mostly a style rule, but it helps keep generated code consistent instead of switching between `interface` and `type` from file to file.

And two boring ones: filenames matching export names, imports only through aliases. They stay boring right up until you install [dead-code detectors](#8-find-dead-code-with-knip-and-fill-the-gaps-yourself). Those work on paths and names, and on a tree where files are named at random, they lie.

(It's worth separating this from [custom rules](#5-turn-recurring-mistakes-into-linter-rules): somebody else's preset goes in as a batch and takes one evening, no incidents required. Your own rule is a different conversation.)

## 4. Set layer boundaries before the app drifts toward the GitHub average

[**eslint-plugin-boundaries**](https://github.com/javierbrea/eslint-plugin-boundaries), [**dependency-cruiser**](https://github.com/sverweij/dependency-cruiser), and `boundaries` in [oxlint](https://github.com/oxc-project/oxc) all do the same thing, they declare the layers of the app and declare which layers are allowed to import which.

With AI, these boundaries matter more because the model does not remember your architecture from one session to the next. Left unconstrained, it tends to reproduce patterns common across other repositories, even when those patterns don't belong in yours. The result is gradual architectural drift, and AI pulls the app toward the GitHub mean, one reasonable-looking import at a time.

The exact boundaries depend on the application, but here are rules that keep paying off for us:

- Dependencies run in one direction. Components can depend on services; services cannot depend on components.
- Utilities, types, and configuration stay free of domain logic.
- The design system stays isolated from application state, API calls, and domain code so it can still be updated or replaced independently.
- Mocks stay out of production builds.
- The router maps routes to pages and does nothing else.

When a service imports a component, the check fails with a message like this:

```
x boundaries(dependencies): Business layers stay UI-free: a service, store, lib
  or type file must not import components, pages or hooks. A type shared with
  a form belongs in src/types or next to the service.
```

This explanation matters as much as the failure because it tells the developer or agent, not only what is wrong, but where the dependency should go instead.

## 5. Turn recurring mistakes into linter rules

Writing your own rule is now cheap. It used to mean parsing an AST, reading docs, and half a day's work for one check. (Again, it also meant "we don't do that here" knowledge lived in people's heads and PR comments.) Now, a working rule takes ten minutes with AI.

This isn't a frontend-only shift: [custom RuboCop cops](/chronicles/writing-custom-rubocop-rules-in-2026) got the same treatment on the Ruby side.
*Read also Evil Martians' article*: https://evilmartians.com/chronicles/writing-custom-rubocop-rules-in-2026

So, a rule stopped being an investment and has become a normal response to a recurring mistake. But unlike [somebody else's preset](#3-use-the-linter-as-a-behavior-filter), your own needs a reason to exist. Two kinds are worth noting:

**A behavior filter** cancels a default AI brings in from outside. If the common way to do something is wrong in your codebase, explaining it in a rules file won't help. A million repositories vote for the common version, and the rules file gets read once per session.

**A scar** encodes a bug you already shipped.

Here's ours, and it's about as mundane as it gets.

```
Services compute, formatters round.
Rounding twice moves the value off its true one: Math.round(h * 10) / 10
turned a 26h15m total into 26h18m.
```

The rule appeared after rounding applied twice along the way turned a 26 hours 15 minutes total into 26 hours 18 minutes. The service rounded hours to a tenth, the formatter rounded again. That double rounding added three minutes to the total. The bug took half a day to find because each rounding step looked correct on its own.

The rule bans `Math.round` and `toFixed` in anything a service returns, and prints the reason.

The test for whether a rule deserves to exist is simple: if you can't name the incident behind it, don't write it.

## 6. Put the rules in front of the model

Everything above catches problems after the code is written. Another option is to put some of those rules in front of the model while it works.

The usual setup is a project rules file that the agent reads at the start of a session, plus skills or tools that add more specific guidance. Those rules can cover anything from hard bans to the [the taste of a particular engineer](/chronicles/vibe-coding-in-style-dot-md). [react-doctor](https://github.com/millionco/react-doctor), for example, can run as a project scan and also be installed as a skill, so the model sees its rules while generating code instead of only after the fact.

*Read also Evil Martians' article*: https://evilmartians.com/chronicles/vibe-coding-in-style-dot-md

There is a trap here: tools like this often produce a single score across security, performance, and architecture. Treat that score as a diagnostic, not a target. The same warning applies to [mutation scores](#7-use-mutation-testing-to-test-your-tests): once the score becomes the goal, the agent can optimize for the number rather than the quality of the code.

Prevention is probabilistic, and that's a limitation here. A rule in a file is competing with forty other lines and with a million repositories in the model's weights. It lowers the frequency without guaranteeing anything, and it cancels none of the checks below. This is an argument for [moving the safeguards out of the rules file](/chronicles/stop-writing-rules-in-agents-md-use-agent-hooks-and-nano-staged-instead) and into hooks that run the linter on changed files: same session, same feedback loop, no competition for attention.

## 7. Use mutation testing to test your tests

Coverage tells you a line ran and mutation testing tells you whether a test would notice that line breaking.

[Stryker](https://github.com/stryker-mutator/stryker-js) breaks the code on purpose: flips `<` to `<=`, empties an array literal, inverts a condition, swaps a return value. Then it shows the changes that no test had caught.

Surviving mutants usually point to tests that execute the code without checking its behavior closely enough:

- Shape instead of content: `toHaveBeenCalled()` instead of `toHaveBeenCalledWith(...)`, `toBeDefined()`, `toHaveLength(3)` on a list nobody looked inside
- A snapshot created from output nobody verified. It can preserve an existing bug as the expected result
- An assertion that repeats the implementation. If the test uses the same flawed logic as the code, both can be wrong in the same way
- Checking the mock instead of the code: the test confirms that the mock returned what was put into it

All of these tests can stay green while still giving you 100% coverage. Saying "AI writes bad tests" misses the point. The model can produce tests that satisfy the request and pass the suite without testing the behavior that actually matters. A green test run no longer tells you that someone made that judgment.

Don't set a failure threshold. Equivalent mutants will always exist, and a valuable test may not move the score at all. Once the score becomes the goal, you get tests written to kill mutants rather than test useful behavior. So, treat mutation testing as a detector instead of a KPI.

Remember that it isn't free either. On our project, that's around 4,700 mutants and roughly six minutes of an incremental run. In CI it's worth narrowing to files changed in the pull request, otherwise every PR pays for the whole repository's history.

## 8. Find dead code with Knip and fill the gaps yourself

AI adds code much more readily than it removes it. Unless deletion is part of an automated check, unused files, exports, and dependencies tend to accumulate.

Start with [knip](https://github.com/webpro-nl/knip). It finds files nobody imports, exports nobody uses, and dependencies that are no longer needed. Knip also takes little effort to add to an existing project.

Then, work with exceptions starts. Knip's `ignore` list will grow, as you account for generated files, unusual imports, and other cases it cannot detect correctly. Review that list periodically. Every ignored path is a part of the codebase the detector has been told not to inspect.

General-purpose tools will not catch every problem specific to your repository. A small Node script can fill those gaps, and AI has made scripts like these much cheaper to write.

One of ours walks the import graph from the application's entry point and records every file it can reach. Anything outside that graph is potentially dead code. Knip asks whether something is imported; our script asks whether it is reachable from the running application. Because the script knows our aliases, generated client, and design-system conventions, it can make assumptions that a general-purpose tool cannot.

A second script checks stale `vi.mock()` declarations. A mock can stop matching the code it was written for while the test continues to pass. TypeScript only sees the module path as a string, the linter sees a function call, and coverage can still report the code as covered. The script resolves the path from `vi.mock()`, reads the module's real exports, and compares them with the factory's keys:

```js
const target = resolveModule(specifier, specPath);
if (!target) continue;

const real = exportsOf(target);
const missing = keys.filter((key) => !real.has(key));

if (missing.length > 0) {
  findings.push({ file, line, specifier, reason: `not exported: ${missing}` });
}
```

Before this script existed, we had no way to know whether the project contained four stale mocks or forty. Now the check produces a concrete count on every run. That is the main value of small detectors like these: they turn an unknown problem into something measurable.

Custom detectors need tests of their own. They are often written with the same AI they are meant to police, and a detector that silently misses problems can create false confidence. Our graph walker, for example, could miss an entire branch if someone adds an alias it does not understand. At minimum, test the detector against a deliberately dead file that it must find.

## 9. Find duplicate code with jscpd

AI often writes a second implementation instead of finding the first. Searching a large codebase for an existing helper costs time and tokens, with no guarantee the search will succeed. Writing a new function is faster, so duplicate implementations accumulate.

This seems to happen beyond our repo. [GitClear](https://www.gitclear.com/the_ai_code_quality_maintainability_gap) went through 623 million changes over the last three years: block duplication is up 81%, while the share of moved code, meaning refactoring, dropped fivefold. The data is observational and the lines aren't tagged as AI-written, so it's early to treat it as proof. There's a result pointing the other way too: in [GitHub's experiment](https://github.blog/news-insights/research/does-github-copilot-improve-code-quality-heres-what-the-data-says/), code written with Copilot scored slightly better than usual on readability and maintainability.

Duplicate code is not automatically a sin. The risk is that two copies start to diverge. In our case, the same payroll total showed 23 hours 30 minutes in one place and 0 hours 00 minutes in another.

[jscpd](https://github.com/kucherenko/jscpd) catches copy-pasted code by looking for textually similar blocks. The important configuration is to use different thresholds for markup and logic. Repeated JSX can be intentional; repeated business logic is much more likely to become a maintenance problem.

The limit is that jscpd only finds textual duplication. Two separately written implementations of the same rule can look different enough that it will miss them. That means semantic duplicates still require human review, which is what our [last section](#what-none-of-this-catches) is about.

## 10. Enforce every check in CI

A check only becomes enforceable when CI blocks the branch if it fails. The only place where a check is mandatory is a pipeline that won't let the branch through. The setup is boring, which is why it works:

- Formatting and linter
- `tsc` as its own job
- Unit tests
- Mutation testing on the pull request diff
- A build

Deploys should depend on those jobs and start only after they pass. Keep the checks separate rather than hiding them behind one script. If `typecheck` fails, you should see that immediately instead of digging through a combined log.

## What does this all cost?

The list above may seem like pure profit, but there's a bill.

Let's start with false positives, i.e. noise. sonarjs will flag normal code, and jscpd will flag repetition in tests and fixtures where duplication may be intentional. The risk is that exceptions accumulate quietly. A growing list of `eslint-disable` comments and ignored paths is a sign that the checks are losing coverage.

Rules also go stale. If a rule has not caught anything useful in a year, review whether it still belongs. Remove it temporarily and see whether another check already covers the same problem.

Then, there's time. Mutation testing adds minutes to each PR, and the detector test from [the section above](#8-find-dead-code-with-knip-and-fill-the-gaps-yourself) needs maintaining on top of that.

The biggest cost is onboarding. A new developer can hit hundreds of rules before they understand why those rules exist. That makes clear error messages important. Each one should explain not just what failed, but why the rule exists and what to do instead.

## Rollout order

The advice to "turn on strict and four hundred rules" on an existing repository usually produces thousands of errors on the first run and an abandoned effort soon after. Roll them out gradually instead.

1. First, establish a baseline. New rules go in as warnings, old code is left as-is, and the check runs only on changed files. New or modified code has to pass; the existing backlog can be handled separately.
2. Then promote rules one at a time. If a warning catches a real bug, promote it to an error so future violations fail the check. A rule that produces nothing but noise for a month gets turned off.
3. Add the CI gate only after the checks have settled. Otherwise, developers start working around the checks instead of fixing what they catch, and those workarounds tend to stick around.

Some codebases don't need all of this. If there are three of you, you're looking for product-market fit, and half the code will be dead in a month, this setup may cost more than it saves. It tends to pay off under three conditions: the code outlives a quarter, someone other than the author reads it, and bugs have consequences outside the development team.

If you're starting with exactly one thing, take the [contract](#1-stop-guessing-api-fields-with-openapi): it removes a whole class of defects before they're written. Mutation testing goes second, it answers whether there's anything underneath you at all, and the [gate](#10-enforce-every-check-in-ci) is what keeps either of them from being skipped.

## What none of this catches

No single check here will tell you that a feature is wrong, that an abstraction was introduced too early, that some state should live in the URL, or that this is the third pattern in the codebase solving the same problem. Taste, architecture, and product judgment are still on us.

Further, a green run of all ten checks doesn't mean anyone understood the code. It means only that the checks found none of the problems they were designed to detect. They cannot produce understanding. So remember, this post isn't about stopping code review; it's about using automation to reduce how much code people need to manually inspect.

---

**Tame AI-Generated Code** We've built mutation testing, linting rules, and CI gates for teams shipping with AI. Set up the checks that catch what models hide—contract codegen, boundary linting, dead-code detectors, and mutation testing workflows. Hire us to build it right.
 [Contact Evil Martians](https://evilmartians.com/contact-us)
