AI-Generated Code: 23 Tells That Reveal Claude, GPT, or Cursor Wrote It (2026 Field Guide)
A field guide of 23 specific code patterns that reveal an LLM wrote the code — useless comments, premature abstractions, defensive try/catch noise, generic naming — with refactor recipes for each.
You can spot AI-generated code from across the room. Not because it's wrong — most of it runs — but because it has a smell. A particular kind of tidy, defensive, over-explained, generically-named tidiness. The kind a senior dev would never write because they have better things to do than wrap a string concat in a try/catch.
This is a field guide. Twenty-three specific patterns that, when you see two or three of them in the same file, mean an LLM wrote it. Probably Claude, probably GPT-5 via Cursor, probably without anyone reading the diff carefully before it merged. Each tell comes with a code sample, an explanation of why the model defaults to that pattern, and a refactor recipe.
This is not a moral panic about AI tools. We use them. They are, on a good day, faster than typing. The problem is that the median output ships unedited and accumulates in codebases like sediment, and after a year you have a 40,000-line repo where every file looks like it was written by the same slightly-over-cautious junior who learned to code last Tuesday.
If you want the broader picture of what AI slop is doing to the web, see The 2026 State of AI-Generated Web Slop. If you want patterns that go the other direction — distinctive, signature code instead of generic — see 73 Patterns from AI Slop to Signature. For the prompt-side counterpart, the anti-slop prompt template explains how to stop the generation from emitting these defaults in the first place.
This guide is about reading code that already exists. Auditing it. Reviewing it. Deciding whether to merge it.
---
TL;DR — the 23 tells
| # | Tell | Severity (1-5) | Refactor effort | |---|------|---------------|-----------------| | 1 | Comments that restate the code | 2 | trivial (delete) | | 2 | Premature try/catch around code that can't throw | 3 | low | | 3 | Defensive if (!x) return null for impossible nulls | 3 | low | | 4 | Generic naming: data, result, handler, manager | 4 | medium | | 5 | Over-abstracted 3-line functions wrapped in classes | 3 | medium | | 6 | JSDoc on internal functions that adds nothing | 2 | trivial | | 7 | console.log('x:', x) left in production | 2 | trivial | | 8 | cn = (...args) => args.filter(Boolean).join(' ') reinvented per file | 3 | low | | 9 | useState with explicit type for inferable types | 1 | trivial | | 10 | useCallback / useMemo on everything | 4 | medium | | 11 | import * as React from 'react' when only useState is used | 1 | trivial | | 12 | Tailwind classes in inconsistent order | 2 | trivial (formatter) | | 13 | Mixed style + className with redundant rules | 3 | low | | 14 | Repeated identical 3-line snippets, no helper | 4 | medium | | 15 | interface IFoo vs type Foo inconsistency | 2 | low | | 16 | try { } catch (err) { console.error(err) } swallow | 5 | medium | | 17 | as any or as unknown as X casts | 4 | medium-high | | 18 | Triple-nested ternaries | 3 | low | | 19 | // TODO: improve later left forever | 2 | trivial | | 20 | Empty useEffect(() => {}, []) placeholders | 3 | trivial | | 21 | {...props} spread on the wrong element | 4 | low | | 22 | README that says "TODO: add real description" | 2 | low | | 23 | Test files that test the mock, not behavior | 5 | high |
Severity is "how much pain this causes per occurrence in a real codebase," not "how ugly it looks." A swallow-catch (#16) is worse than a useless comment (#1) because the swallow-catch hides bugs forever while the comment just wastes a line.
If you read nothing else: tells #4, #14, #16, #17, and #23 are the ones that compound. They make code reviews harder, bugs more frequent, and onboarding miserable. Hunt those first.
---
Why detecting AI code matters
There are four reasons to care about this skill.
Code review. When you review a PR, you are not just checking correctness. You are checking that the code fits the rest of the codebase, follows internal conventions, and won't make future maintenance harder. AI code, even when correct, often violates all three. It shows up in a codebase that uses tagged unions, and the AI writes a switch on a string field. It shows up in a project where the team uses Result types, and the AI wraps everything in try/catch. Review is where this gets caught — if you can see the tells.
Hiring. When a candidate submits a take-home or shares a public repo, the gap between "wrote this themselves" and "asked Claude to write this" matters. Not because using AI in 2026 is suspicious — almost everyone does — but because the candidate's actual skill is what you're measuring. A repo where every function has a JSDoc that paraphrases the function name, where every component is wrapped in useCallback, where comments restate the code, tells you the candidate either doesn't review their AI output or doesn't know what's wrong with it. Either way, useful signal.
Contractor audit. You hired an external dev to ship a feature. They delivered. The code "works." Should you accept it? Counting AI tells gives you a fast read on whether you're getting craftsmanship or volume.
The junior dev trap. This is the worst one. Juniors learn from the code around them. If the codebase is full of AI defaults — defensive try/catch, generic naming, over-memoized hooks — juniors absorb those defaults as "how code is written here." They propagate. Five years from now, an entire generation will write try/catch around JSON.parse of literal strings because their mentors' AI did it.
This is distinct from text slop or visual slop. Text slop wastes the reader's time but doesn't compound. Visual slop is ugly but isolated to one screen. Code slop accumulates. It calcifies into the foundation that future code is built on. Removing it later is expensive — sometimes more expensive than rewriting.
For frontend visual slop in particular, see De-AI your Lovable / v0 / Bolt site. For a state-of-the-discipline read on the practice itself, Vibe Coding 2026: Honest State of AI Frontends.
---
The 23 tells
Tell 1 — Comments that restate the code
Severity: 2/5. Refactor: trivial.
The most universal AI tell. The model has been trained on code with comments, so it produces code with comments, and most of those comments paraphrase the line below them.
Before:
// Add two numbers and return the result
function add(a: number, b: number): number {
// Sum the values
const sum = a + b;
// Return the sum
return sum;
}
// Loop through the array
for (let i = 0; i < items.length; i++) {
// Get the current item
const item = items[i];
// Process the item
processItem(item);
}After:
function add(a: number, b: number): number {
return a + b;
}
for (const item of items) {
processItem(item);
}Why LLMs default to this. Training data over-represents tutorial-style code, where every line is explained for pedagogical reasons. The model treats explanatory comments as "good practice" because they appear next to high-quality canonical examples. It doesn't have the context to know that in a production codebase, the comment-to-code ratio should be near zero except where intent is non-obvious.
Refactor recipe. Delete every comment that paraphrases the code. Keep only comments that explain *why*, not *what*. A useful test: if you remove the comment, can a competent dev still figure out what the code does in 10 seconds? If yes, the comment is noise.
A good comment explains a non-obvious decision: // Polyfill for Safari < 17 — remove after 2027. A bad comment explains a for loop.
Tell 2 — Premature try/catch around code that can't throw
Severity: 3/5. Refactor: low.
// AI version
function getUserName(user: { name: string }): string {
try {
return user.name;
} catch (err) {
console.error('Failed to get user name', err);
return '';
}
}
function formatPrice(price: number): string {
try {
return `$${price.toFixed(2)}`;
} catch (err) {
return '$0.00';
}
}Both try blocks are dead. Property access on a typed parameter cannot throw in JavaScript. toFixed on a number cannot throw. The catch blocks will never execute, but they will:
- Make the code harder to read.
- Convince the next reader that the function *can* throw, leading them to add their own try/catch upstream.
- Confuse the type checker if you use exhaustiveness checking on errors.
After:
function getUserName(user: { name: string }): string {
return user.name;
}
function formatPrice(price: number): string {
return `$${price.toFixed(2)}`;
}Why LLMs default to this. Defensive coding is over-represented in training data. The model has seen many real examples of try/catch around I/O, and generalizes that pattern to "any function that does anything." It doesn't reason about which operations actually throw.
Refactor recipe. For every try/catch, ask: what specifically inside this block can throw? List them. If the list is empty, delete the try/catch. If the list is "any one of fifteen things," tighten the scope so only the throwing line is wrapped.
Tell 3 — Defensive `if (!x) return null` for impossible nulls
Severity: 3/5. Refactor: low.
// Component receives `user` as a required, typed prop
function UserCard({ user }: { user: User }) {
if (!user) return null;
if (!user.name) return null;
if (!user.email) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}If user: User is non-optional, and name and email are non-optional fields on User, all three guards are dead branches. They exist because the model sees a prop, defaults to "props might be undefined," and writes a guard regardless of the type signature.
After:
function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}If the type *is* nullable in your domain — say, the API can actually return a user without an email — make that explicit:
function UserCard({ user }: { user: User & { email?: string } }) {
return (
<div>
<h2>{user.name}</h2>
{user.email && <p>{user.email}</p>}
</div>
);
}Why LLMs default to this. Same reason as Tell 2 — defensive defaults trained into the corpus. The model also doesn't reliably read the type signature in the same prompt-pass; it generates the body conditioned on the function name and a vague sense of "render a user."
Refactor recipe. For every early return based on a falsy check, verify the type allows the falsy value. If it doesn't, delete the guard and trust the type.
Tell 4 — Generic naming: `data`, `result`, `handler`, `manager`, `processor`
Severity: 4/5. Refactor: medium.
// AI version
async function getData(id: string) {
const result = await fetch(`/api/items/${id}`);
const data = await result.json();
return data;
}
class DataManager {
private data: any[] = [];
process(item: any) {
const result = this.handler(item);
this.data.push(result);
return result;
}
private handler(item: any) {
return { ...item, processed: true };
}
}Six different things named data, result, or one of the four interchangeable nouns. None of them carry meaning. A reader scanning the file has to mentally re-parse what data refers to in each scope.
After (with domain naming):
async function fetchInvoice(invoiceId: string): Promise<Invoice> {
const response = await fetch(`/api/invoices/${invoiceId}`);
return await response.json();
}
class InvoiceCache {
private invoices: Invoice[] = [];
add(invoice: Invoice): TaggedInvoice {
const tagged = this.tagAsProcessed(invoice);
this.invoices.push(tagged);
return tagged;
}
private tagAsProcessed(invoice: Invoice): TaggedInvoice {
return { ...invoice, processed: true };
}
}The refactored version costs a few extra characters and saves every reader from having to figure out what data is.
Why LLMs default to this. Generic identifiers are safe — they fit anywhere, they pass linting, they don't require domain knowledge. The model picks them when it doesn't know enough about the surrounding code to choose a specific name. When you give the model "write a function to fetch data," it produces getData. The fault is partly in the prompt, but the model also defaults to generic naming even when context is available.
Refactor recipe. Banned generic identifiers in code review: data, result, handler, manager, processor, helper, util, service (when standalone), info, obj, arr. For each occurrence, rename to the specific domain noun. If you can't think of one, the function might be doing too much — split it.
Tell 5 — Over-abstracted 3-line functions wrapped in classes
Severity: 3/5. Refactor: medium.
// AI version
class StringFormatter {
private separator: string;
constructor(separator: string = ', ') {
this.separator = separator;
}
format(items: string[]): string {
return items.join(this.separator);
}
}
const formatter = new StringFormatter(', ');
const result = formatter.format(['a', 'b', 'c']);A class, a constructor, a private field, and an instance method — to avoid writing items.join(', '). The abstraction adds nothing; it removes nothing. It exists because the model was asked to "create a string formatter" and produced the OO-shaped answer it had seen most often in training.
After:
const result = ['a', 'b', 'c'].join(', ');Or if you genuinely need a reusable function:
const formatList = (items: string[], separator = ', ') =>
items.join(separator);Why LLMs default to this. Java and enterprise-style C# code is over-represented in training data, especially in beginner-tutorial form. The model has internalized "real code uses classes." It also gets confused by prompts like "create a Foo manager" — the word "manager" or "service" pulls it toward the OO shape.
Refactor recipe. Every class with one method and a single field is a function in disguise. Inline it. Keep classes only when you have actual instance state that mutates over the object's lifetime, or when polymorphism is genuinely useful.
Tell 6 — JSDoc on internal functions that adds nothing
Severity: 2/5. Refactor: trivial.
// AI version
/**
* Add two numbers
* @param a - The first number
* @param b - The second number
* @returns The sum of a and b
*/
function add(a: number, b: number): number {
return a + b;
}
/**
* Get the user
* @param id - The id
* @returns The user
*/
function getUser(id: string): User {
return userStore.get(id);
}The TypeScript signature already says everything the JSDoc says, plus more. The JSDoc descriptions paraphrase the parameter names. They have negative information value: a reader has to read them, parse them, and confirm they say nothing.
After:
function add(a: number, b: number): number {
return a + b;
}
function getUser(id: string): User {
return userStore.get(id);
}JSDoc is justified when:
- The function is part of a public API and you want hover-tooltips with usage notes.
- A parameter has a non-obvious meaning (
@param expiry - Unix seconds, NOT milliseconds). - The return value has caveats (
@returns null if the user opted out of analytics).
For internal add or getUser, it's pure noise.
Why LLMs default to this. Documented code is over-represented in training data, especially library code. The model has seen many examples of /** blocks before functions and treats them as a sign of quality. It doesn't distinguish between exported library API and internal one-liner.
Refactor recipe. Delete JSDoc that paraphrases the function name and parameters. Keep it only where it conveys information not already in the signature.
Tell 7 — Useless `console.log` left in
Severity: 2/5. Refactor: trivial.
async function loadOrders(userId: string) {
console.log('Loading orders for user:', userId);
const orders = await db.orders.findMany({ where: { userId } });
console.log('Found orders:', orders);
console.log('Returning orders');
return orders;
}Three logs, none of which would help debug a real problem. The first restates the function name, the second logs an entire array on every call (production-log-bloat-in-a-bottle), the third is a celebratory "we got here" announcement.
After:
async function loadOrders(userId: string) {
return db.orders.findMany({ where: { userId } });
}Or, if you actually need observability:
async function loadOrders(userId: string) {
const orders = await db.orders.findMany({ where: { userId } });
metrics.increment('orders.loaded', { count: orders.length });
return orders;
}Why LLMs default to this. During the code generation, the model is sometimes mimicking a debugging session. It also sees a lot of tutorial code with console.log for pedagogical reasons. It rarely knows what the project's actual logging pipeline is, so it falls back to console.log as a generic "make it observable" gesture.
Refactor recipe. ESLint rule no-console set to error for non-test files. Production code uses your structured logger or metrics, not stdout.
Tell 8 — `cn` reinvented in every file
Severity: 3/5. Refactor: low.
// File 1
const cn = (...classes: (string | undefined | false)[]) =>
classes.filter(Boolean).join(' ');
// File 2 (different repo, same file structure)
function classNames(...args: any[]) {
return args.filter(Boolean).join(' ');
}
// File 3
const joinClasses = (a: string, b?: string, c?: string) => [a, b, c].filter(Boolean).join(' ');Every file that conditionally combines class strings reinvents the same function. The model produces a one-liner inline because it's faster than asking "is there already a cn utility?"
After:
// lib/cn.ts — one file, one source of truth
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));Then everywhere else:
import { cn } from '@/lib/cn';clsx + tailwind-merge is the canonical pair for Tailwind projects because it handles conditional inputs *and* deduplicates conflicting Tailwind classes (so cn('p-4', condition && 'p-6') resolves correctly).
Why LLMs default to this. The model has seen many cn definitions in training data. When it generates a component that needs conditional classes, it inlines the function rather than checking project conventions. This is also a chunking artifact — the model doesn't reliably re-use definitions across the same generation if they're far apart in context.
Refactor recipe. Grep your repo for filter(Boolean).join(' '). Every match is a candidate for replacement. Centralize once, import everywhere.
Tell 9 — `useState<string>('')` with explicit type for inferable types
Severity: 1/5. Refactor: trivial.
// AI version
const [name, setName] = useState<string>('');
const [count, setCount] = useState<number>(0);
const [items, setItems] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState<boolean>(false);TypeScript can infer all four types from the initial values. The explicit generics add nothing.
After:
const [name, setName] = useState('');
const [count, setCount] = useState(0);
const [items, setItems] = useState<string[]>([]); // explicit IS useful here — empty array
const [isOpen, setIsOpen] = useState(false);The items case is the exception — useState([]) would be inferred as never[]. There the explicit generic is correct.
Why LLMs default to this. Verbosity bias. Explicit types are "safer" in the model's training prior, even when redundant.
Refactor recipe. Lint rule: prefer inference for primitives with non-empty defaults. Allow explicit types for empty arrays, objects, and union types.
Tell 10 — `useCallback` / `useMemo` on everything
Severity: 4/5. Refactor: medium.
// AI version
function ProductList({ products }: { products: Product[] }) {
const handleClick = useCallback((id: string) => {
console.log(id);
}, []);
const sortedProducts = useMemo(() => {
return [...products].sort((a, b) => a.name.localeCompare(b.name));
}, [products]);
const total = useMemo(() => {
return products.reduce((sum, p) => sum + p.price, 0);
}, [products]);
const formattedTotal = useMemo(() => {
return `$${total.toFixed(2)}`;
}, [total]);
return (
<div>
<p>{formattedTotal}</p>
{sortedProducts.map(p => (
<Product key={p.id} product={p} onClick={handleClick} />
))}
</div>
);
}Four memoization calls in twelve lines. Every one has overhead — the dependency array comparison, the closure allocation, the React internal bookkeeping. Memoization is justified when:
- The computation is genuinely expensive (>1ms on slow hardware).
- The output is referentially stable and is passed as a dependency to another memoized thing or a memoized child.
- You have profiled and confirmed the unmemoized version causes a problem.
For a 100-item sort or a sum, none of those apply. The memoization wrapper is more expensive than the computation.
After:
function ProductList({ products }: { products: Product[] }) {
const sorted = [...products].sort((a, b) => a.name.localeCompare(b.name));
const total = products.reduce((sum, p) => sum + p.price, 0);
return (
<div>
<p>${total.toFixed(2)}</p>
{sorted.map(p => (
<Product key={p.id} product={p} onClick={(id) => console.log(id)} />
))}
</div>
);
}If Product is wrapped in React.memo and re-renders are demonstrably a problem, *then* useCallback for onClick is justified. Until then, it's anti-pattern theater.
Why LLMs default to this. Models trained on React tutorials see a lot of "always memoize" advice. The advice was never universally true; it's a reaction to the very real but narrow problem of over-rendering. The model can't tell the narrow case from the universal case, so it applies the rule everywhere.
Refactor recipe. Default to no memoization. Add it only when profiling shows a measurable problem. Use the React Compiler (or React Forget, depending on the year you're reading this) which automates the optimization decision.
Tell 11 — `import * as React from 'react'` when only `useState` is used
Severity: 1/5. Refactor: trivial.
import * as React from 'react';
export function Counter() {
const [count, setCount] = React.useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}The namespace import pulls in everything React exports. Some of it tree-shakes, some of it doesn't, depending on the bundler. Either way, the convention in modern React is named imports.
After:
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}Note: in React 17+ with the new JSX transform, you don't even need to import React for JSX itself.
Why LLMs default to this. The namespace import was the documented pattern in older React tutorials. Training data includes many of those. The model picks the older pattern when it's not certain about modern conventions.
Refactor recipe. Lint rule: prefer named imports from React. Codemod existing files in one PR.
Tell 12 — Tailwind classes in inconsistent order
Severity: 2/5. Refactor: trivial via formatter.
<div className="text-white p-4 flex bg-black hover:bg-gray-900 items-center mt-2 rounded-lg">
<span className="font-bold mr-2 text-lg">Title</span>
<span className="opacity-50 text-sm">Subtitle</span>
</div>Each className string is a random permutation of valid classes. Across a codebase, this means:
- Diffs are noisy (rearranging classes changes the diff even though behavior didn't change).
- Visual scanning of components is harder (you can't predict where layout vs color vs typography classes will appear).
- Conflicts between classes are easier to miss (
text-whiteandtext-gray-100both in the same string).
After (with prettier-plugin-tailwindcss):
<div className="mt-2 flex items-center rounded-lg bg-black p-4 text-white hover:bg-gray-900">
<span className="mr-2 text-lg font-bold">Title</span>
<span className="text-sm opacity-50">Subtitle</span>
</div>The plugin sorts classes by category (layout > flex > spacing > sizing > typography > backgrounds > borders > effects > transitions > variants). Once enabled, the order is enforced automatically and code reviewers don't have to think about it.
Why LLMs default to this. The model produces classes in the order it generates them mentally — usually the order corresponding to the visual description in the prompt. Different prompts produce different orders.
Refactor recipe. Install prettier-plugin-tailwindcss. Run prettier --write . once. Done.
Tell 13 — Mixed `style` + `className` with redundant rules
Severity: 3/5. Refactor: low.
<div
className="text-red-500 p-4"
style={{ color: 'red', padding: '16px', fontSize: '14px' }}
>
...
</div>Two definitions of color, two definitions of padding, fontSize defined inline. Now ask: which wins? It depends on CSS specificity, source order, and whether Tailwind's preflight changes anything. The answer is "I don't know without testing," which means the next person to touch this component will break something.
After:
<div className="p-4 text-sm text-red-500">
...
</div>Single source of truth. No specificity ambiguity. Reviewable.
Why LLMs default to this. The model is uncertain whether the project uses Tailwind, CSS-in-JS, plain CSS, or all three. When uncertain, it produces both a className (in case Tailwind is the system) and an inline style (in case it's not). The result is duplicated rules.
Refactor recipe. Pick one styling system per component. Forbid inline style for anything that could be a class. Allow inline style only for genuinely dynamic values (e.g., style={{ width: ${progress}% }}).
Tell 14 — Repeated identical 3-line snippets, no helper
Severity: 4/5. Refactor: medium.
// In file A
const date = new Date(timestamp);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const formatted = `${yyyy}-${mm}-${dd}`;
// In file B (60 lines later, or another file)
const d = new Date(order.createdAt);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const formattedDate = `${y}-${m}-${day}`;Same logic, copied. The model wrote it twice because each generation is independent — it doesn't check whether it has produced this snippet before.
After:
// lib/format.ts
export const formatDateISO = (input: Date | string | number): string => {
const d = new Date(input);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};Then both files:
import { formatDateISO } from '@/lib/format';
const formatted = formatDateISO(timestamp);(Better still: use date-fns or Intl.DateTimeFormat. But even the inline version centralized once is a major win.)
Why LLMs default to this. Per-call independence, weak cross-file awareness, and a bias toward inlining "small" logic. The model also doesn't reliably search the existing codebase before writing — even when you give it tools to do so.
Refactor recipe. Run a duplication-detection tool (jscpd, SonarJS, or your IDE's built-in). Every clone above 20 tokens is a candidate for extraction.
Tell 15 — `interface IFoo` vs `type Foo` inconsistency
Severity: 2/5. Refactor: low.
// File 1
interface IUser {
id: string;
name: string;
}
// File 2
type Order = {
id: string;
total: number;
};
// File 3
interface ProductData {
sku: string;
price: number;
}
// File 4
type IShipment = { // I-prefix on a type alias
carrier: string;
};Three different conventions in one project: IFoo interface (Hungarian-style, common in C#/Angular), type Foo, plain interface Foo, and type IFoo. The rest of the team will spend the next two years bikeshedding which to use.
After (pick one and enforce):
// Option A: type aliases everywhere except where extension is needed
type User = { id: string; name: string };
type Order = { id: string; total: number };
type Product = { sku: string; price: number };
// Option B: interfaces everywhere, no I-prefix
interface User { id: string; name: string }
interface Order { id: string; total: number }
interface Product { sku: string; price: number }Modern TypeScript leans toward type for object shapes unless you specifically need interface features (declaration merging, extends chains). Either choice is fine; mixing all three is not.
Why LLMs default to this. Different sources in training data use different conventions. The model picks based on local cues — if the prompt mentions "User", it might emit IUser because Angular tutorials with IUser are in the data. There's no strong global preference.
Refactor recipe. ESLint rule @typescript-eslint/consistent-type-definitions and @typescript-eslint/naming-convention. Codemod the existing repo.
Tell 16 — `try { } catch (err) { console.error(err) }` swallow
Severity: 5/5. Refactor: medium.
async function saveOrder(order: Order) {
try {
await db.orders.create(order);
await sendConfirmationEmail(order.customerEmail);
return order;
} catch (err) {
console.error('Error saving order:', err);
return null;
}
}The function "saved the order" (returns) and "didn't save the order" (returns null) — and the caller has no way to tell which without inspecting the return value, which is *less* informative than the original error. Worse, console.error writes to stdout, which in production usually goes to a log no one reads.
The function's contract is now: "tries to save an order, and either returns it or silently returns null with a server-log breadcrumb you'll never see." Bug reports for this kind of code are nightmares: "I clicked save but nothing happened." There's no error in the UI because we caught it. There's no error in the metrics because we logged to stdout. The bug is invisible.
After:
async function saveOrder(order: Order): Promise<Order> {
await db.orders.create(order);
await sendConfirmationEmail(order.customerEmail);
return order;
}
// At the call site, decide what to do with the error:
try {
const saved = await saveOrder(order);
showSuccess(saved);
} catch (err) {
reportError(err);
showFailure(err);
}The error is propagated. The call site decides how to handle it (show UI, retry, fall back, alert ops). The function's contract is honest: it either succeeds or throws.
Why LLMs default to this. Defensive coding bias plus an aversion to "letting errors bubble." The model treats try/catch as a generic safety wrapper and doesn't reason about the difference between "log and recover" and "log and silently fail."
Refactor recipe. Audit every catch block. For each one, answer: does this catch handle the error meaningfully (retry, fall back, transform, propagate as another error type)? If the answer is "log and return null," delete the catch and let it propagate.
Tell 17 — `as any` or `as unknown as X` casts
Severity: 4/5. Refactor: medium-high.
function processInput(input: unknown) {
const data = input as any;
return data.value.toString();
}
const config = JSON.parse(rawConfig) as unknown as AppConfig;
const el = document.querySelector('.target') as any;
el.style.display = 'none';Every cast is a confession: "I know the type system thinks this is wrong, but I want to do it anyway." Sometimes the cast is justified (querySelector returning Element | null when you know the element exists). Often it's a workaround for a type mistake earlier in the chain.
After:
function processInput(input: { value: number | string }) {
return String(input.value);
}
const config = AppConfigSchema.parse(JSON.parse(rawConfig));
const el = document.querySelector('.target');
if (el instanceof HTMLElement) {
el.style.display = 'none';
}The first cast becomes a real type. The second uses Zod (or Valibot, or io-ts) to validate the JSON shape. The third uses a runtime check that narrows the type.
Why LLMs default to this. Casts make the type errors disappear, which is what the model is optimizing for short-term. The model often doesn't have access to the full type graph, so when its generated code conflicts with an existing type, it papers over with as any.
Refactor recipe. ESLint rule @typescript-eslint/no-explicit-any. Custom rule to flag as unknown as X chains. For each cast, refactor to a real type, a runtime validator, or a narrowing check.
Tell 18 — Triple-nested ternaries
Severity: 3/5. Refactor: low.
<div>
{status === 'loading'
? <Spinner />
: status === 'error'
? <Error message={error?.message ?? 'Unknown'} />
: status === 'empty'
? <Empty />
: data
? <List items={data} />
: null}
</div>The intent is roughly readable, but the structure is brittle. Adding a fifth state requires editing three lines. Fixing a typo in the middle requires counting parentheses.
After:
function ListView({ status, data, error }: ListViewProps) {
if (status === 'loading') return <Spinner />;
if (status === 'error') return <Error message={error?.message ?? 'Unknown'} />;
if (status === 'empty') return <Empty />;
if (data) return <List items={data} />;
return null;
}
// At call site:
<div>
<ListView status={status} data={data} error={error} />
</div>Or a switch on a tagged union:
function ListView(state: ListState) {
switch (state.kind) {
case 'loading': return <Spinner />;
case 'error': return <Error message={state.message} />;
case 'empty': return <Empty />;
case 'list': return <List items={state.items} />;
}
}The switch is exhaustive — TypeScript will complain if you add a new state and forget to handle it.
Why LLMs default to this. Ternaries fit inline in JSX, which feels concise. The model picks ternaries when the prompt is "render conditionally" and doesn't elevate the conditional into a function. Each branch is generated with the previous one in scope, so they nest naturally.
Refactor recipe. Rule of thumb: more than two levels of ternary is a function. Extract.
Tell 19 — `// TODO: improve later` left forever
Severity: 2/5. Refactor: trivial.
// TODO: handle errors properly
async function load() {
return fetch('/api/data').then(r => r.json());
}
// TODO: add types
function process(data) {
// TODO: optimize
return data.map(x => x * 2);
}
// TODO: refactor this whole file// TODO without an owner, a date, or a ticket number is decoration. It signals "I know this isn't great" without actually marking the work for action. After six months no one knows what improvement was supposed to happen, who was supposed to do it, or whether the issue is still relevant.
After:
// TODO(ops, 2026-Q3, ENG-1342): handle network errors with retry+backoff
async function load() {
return fetch('/api/data').then(r => r.json());
}Every TODO has:
- An owner (
ops,@robin, the team alias) - A target date or quarter
- A ticket reference
- A specific description of what should change
If the TODO can't be filled in with those four items, delete it. If the work matters, file a ticket. If it doesn't matter, the comment is noise.
Why LLMs default to this. TODO comments are common in training data. The model uses them as a "I'm not 100% sure this is right" marker without committing to fix it. There's no follow-through because the model has no follow-through.
Refactor recipe. Custom ESLint rule: TODO comments must match the format TODO(owner, date|quarter, ticket): description. CI fails on bare // TODO.
Tell 20 — Empty `useEffect(() => {}, [])` placeholders
Severity: 3/5. Refactor: trivial.
function Form() {
const [name, setName] = useState('');
useEffect(() => {
// initialize form
}, []);
useEffect(() => {
// handle name change
}, [name]);
return <input value={name} onChange={e => setName(e.target.value)} />;
}Both effects are empty. The first one was generated as a "mount lifecycle" placeholder; the second as a "name change handler" placeholder. Neither does anything. Both add to the React reconciliation cost and clutter the component.
After:
function Form() {
const [name, setName] = useState('');
return <input value={name} onChange={e => setName(e.target.value)} />;
}Why LLMs default to this. The model has been trained on tutorials that show useEffect patterns and emits them on autopilot when generating React components, regardless of whether the component needs side effects.
Refactor recipe. ESLint rule react-hooks/exhaustive-deps plus a custom rule that flags empty effect bodies. Delete on sight.
Tell 21 — `{...props}` spread on the wrong element
Severity: 4/5. Refactor: low.
function Button({ children, ...props }: ButtonProps) {
return (
<div className="wrapper">
<button {...props}>{children}</button>
<span {...props}>indicator</span>
</div>
);
}The spread on is a bug. If the parent passes onClick, both the button and the span get the click handler, and clicking the indicator triggers the action — surprising the user. If the parent passes disabled, the span receives it as an unknown attribute and emits a console warning. If the parent passes type="submit", same thing.
After:
function Button({ children, ...buttonProps }: ButtonProps) {
return (
<div className="wrapper">
<button {...buttonProps}>{children}</button>
<span aria-hidden>indicator</span>
</div>
);
}Spread props on exactly the element that should receive them — usually the outermost element of the component, or the explicit "primary" interactive element.
Why LLMs default to this. The model wants to "pass through" props but doesn't reason about which element should receive them. When in doubt it spreads on multiple, hoping at least one is right.
Refactor recipe. Audit every {...props} spread. For each, ask: is this element the right one to receive these props? If not, narrow the destination.
Tell 22 — README that says "TODO: add real description"
Severity: 2/5. Refactor: low.
A new project repo. The README:
# my-app
A web application built with React, TypeScript, and Tailwind CSS.
## Getting Started
npm install npm run dev
## TODO: add real descriptionThe README exists. It says nothing. Anyone arriving at the repo learns: it's a web app, it uses common tools, and someone meant to write more later but didn't.
After:
# Acme Invoice Portal
Internal tool for the finance team to upload, review, and export invoices to NetSuite.
## Who uses this
- Finance team (10 users) — primary daily users
- Audit (read-only)
## Why it exists
Replacing the legacy Excel + email workflow. Eliminates manual NetSuite entry.
## Stack
- Next.js 15 (app router)
- Postgres on Supabase
- NetSuite SuiteCloud API for export
## Local dev
`pnpm install && pnpm dev`. Requires `.env.local` (ask in #finance-tools).
## Deployment
Vercel main → prod. PRs → preview. See `docs/deploy.md`.
## Owner
@robin (#finance-tools on Slack).The good README answers: what does this do, who uses it, why it exists, how to run it, how to deploy it, who owns it.
Why LLMs default to this. The model produces a generic README skeleton because it doesn't know the project specifics. The "TODO: add real description" is the model's way of admitting it filled in placeholders.
Refactor recipe. Every repo's README needs the seven items above on day one. If it doesn't, the project is one bus-factor incident from being unmaintainable.
Tell 23 — Test files that test the mock, not behavior
Severity: 5/5. Refactor: high.
import { saveOrder } from './saveOrder';
import { db } from './db';
jest.mock('./db');
describe('saveOrder', () => {
it('calls db.orders.create', async () => {
const order = { id: '1', total: 100 };
await saveOrder(order);
expect(db.orders.create).toHaveBeenCalledWith(order);
});
it('calls sendEmail', async () => {
const order = { id: '1', total: 100, email: '[email protected]' };
await saveOrder(order);
expect(sendEmail).toHaveBeenCalled();
});
});Tests pass. Coverage report says 100%. But what did we test?
We tested that saveOrder calls db.orders.create and sendEmail. That's the *implementation*, not the *behavior*. If we refactor saveOrder to use a different DB layer or a queue, the tests break — even though the user-facing behavior is the same. If saveOrder has a bug that returns the wrong thing, the test passes — because we never check the return.
After:
import { saveOrder } from './saveOrder';
import { setupTestDb } from './testdb';
describe('saveOrder', () => {
it('persists the order and returns it', async () => {
const db = await setupTestDb();
const result = await saveOrder({ id: '1', total: 100 });
expect(result).toEqual({ id: '1', total: 100, status: 'saved' });
const stored = await db.orders.findById('1');
expect(stored).toEqual(result);
});
it('throws when the order has no total', async () => {
await expect(saveOrder({ id: '1' } as any)).rejects.toThrow(/total/);
});
});These tests check behavior. They survive refactors. They fail when actual user-visible behavior breaks.
Why LLMs default to this. Mock-based tests are the most common testing pattern in JavaScript training data. They're easy to generate because the model knows the mock API. Behavior-based tests require setting up real infrastructure, which is harder to generate without project context.
Refactor recipe. For each test file, count: how many assertions check toHaveBeenCalled versus how many check actual return values or persistent state? If the ratio is mostly toHaveBeenCalled, the tests are theater. Rewrite to check observable behavior.
---
Tool-specific signatures
Different AI coding tools have different defaults. Knowing the dialect helps identify the source.
| Tool | Strongest tells | Weak spot | |------|----------------|-----------| | Claude Code (terminal) | Restating comments (#1), JSDoc (#6), TODO comments (#19), unnecessary helpers | Tends to write *too much explanation* in PR descriptions | | GPT-5 (Codex / canvas) | Generic naming (#4), defensive guards (#3), try/catch swallow (#16) | Verbose outputs, repeats work across files | | Cursor (composer) | useCallback/useMemo everywhere (#10), import * as React (#11), inline cn (#8) | "Apply to file" reformats and shuffles imports | | Aider | Mostly clean output, but inherits the underlying model's defaults | Sometimes leaves merge-conflict-style markers if the model gets confused | | Continue.dev | Mid-level — depends heavily on which model is configured | Inline edit mode produces shorter, less defensive code | | GitHub Copilot (legacy) | // AI suggestion placeholders, repeated 3-line patterns (#14), generic naming | Single-line completions are usually clean; multi-line less so | | Tabnine | Type annotations everywhere (#9), occasional outdated patterns | Less likely to invent libraries | | Codeium | Similar to Copilot, less verbose | Generic names slightly less common than GPT-5 |
These signatures are tendencies, not certainties. The same model used through different tools produces different output because the surrounding prompts differ. A Claude model in Claude Code writes one way; the same model in Cursor with Cursor's prompt scaffolding writes slightly differently.
The most reliable signature across all tools is the *combination* of tells. One tell could be a coincidence. Five tells in fifty lines is AI-generated.
---
Tell frequency in 100 audited PRs (ASCII chart)
Across 100 small-to-medium PRs reviewed in March 2026 across two private codebases (one Next.js consumer app, one internal admin tool), here's how often each tell appeared. "Appeared" means at least one occurrence in the PR.
Tell Appearances (out of 100)
#1 Useless comments ################################## 68
#10 useCallback/useMemo everywhere ############################### 61
#4 Generic naming ############################### 61
#9 Explicit type on inferable ######################### 50
#7 console.log left in ##################### 43
#19 Bare TODO comments #################### 40
#16 try/catch swallow ################## 35
#3 Defensive nulls ################## 35
#6 JSDoc on internal ################ 32
#14 Repeated snippets ################ 32
#12 Tailwind class order ############## 28
#15 Interface vs type ############# 26
#8 cn reinvented ############ 24
#17 as any / as unknown ########### 22
#2 Premature try/catch ########## 20
#11 import * as React ########## 19
#5 Over-abstracted classes ######### 18
#18 Triple ternaries ######## 16
#21 props spread wrong ####### 14
#23 Mock-only tests ###### 12
#20 Empty useEffect ##### 10
#13 Mixed style+className ##### 10
#22 Placeholder README ### 6The top three (useless comments, over-memoization, generic naming) show up in over half of all PRs we audited. Those three alone account for most of the "AI smell" you can pick up at a glance.
---
The "vibe coded" code review checklist (30-min protocol)
When a PR lands and you suspect it was vibe-coded — minimal human review, possibly minimal human reading — run this 30-minute protocol before merging. It's faster than letting the issues compound.
Minutes 0-3 — File scan. Open the diff. Don't read code yet. Count:
- How many new files?
- How many lines per file?
- Any file over 500 lines? Flag.
- Any file under 20 lines that wraps a one-liner? Flag.
- Test file present? If a behavior change has no test, flag.
Minutes 3-8 — Comment scan. Search the diff for //, /*, TODO, FIXME. For each:
- Does the comment add information not in the code? If no, flag for deletion.
- Is there a TODO without owner/date/ticket? Flag.
- Are there
console.logleft in? Flag.
Minutes 8-13 — Type and naming scan. Search for:
: any,as any,as unknown as. Count occurrences. Each one needs justification.- Variable names:
data,result,handler,manager,info,obj. Each one is a refactor candidate. useStatewhere the type is inferable.(default)
Minutes 13-20 — Defensive code scan.
- Every
try/catch— what specifically can throw inside? If nothing, flag. - Every
if (!x) return null— does the type allow null? If not, flag. - Every
useCallback/useMemo— is there a profiled reason? If not, flag.
Minutes 20-25 — Behavior check. Read the actual logic of the change. Ignore the boilerplate. Does the function:
- Do what the PR description says?
- Handle the obvious edge cases (empty input, network failure, race conditions)?
- Match the existing patterns in the codebase?
Minutes 25-30 — Test reality check. Open the new tests:
- Are assertions on return values / persistent state, or on
toHaveBeenCalled? - If you delete the function body, do the tests still pass? (Run mentally — if yes, the tests are theater.)
- Are there tests for failure cases, not just happy path?
If three or more flags appear in the protocol, the PR is not ready to merge. Send it back with the specific items, not a vague "looks AI-generated."
flowchart TD
A[PR opens] --> B{File scan: any flags?}
B -->|Yes| C[Comment author with specifics]
B -->|No| D{Comment scan: any flags?}
D -->|Yes| C
D -->|No| E{Type/naming scan?}
E -->|Yes| C
E -->|No| F{Defensive code scan?}
F -->|Yes| C
F -->|No| G{Behavior matches description?}
G -->|No| H[Block merge]
G -->|Yes| I{Tests check behavior?}
I -->|No| H
I -->|Yes| J[Approve and merge]
C --> K[Author revises]
K --> A---
What to do if your team ships AI code
The reality of 2026 is that your team ships AI code. Pretending otherwise is denial. The question is how to keep the quality bar without slowing the team to a crawl.
Review gates. Two-reviewer approval for any PR over 200 lines. One reviewer is enough for smaller changes, but the threshold catches most "I generated it, looks fine" merges.
Lint rules. Codify the worst tells as ESLint errors. Suggested rules:
{
"rules": {
"no-console": ["error", { "allow": ["warn", "error"] }],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/naming-convention": [
"error",
{ "selector": "interface", "format": ["PascalCase"], "custom": { "regex": "^I[A-Z]", "match": false } }
],
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error"
}
}Plus custom rules (Eslint plugin or codemod) for:
- Bare
// TODOwithout owner/ticket. - Empty
useEffectbody. useCallback/useMemoon inline lambda or trivial computation (heuristic).- More than two consecutive ternary operators.
tryblock whose body cannot throw (harder, requires type info).
CODEOWNERS. Critical paths (auth, payments, data layer) get senior owners. PRs touching those paths need senior approval, not just any teammate.
Custom ESLint rule for forbidden names. A simple rule that errors on identifiers named data, result, info, obj, arr, handler, manager, processor, helper, util. Allow with justification comment if needed.
Pre-commit hook. lint-staged runs ESLint and Prettier on staged files. Block commits that introduce new violations of the rules above. Existing violations get a baseline file so you don't have to fix everything in one PR.
Periodic AI audit. Once a quarter, run jscpd for duplication, ts-unused-exports for dead code, and a manual scan of the largest files for the tells in this guide. Track AI tells over time as a quality metric.
---
The junior dev trap
This is the part that makes me lose sleep.
A senior developer can use AI tools and recover. They look at the output, mentally translate "this is the AI doing its over-defensive thing" to "delete the try/catch," and ship clean code. The senior knows the difference between code that works and code that's good. They've seen enough bad code to know what good code looks like.
A junior developer, especially one whose first six months of professional work is dominated by AI tools, never sees the senior version. They see:
- Their own attempt, which they know is shaky.
- The AI's output, which they know is "what real code looks like."
- The merged PR, which they don't read because the AI version was approved.
What they absorb is the AI default. They learn that real code wraps everything in try/catch, names variables data, sprinkles useCallback everywhere, has comments restating each line. When they write code without the AI three months later, they reproduce the AI patterns from memory. The patterns are now their style. They are now the vector for spreading these patterns into the next codebase.
This is how you get a generation of developers whose mental model of "good code" is "what an AI in 2024 produced." The patterns calcify. They become the new normal.
The defenses are pedagogical, not technical:
- Pair on code review. Juniors should sit with seniors during PR review. The "ah, this comment adds nothing" moment is the lesson. It can't be replaced by a lint rule.
- Curated exemplar repo. Maintain a small repo of code your team is proud of. Juniors read it. New patterns get added to it. AI output is *not* the source of truth — the exemplar repo is.
- Refactor sessions. Once a month, take a real file from the codebase, identify its tells, and refactor it together. Show the diff. Discuss why each change improves the code.
- Read non-AI code. Open-source projects with a strong human voice (anything by Sindre Sorhus, the React core, esbuild, Bun) make a good study set. The contrast is informative.
- Banned: "the AI wrote it" as a defense in review. Every author owns every line of the code they ship. If they didn't read it, they shouldn't ship it.
For the philosophical companion to this — why distinct, opinionated code matters — see 73 Patterns from AI Slop to Signature.
---
Refactor recipes — 12 generic moves that fix many tells
These twelve transformations cover most of the work needed to clean up AI-generated code. Each is a self-contained move you can apply mechanically.
| # | Recipe | Tells fixed | Effort per occurrence | |---|--------|-------------|----------------------| | R1 | Delete redundant comments | #1, #6, #19 | seconds | | R2 | Remove dead try/catch | #2, #3 | minute | | R3 | Rename generic identifiers to domain | #4 | 2-5 min | | R4 | Inline single-method classes into functions | #5 | 2-5 min | | R5 | Centralize duplicate snippets into helpers | #14, #8 | 5-15 min | | R6 | Replace mock-call assertions with behavior assertions | #23 | 15-60 min | | R7 | Drop unnecessary memoization | #10 | minute | | R8 | Convert namespace imports to named | #11 | seconds (codemod) | | R9 | Run Tailwind formatter | #12 | one command | | R10 | Pick one styling system per component | #13 | 5-15 min | | R11 | Replace casts with validation | #17 | 10-30 min | | R12 | Convert ternary chains to switch or function | #18 | 5 min |
R1 — Delete redundant comments. Open the file. For each // line: read the line above and below. If you can predict what the comment says from the code, delete the comment. Repeat.
R2 — Remove dead try/catch. For each try block, ask "what concrete operation in here throws?" If the answer is "nothing," delete try and catch. If the answer is "this one specific I/O call," tighten the try to wrap only that call.
R3 — Rename generic identifiers to domain. Find/replace within file: data → the actual noun (invoice, userProfile, purchases). result → the actual noun. If the function returns "data" because it really is generic, the function is probably misplaced — it should live in a generic utility, not a domain module.
R4 — Inline single-method classes into functions. A class with one public method, no fields that change after construction, used in one place: convert to a top-level function. Delete the class definition. Replace new Foo().bar(x) with foo(x).
R5 — Centralize duplicate snippets into helpers. Run jscpd or use IDE clone detection. For each duplication: extract to a single helper, replace clones with imports of the helper. Add a unit test for the helper.
R6 — Replace mock-call assertions with behavior assertions. For each expect(mock).toHaveBeenCalledWith(...): ask what user-observable thing this mock represents. Rewrite the test to verify that thing. Often this means setting up a real (in-memory or test) version of the dependency instead of mocking it.
R7 — Drop unnecessary memoization. For each useCallback or useMemo: profile or count. If the wrapped expression is cheap (sub-millisecond) and the result isn't passed as a dependency to anything memoized, delete the wrapper. Inline the expression.
R8 — Convert namespace imports to named. Codemod: import * as React from 'react'; React.useState → import { useState } from 'react'; useState. Run once across the repo.
R9 — Run Tailwind formatter. Install prettier-plugin-tailwindcss. Run prettier --write .. Done. Set up CI to run it on every PR.
R10 — Pick one styling system per component. For each component using both style and className: pick one. Move all rules into the chosen system. Document which is canonical for the project.
R11 — Replace casts with validation. For each as any / as unknown as X: identify the actual data shape. Define a schema (Zod, Valibot, io-ts). Replace the cast with Schema.parse(input) at the boundary. Inside the boundary, types flow correctly without casts.
R12 — Convert ternary chains to switch or function. For each ternary chain of three or more levels: extract to a function. Inside the function, use a switch (for tagged unions) or sequential if/return (for general conditions). Each branch becomes a single line.
These twelve recipes, applied across a codebase, eliminate 80% of AI tells. The remaining 20% needs case-by-case judgment.
---
Before / after gallery
A consolidated set of before/after pairs covering tells that didn't get a long section but show up frequently.
Generic util file:
// before
export function processData(input: any) {
const data = input;
const result = data.value * 2;
return result;
}
// after
export const doubleValue = (input: { value: number }) => input.value * 2;Useless useEffect:
// before
function Greeting({ name }: { name: string }) {
useEffect(() => {}, []);
return <h1>Hello, {name}</h1>;
}
// after
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}</h1>;
}Defensive null check on typed prop:
// before
function Avatar({ user }: { user: User }) {
if (!user) return null;
return <img src={user.avatar} alt={user.name} />;
}
// after
function Avatar({ user }: { user: User }) {
return <img src={user.avatar} alt={user.name} />;
}Repeated cn definition:
// before (in 4 different files)
const cn = (...args) => args.filter(Boolean).join(' ');
// after (one file, four imports)
// lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));JSDoc that says nothing:
// before
/**
* Get user by id
* @param id - the id
* @returns the user
*/
export function getUser(id: string): User { /* ... */ }
// after
export function getUser(id: string): User { /* ... */ }Try/catch swallow:
// before
async function publish(post: Post) {
try {
await db.posts.insert(post);
} catch (e) {
console.error(e);
}
}
// after
async function publish(post: Post) {
await db.posts.insert(post);
}
// caller decides what to do with errorsPremature memoization:
// before
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);
// after
const total = items.reduce((s, i) => s + i.price, 0);Triple-nested ternary:
// before
{loading ? <Spin /> : error ? <Err /> : data ? <List /> : <Empty />}
// after
{(() => {
if (loading) return <Spin />;
if (error) return <Err />;
if (data) return <List />;
return <Empty />;
})()}
// or extract to a small ListView componentGeneric naming on parameter:
// before
function process(data: any) { /* ... */ }
// after
function applyDiscount(order: Order): Order { /* ... */ }Inline as any cast:
// before
const config = JSON.parse(rawConfig) as any;
// after
const config = AppConfigSchema.parse(JSON.parse(rawConfig));Mock-only test:
// before
it('saves', async () => {
await save({ id: 1 });
expect(db.save).toHaveBeenCalledWith({ id: 1 });
});
// after
it('persists the saved record', async () => {
await save({ id: 1 });
const fetched = await db.findById(1);
expect(fetched).toEqual({ id: 1, status: 'saved' });
});Forgotten console.log:
// before
async function load() {
console.log('loading...');
const r = await fetch('/api/x');
console.log('got', r);
return r.json();
}
// after
async function load() {
const r = await fetch('/api/x');
return r.json();
}Bare TODO:
// before
// TODO: handle errors
// after
// TODO(@robin, 2026-Q3, ENG-2314): retry on 503 with exponential backoff (max 3)---
FAQ
Is using AI to write code bad? No. Using AI to write code without reviewing the output carefully is bad. The line is "did you read every line, understand it, and decide it should ship?" If yes, AI is a faster typewriter. If no, you're shipping unedited drafts and the codebase pays the bill.
Should I rewrite all AI-generated code? No. Rewrite the worst tells (severity 4-5) when you touch the file for another reason. Don't initiate rewrites for cosmetic tells (severity 1-2) — the diff churn isn't worth the marginal cleanliness. The goal is preventing new bad code, not deleting all old code.
How do I prove someone used AI? You usually can't, and you usually shouldn't try. The tells in this guide are evidence, not proof. The right framing is "this code has these problems, please fix them" — not "I know you used AI." Whether or not they used AI is irrelevant to whether the problems need fixing. Focus on the code.
Will AI tools eventually stop producing these patterns? Some will. Models trained more recently make fewer of the most flagrant errors. But several tells (generic naming, over-memoization, defensive guards) appear stable because they're emergent from training-data distributions, not specific to any model version. Even improved models default to over-explanation and over-defensiveness because their training corpus rewards them.
How do I tell apart "AI wrote this" from "junior wrote this"? You often can't — that's the point of the junior dev trap. The key signal is *consistency at scale*. A junior produces inconsistent code: their bad days look different from their good days. AI output is uniformly mid: every file looks like it came from the same slightly-careful intern, regardless of the file's importance or complexity.
My team uses Cursor. How do I keep quality up? Three things. (1) Run the lint config in this guide. (2) Require human review on every line — the "I read this and approve" gate. (3) Maintain an exemplar set of files that demonstrate good patterns; reference them in PR descriptions when teaching teammates.
Is it OK to keep try/catch even if nothing throws right now? Generally no. The cost of dead try/catch is real (readability, false sense of robustness, type-checker confusion). If you're worried about future code throwing, add the try/catch when the code that throws actually exists. Until then, you're guarding against ghosts.
What about useCallback for child re-render prevention? Justified when (a) the child is wrapped in React.memo, (b) the callback is a dependency that would otherwise cause re-renders, and (c) profiling shows it matters. Skip useCallback for callbacks passed to native DOM elements () — those don't benefit from referential stability.
Are these tells universal or framework-specific? The first eight are language-level (TypeScript, JavaScript). Tells 9-13 and 20-21 are React-specific. Tells 14-19, 22-23 are universal. If you're working in Vue, Svelte, or Solid, the React-specific tells become "your framework's equivalent — over-reactive primitives, unnecessary $ derived state, etc." The shape repeats.
What's the single fastest improvement I can make today? Add prettier-plugin-tailwindcss and @typescript-eslint/no-explicit-any: error. Run them across the repo. You'll fix two tells in an afternoon and prevent regression on a third forever.
How do I review a 5,000-line PR that's clearly AI-generated? You don't merge it. PRs that large can't be reviewed honestly. Ask the author to break it into smaller PRs by feature or concern. If they refuse, escalate. Reviewing a PR you didn't read is rubber-stamping; rubber-stamping AI code is how the codebase becomes unmaintainable.
Should I use AI to refactor AI code? Carefully. The risk is the AI applies its own defaults during refactoring — fixing one tell while introducing another. Pair AI-driven refactors with the lint config and human review. The AI is a fast text-mover, not a quality oracle.
What if my linter conflicts with my AI tool's output? The linter wins. Configure your AI tool (Claude Code, Cursor, etc.) with the lint config in context. The output will lean closer to your conventions. If it still produces violations, run eslint --fix after generation.
Are there positive AI tells — patterns AI does better than humans? Yes. AI is consistent with naming once given a convention. AI never forgets to add a return type if asked. AI is happy to write seventeen unit tests at once. The mistake is assuming AI is *always* better at those things — it's better at the mechanical parts, but the choice of which 17 tests to write is still a human design decision.
Do these tells matter for prototypes that won't ship? No, until the prototype starts shipping. The dangerous moment is when "throwaway prototype" turns into "well, the demo went well, let's productionize this." The accumulated tells become the foundation. Either rewrite at productionization time or keep prototypes clean from the start.
---
Glossary
AI tell. A code pattern that strongly suggests an LLM generated the code rather than a human writing it freehand.
Vibe coding. Generating code via AI prompts without reading the output carefully, relying on "it ran, ship it" as the quality bar.
Defensive code. Code that guards against impossible conditions (null where null can't occur, errors where nothing can throw). Looks safe; is mostly noise.
Generic naming. Identifiers like data, result, handler that carry no domain information.
Mock-call assertion. A test that verifies a function called a mock with certain arguments, instead of verifying observable behavior. Tests the implementation, not the contract.
Tagged union (discriminated union). A type where each variant has a unique tag (kind, type, etc.) so TypeScript can narrow exhaustively in switch statements.
Codemod. An automated, syntax-aware code transformation. Faster and safer than regex-based refactoring for large codebases.
Tree-shaking. Bundler optimization that removes unused exports from imported modules. Works better with named imports than namespace imports.
Schema validation. Runtime checking that incoming data matches an expected shape (Zod, Valibot, io-ts). The runtime version of TypeScript's compile-time types.
---
Sources and further reading
- React Hooks docs (react.dev) — official guidance on
useCallback/useMemo, including when not to use them. - TypeScript Handbook — type narrowing and
interfacevstypealiases. - ESLint and
@typescript-eslintrule references — the lint rules cited in the linter section. - Prettier and
prettier-plugin-tailwindcssdocumentation. - Internal posts referenced in this guide:
- The 2026 State of AI-Generated Web Slop - Anti-Slop Prompt Template - De-AI Your Lovable / v0 / Bolt Site - Vibe Coding 2026: Honest State of AI Frontends - 73 Patterns from AI Slop to Signature
---
Severity matrix — ranking the 23
A single severity column undersells the picture. Here's a richer view: severity (how much pain per occurrence), prevalence (how often it shows up), and tractability (how cheap it is to fix). The combination tells you what to fix first.
| Tell | Severity | Prevalence | Tractability | Priority score | |------|---------:|-----------:|-------------:|---------------:| | #16 try/catch swallow | 5 | 3 | 3 | 11 | | #23 mock-only tests | 5 | 2 | 2 | 9 | | #4 generic naming | 4 | 5 | 3 | 12 | | #14 repeated snippets | 4 | 4 | 3 | 11 | | #10 over-memoization | 4 | 5 | 4 | 13 | | #17 as any casts | 4 | 3 | 3 | 10 | | #21 props spread wrong | 4 | 2 | 4 | 10 | | #2 premature try/catch | 3 | 2 | 4 | 9 | | #3 defensive nulls | 3 | 4 | 4 | 11 | | #5 over-abstracted classes | 3 | 2 | 3 | 8 | | #8 cn reinvented | 3 | 3 | 5 | 11 | | #13 mixed style+className | 3 | 1 | 4 | 8 | | #18 triple ternaries | 3 | 2 | 4 | 9 | | #20 empty useEffect | 3 | 1 | 5 | 9 | | #1 useless comments | 2 | 5 | 5 | 12 | | #6 useless JSDoc | 2 | 4 | 5 | 11 | | #7 console.log left in | 2 | 4 | 5 | 11 | | #12 Tailwind order | 2 | 3 | 5 | 10 | | #15 interface vs type | 2 | 3 | 4 | 9 | | #19 bare TODO | 2 | 4 | 5 | 11 | | #22 placeholder README | 2 | 1 | 4 | 7 | | #9 explicit useState type | 1 | 5 | 5 | 11 | | #11 namespace React import | 1 | 2 | 5 | 8 |
Priority is severity + prevalence + tractability. The top of the list is where to start. Notice that #10 (over-memoization) edges out the swallow-catch (#16) on raw priority — not because it's worse per occurrence, but because it's everywhere and trivially fixable. Bulk wins matter when you're triaging a 200-file repo.
Strategy:
- Top tier (priority 11+): fix systematically across the codebase. Lint rules, codemods, audit sweeps.
- Mid tier (8-10): fix on touch. When you open the file for another reason, clean it up.
- Lower tier (under 8): don't initiate work for these alone. Let them get cleaned up incidentally.
---
What an honest AI workflow looks like
If you're going to use AI tools — and you are — there's a workflow that produces consistently good output instead of consistently mid output. It's not a secret. It's just slower than the unedited "generate, accept, ship" loop most teams default to.
Step 1 — System prompt with project conventions. Whatever tool you use (Claude Code, Cursor, Aider), give it the project's actual conventions in the system prompt or a context file. Things like:
- "Use
typenotinterfacefor object shapes." - "Don't write JSDoc on internal functions."
- "Don't add
useCallbackoruseMemounless I tell you to." - "Don't write comments that paraphrase the code."
- "Use the existing
cnhelper at@/lib/cn. Don't reinvent." - "Errors propagate; do not catch and log unless I specify."
The list lives in a file (AGENTS.md, .cursor/rules, CLAUDE.md, whatever your tool reads). Update it as you discover new defaults to suppress.
Step 2 — Generate small chunks. Ask for one function, one component, one focused change. Do not ask for a whole feature in one prompt. Smaller chunks mean shorter outputs, which means you can actually read every line.
Step 3 — Read every line. Before accepting any AI output, read it. All of it. Out loud is a useful trick — if a line sounds odd when you read it aloud, it's probably wrong. Out-loud reading also catches the "this is doing something I didn't ask for" subtlety that silent skimming misses.
Step 4 — Edit, don't accept. Almost always, the AI output needs at least one edit. Rename a generic identifier. Delete a redundant comment. Tighten a try/catch. The act of editing is the act of taking ownership. If you accept untouched, you didn't review.
Step 5 — Run the linter and the tests. Both before commit. The linter catches the mechanical tells; the tests catch behavior issues. Neither alone is enough. Together they catch most of the worst output.
Step 6 — Self-review your own diff. Open the diff in your PR view. Read it as if a coworker submitted it. The framing shift from "this is mine, ship it" to "would I approve this from a teammate" is enormous.
Step 7 — Ask for review. Even if your team allows self-merge for small changes, AI-assisted PRs deserve human review. The whole point of review is to catch what the author missed. AI authors miss a lot.
This workflow is slower than vibe-coding. It is faster than refactoring six months later when the codebase has accumulated a thousand AI tells. The math always favors slow upfront over fast-and-debt.
---
Lint config (full example)
A working baseline ESLint + Prettier setup that catches many of the tells in this guide. Save as .eslintrc.json or its modern flat-config equivalent.
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"project": "./tsconfig.json"
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"plugins": ["@typescript-eslint", "react-hooks"],
"rules": {
"no-console": ["error", { "allow": ["warn", "error"] }],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "interface",
"format": ["PascalCase"],
"custom": { "regex": "^I[A-Z]", "match": false }
},
{
"selector": "variable",
"format": ["camelCase", "PascalCase", "UPPER_CASE"],
"filter": {
"regex": "^(data|result|info|obj|arr|handler|manager|processor|helper)$",
"match": true
},
"custom": { "regex": "x", "match": false }
}
],
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error",
"no-empty": ["error", { "allowEmptyCatch": false }],
"no-empty-function": "error",
"@typescript-eslint/no-empty-function": "error",
"no-nested-ternary": "error",
"no-warning-comments": [
"warn",
{
"terms": ["TODO", "FIXME"],
"location": "anywhere"
}
]
}
}The naming-convention rule with the inverted custom regex is a hack to forbid generic names. If your tooling supports custom rules, write a proper one. The rule above blocks the literal generic identifiers data, result, info, obj, arr, handler, manager, processor, helper from being used as variable names — pushing the developer toward domain-specific naming.
Pair this with prettier-plugin-tailwindcss in .prettierrc:
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["cn", "clsx"],
"singleQuote": true,
"trailingComma": "all"
}And a lint-staged config:
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"]
}
}Hooked into Husky's pre-commit, every commit runs the lint and format pass before reaching the repo. Drift is caught before it lands.
---
Anti-patterns by language and framework
The 23 tells are mostly TypeScript/React-shaped because that's where most AI codegen lives. The same shapes show up in other stacks; here's a translation table for the most common.
| Tell | TypeScript/React | Python | Go | Rust | Swift | |------|------------------|--------|-----|------|-------| | Useless comments | // add two numbers | # add two numbers | // add two ints | // add two ints | // add two ints | | Try/catch swallow | try {} catch(e) { console.error(e) } | try: ... except: pass | if err != nil { fmt.Println(err) } (no return) | let _ = result; // ignored | try? expression always | | Generic naming | data, result | data, result, obj | result, data, tmp | result, data | data, result | | Defensive nulls | if (!x) return null | if not x: return None | if x == nil { return nil } (where x can't be nil) | if let Some(x) = x { ... } redundant | if let x = x { ... } redundant | | Over-explained docs | JSDoc paraphrasing names | docstring "returns the result" | godoc "Foo returns foo" | rustdoc "/// returns x" | /// paraphrasing | | Over-defensive type guards | as any | cast(Any, ...) or # type: ignore | type assertion .(any) | unsafe blocks for non-FFI | as! force casts |
The shapes are universal because they reflect the model's defaults, not language conventions. A model trained on Python will wrap simple expressions in try/except; a model trained on Go will check for nil on values that can never be nil; a model trained on Rust will sprinkle unwrap() everywhere. The fix is the same: read the output, recognize the defensive default, decide whether the defense is real, and remove it if not.
Some language-specific tells worth flagging:
Python: bare except: clauses without specifying the exception type. print() statements left in production code. Manual with block boilerplate around context managers that don't need it. Type hints written as Optional[List[Dict[str, Any]]] when a Pydantic model would clarify everything.
Go: error handling that always returns the error unwrapped (return err instead of return fmt.Errorf("doing X: %w", err)). Empty else branches. Receiver names that don't match the canonical first-letter convention (func (this *Server) instead of func (s *Server)).
Rust: .unwrap() everywhere when ? would propagate cleanly. String::from("...") when "...".to_string() or just &str would do. Lifetimes annotated where they could be elided. Excessive Box for values that don't need heap allocation.
Swift: force-unwraps (!) on optionals that the type system says must be checked. try? everywhere instead of properly handling errors. @objc annotations on functions that don't need them.
The pattern is: AI defaults to the most-defensive, most-verbose, most-explicit version of every construct. Read the code. Strip the defensive layer when it's not load-bearing.
---
When AI tells are actually fine
This guide reads like every AI default is wrong. That's not quite right. Some of the tells are genuinely good practice in specific contexts. Knowing when is part of the skill.
Useless comments are fine when the audience is beginners — tutorial content, pedagogical examples, blog posts explaining a concept. The comments aren't noise; they're the *point*. AI-generated tutorials with line-by-line commentary are useful for the audience that needs them. Internal team code is not that audience.
Try/catch around clearly-throwing operations is fine and required. The tell is when the wrapped code can't throw. JSON.parse(userInput) warrants try/catch because userInput might be malformed. JSON.parse('{"a":1}') does not, because the literal is known good.
Defensive null checks are fine when the type genuinely allows null and you need a meaningful response. The tell is when the type forbids null and the check is dead. user.email?.toLowerCase() is fine if email is optional. if (!user.email) return null after user.email: string (non-optional) is dead code.
JSDoc is fine and useful for public library APIs, exported functions consumed by other teams, and anywhere hover-tooltips matter to consumers. The tell is when the JSDoc paraphrases the function name on an internal helper.
Memoization is fine and necessary when you've profiled and know it matters. The tell is universal application without measurement.
Generic naming is fine for genuinely generic utilities — compose, pipe, identity, curry. These have no domain. The tell is when domain-specific code uses generic names.
Try/catch with a real handler is fine and good. The tell is the swallow — catching, logging to stdout, and returning a magic value that hides the failure.
useCallback is fine when you're passing the callback to a memoized child or to a hook dependency that legitimately benefits. The tell is wrapping every callback regardless.
Multiple ternaries are fine for a two-branch condition with simple expressions. The tell is three-plus levels with complex expressions.
Casts are fine at boundaries where the type system genuinely can't know — DOM lookup results, JSON.parse outputs (with validation), polymorphic library calls. The tell is casting to escape a type error you should fix.
The rule of thumb: a tell becomes a real problem when it's applied universally. The same construct, used surgically, is just normal code. The AI's failure mode is not generating bad constructs but generating them everywhere without judgment.
---
Auditing an existing codebase — a worked example
Let's walk through a real audit, abstracted from a January 2026 review of a mid-stage SaaS frontend (Next.js 14, ~22,000 lines TypeScript+TSX, ~80 components).
Day 1, hours 1-2: triage scan. Open the repo. Run the inventory:
# count various tells via grep
grep -rE "^\s*//\s+[A-Z]" --include="*.ts" --include="*.tsx" src/ | wc -l
# 1,847 lines starting with capitalized comments — many useless
grep -rE "as any" --include="*.ts" --include="*.tsx" src/ | wc -l
# 92 occurrences
grep -rE "useCallback|useMemo" --include="*.tsx" src/ | wc -l
# 412 occurrences across 80 components — average 5 per component
grep -rE "console\.log" --include="*.ts" --include="*.tsx" src/ | wc -l
# 234 occurrences — most pre-AI? maybe. all of them? definitely not.
grep -rE "try\s*\{" --include="*.ts" --include="*.tsx" src/ | wc -l
# 178 try blocks. Sample 20 — count how many have meaningful catch logic.
# Result of sample: 6/20 have meaningful handling. 14/20 are swallow or dead.This is the snapshot. From it: heavy comment noise, 92 as any casts, average 5 memoizations per component, 234 stray logs, ~70% of try/catch blocks are dead or swallowing.
Day 1, hours 3-5: install the lint config. Apply the ESLint config from earlier in this guide. Run eslint .. Output: 4,200 errors. Half of them auto-fix with --fix. The remaining ~2,100 need manual review.
The --fix-able errors:
- Tailwind class order (auto via plugin).
import * as React→ named imports (auto via codemod).- Tabs/spaces, trailing commas, etc.
The manual ones:
- Generic identifier names — flagged but not auto-renameable.
as anycasts — each needs a real type.- Mock-only tests — each needs rewriting.
Day 1 ends: baseline established, mechanical fixes applied. The repo is ~30% cleaner already, with no behavior changes.
Days 2-4: top-priority refactors. Allocate three days to the high-priority items. Targets:
- Eliminate
as any(92 → 0). Replace with real types or Zod validators. - Audit every try/catch (178 reviewed, 124 deleted as dead, 35 tightened to wrap only the throwing call, 19 left with meaningful handlers).
- Replace mock-only tests on critical paths (auth flow, payment flow, data persistence) with behavior tests. Roughly 40 tests rewritten.
After three days: codebase has measurable test coverage that actually tests behavior. Type system is honest. Error handling is explicit.
Days 5-10: medium-priority cleanups.
- Generic naming pass — file by file, rename per domain.
- Memoization audit — strip unjustified
useCallback/useMemo. From 412 occurrences to ~80, all retained because the team profiled and confirmed they matter. - Tailwind formatter applied repo-wide.
- Custom
cnconsolidated to one file.
End of two-week sprint:
- Lines of code: down ~12% (8% via dead code removal, 4% via deduplication).
- Type errors with
--strict: from 340 to 0. - ESLint errors: from 4,200 to 0.
- Test coverage that exercises behavior: from ~10% to ~60%.
- Average component file size: from 180 lines to 110.
The sprint paid for itself in the next month, when a critical auth bug was caught by a behavior test that didn't exist before the audit. Pre-audit, the same bug would have shipped silently because the only test was a mock-call assertion.
---
Cultural side: why teams ship AI tells
The technical fixes in this guide solve the symptoms. The deeper cause is cultural. Teams ship AI tells because the team rewards velocity over craft, and the senior reviewers don't have the bandwidth or the explicit mandate to push back on AI defaults.
This shows up as:
The "AI did its best" defense. A reviewer flags a try/catch swallow. The author says "the AI suggested it that way — what's wrong?" The reviewer doesn't have time to explain, so they approve. The pattern propagates.
The PR-throughput KPI. Teams that measure velocity by PR count incentivize small, frequent PRs. AI tools accelerate PR creation. The metric goes up. The codebase quality goes down. No one notices because the metric was the wrong metric.
Senior burnout. Senior reviewers exhausted by reviewing AI-generated PRs start rubber-stamping. The tells slip through. A few weeks later, the codebase has absorbed enough AI defaults that even the seniors can't tell what's "team style" anymore.
The fear of being the AI critic. On many teams, there's social pressure not to be the person who pushes back on AI tools. The narrative is "AI makes us faster, you're slowing us down, you don't get it." The pushback is read as resistance to progress rather than legitimate quality concern.
The cultural fixes:
- Make code review a first-class activity. Senior reviewers get explicit time blocks for review. Review quality is part of their evaluation, not an afterthought.
- Measure outcomes, not throughput. Lines per week is meaningless. Bugs in production, time-to-resolve, time-to-onboard — these matter.
- Normalize "the AI got this wrong." Make it explicit in team norms: AI output that doesn't fit conventions gets sent back, and that's not anti-AI, it's normal review.
- Pair, don't review-by-comment. For complex AI-assisted PRs, sit together with the author. Walk through the diff. The teaching moment is the point.
- Have a quality oracle. One senior whose explicit job is "ensure the codebase doesn't drift toward generic AI defaults." This person owns the lint config, the exemplar repo, and the codebase audits.
Without the cultural fixes, the lint rules and review checklists are bureaucratic noise. With them, the team treats AI as a tool, not as the author of record.
---
A note on attribution and ego
A surprising number of disputes about "is this AI code" are really about ego. The author wants credit for the work. The reviewer suspects the author didn't do the work. The conversation gets framed as "did you cheat" when the actual question is "is this code good."
The healthier framing: who wrote the code is irrelevant. Whether the code is good is the only thing that matters. If the AI wrote it and it's good, ship it. If you wrote it and it's bad, fix it. If the AI wrote it and you didn't read it, fix that — but don't make it about plagiarism.
Frame review around the code, never around the source. The most you should say is "this section has the patterns of unreviewed AI output — please review and edit." That's a process feedback, not an accusation.
Frame self-review the same way. Don't think "this is my code, I shipped it." Think "this is the code that is in my codebase under my name — does it meet the bar I'd hold a teammate to?" The shift from possessive to evaluative is the practice.
---
Closing
The codebase you ship in 2026 will outlive any specific AI model. The patterns it accumulates today will be the patterns juniors learn from in 2027. Catch the tells early; refactor them when you can; build lint rules for the ones that repeat. The goal isn't to keep AI out — that ship sailed — but to keep the codebase yours.
Sailop is the npm-installable toolkit that helps with the visual side of this same problem: stopping AI default UI from shipping. The code-side is a discipline, not a tool. Good review, lint rules, exemplar repos, pair sessions. The AI doesn't write your codebase. You do — even when you start with the AI's draft.
If you take only one practice from this guide, take this: read every line you ship. The AI doesn't, the linter doesn't, the reviewer might not. You have to. That's the whole job description.
SHIP CODE THAT LOOKS INTENTIONAL
Scan your frontend for AI patterns. Generate a unique design system. Stop shipping the same blue gradient as everyone else.