Cleaning up AI-generated code comes down to two disciplines: build a safety net before you change anything, then fix the smells in risk order, security first and style last. The stakes are well measured. For 66% of developers, the top frustration with AI tools is code that’s “almost right, but not quite” (Stack Overflow Developer Survey, 2025).
This is the developer-facing guide. It assumes you’re the one with the repo open. If you’re not, or you’re the founder deciding whether to fix the app and who should do it, start with our guide to fixing a vibe-coded app instead; that’s the decide-and-hire playbook. This post is the how, at the code level: nine recurring smells with before/after fixes, wrapped in the workflow that makes refactoring them safe. It’s the same catalog we work through in vibe code cleanup audits.
Key Takeaways
- AI code fails in nine recognizable patterns; fix them in risk order, security smells first
- No cleanup without a safety net: git checkpoint, then characterization tests, then small PRs
- Veracode found AI-generated code introduces OWASP Top 10 flaws in about 45% of tasks (Veracode, 2025)
- Prevention is a review gate on AI-assisted changes, not abstinence from the tools
Why does AI-generated code need cleanup at all?
Because it’s locally plausible and globally unreviewed. Veracode tested over 100 LLMs across 80 coding tasks and found they introduced OWASP Top 10 vulnerabilities in roughly 45% of cases (Veracode 2025 GenAI Code Security Report). Meanwhile GitClear, analyzing 211 million lines of code, measured a 39.9% drop in moved lines, the classic refactoring signal (GitClear via devclass, 2025).
Each generated block answers one prompt well. Nobody, human or model, is accountable for how the blocks fit together, so the codebase accretes instead of consolidating. The runtime bill shows up later: a New Relic study found AI-generated code introduces close to twice as many critical runtime issues as peer-reviewed human-authored code, with SRE and DevOps teams losing up to a third of the work week triaging machine output (Help Net Security, 2026).
And it’s not a wait-for-better-models problem. Veracode’s Spring 2026 follow-up found security pass rates stuck around 55%, barely moved in two years (Veracode, 2026). The failures are systematic, which is good news for us: systematic failures are catchable with a checklist. That’s what the rest of this guide is, and it’s the code-level half of the product engineering discipline the tools skip.
Before you touch anything: build the safety net
The rule of this post: no cleanup without a safety net. Stack Overflow’s 2025 survey found 45.2% of developers say debugging AI-generated code is more time-consuming than expected (Stack Overflow, 2025). That’s the price of changing untested code blind. Four steps, borrowed from legacy-code discipline, make every fix reversible and verifiable.
-
Checkpoint in git. Commit everything, tag the known-working state, and confirm you can deploy that tag. Every step after this one changes behavior on purpose; you need a guaranteed way back.
-
Write characterization tests. AI-generated codebases almost never ship with tests, so write tests that pin down what the code currently does, quirks included, around anything you must not break: auth flows, payments, data writes. You’re not asserting correctness yet. You’re building the tripwire that tells you when a refactor changed behavior.
-
Refactor in small, single-purpose commits. One smell, one commit, shipped as a reviewable PR. When a change breaks something, a five-line diff tells you where; a five-hundred-line “cleanup” commit tells you nothing.
-
Keep the AI for grunt work, never for unreviewed refactors. Let it draft the tests and mechanical renames, but review everything. Teams shipping machine output unchecked are the ones reporting nearly double the critical runtime issues (Help Net Security, 2026).
With the net in place, you’re ready for the catalog. Browse it below by category or risk order; every card deep-links to its walkthrough.
The nine smells, in risk order
The nine smells, highest risk first: hardcoded secrets, unvalidated input, missing auth, copy-paste duplication, phantom dependencies, swallowed errors, happy-path-only logic, dead code, and comment noise. The order is the argument: security smells first, cosmetics last, because a breach costs more than an ugly file ever will. The duplication problem in the middle is measurable. GitClear found 2024 was the first year copy-pasted lines exceeded moved lines in its 211-million-line dataset (GitClear via devclass, 2025). This ordering follows the risk taxonomy in our vibe-coding technical debt breakdown.
1. Hardcoded secrets and config
The AI needed a value to make the demo run, so it inlined your key. Once committed, it’s in history forever, for every collaborator and every leaked repo copy. The fix is environment variables plus rotation. Moving the key isn’t enough; treat anything that ever touched git as burned.
AI wrote:
// payments.ts
const stripe = new Stripe("sk_live_FAKEKEY_a1b2c3d4e5");
export function charge(amountCents: number, customerId: string) {
return stripe.charges.create({
amount: amountCents,
currency: "usd",
customer: customerId,
});
}
Cleaned:
// payments.ts
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
const stripe = new Stripe(key);
// Then: rotate the old key. Git history still has it.
2. Unvalidated input at trust boundaries
String-built queries and unescaped rendering are the classic generated shortcuts, and models are demonstrably bad here: Veracode found LLMs failed to defend against cross-site scripting in 86% of relevant samples (Veracode, 2025). Root cause: the model mirrors the tutorial-grade code it trained on. Fix rule: validate at every boundary, parameterize every query.
AI wrote:
app.get("/orders", async (req, res) => {
const rows = await db.query(
`SELECT * FROM orders WHERE user_id = '${req.query.userId}'`
);
res.send(`<h1>Orders for user ${req.query.userId}</h1>`);
});
Cleaned:
app.get("/orders", async (req, res) => {
const userId = z.string().uuid().parse(req.query.userId);
const rows = await db.query(
"SELECT * FROM orders WHERE user_id = $1",
[userId]
);
res.json(rows);
});
3. Missing auth on generated routes
Ask an AI tool for “an admin endpoint to list users” and you get exactly that, with no session check, because auth lived in a different prompt. Every scaffolded route ships open until proven otherwise. Fix rule: default-deny middleware at the router level, so a forgotten route fails closed instead of open.
AI wrote:
// routes/admin.ts, scaffolded in isolation: no middleware
app.get("/api/admin/users", async (_req, res) => {
res.json(await db.query("SELECT id, email, role FROM users"));
});
app.delete("/api/admin/users/:id", async (req, res) => {
await db.query("DELETE FROM users WHERE id = $1", [req.params.id]);
res.sendStatus(204);
});
Cleaned:
// Default-deny: every /api route requires a session
app.use("/api", requireSession);
const adminOnly = requireRole("admin");
app.get("/api/admin/users", adminOnly, listUsers);
app.delete("/api/admin/users/:id", adminOnly, deleteUser);
4. Copy-paste divergence
Each prompt regenerates the logic instead of importing it, and the copies drift apart with every session. This is the smell GitClear quantified: duplicated blocks of five or more lines rose 8x in 2024 (GitClear via devclass, 2025). Which copy is right? Nobody knows. Fix rule: extract once, import everywhere, delete the copies.
AI wrote:
// checkout.ts
const total = items.reduce((s, i) => s + i.price * i.qty, 0) * 1.0825;
// invoice.ts (generated a week later)
const total = items.reduce((s, i) => s + i.price * i.qty, 0) * 1.08;
// email-receipt.ts (third session, third tax rate)
const total = items.reduce((s, i) => s + i.price * i.qty, 0) * 1.085;
Cleaned:
// pricing.ts: the single source of truth
export const TAX_RATE = 0.0825;
export function orderTotal(items: LineItem[]): number {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return subtotal * (1 + TAX_RATE);
}
5. Phantom and bloated dependencies
Models suggest packages by plausibility, not existence: some are hallucinated outright, and the rest pile up as three HTTP clients doing one job. Every entry is attack surface and upgrade burden. Fix rule: audit the lockfile, verify each package exists and is maintained, and prune until one job has one library.
AI wrote:
{
"dependencies": {
"axios": "^1.7.0",
"node-fetch": "^3.3.2",
"got": "^14.4.0",
"moment": "^2.30.1",
"date-fns": "^3.6.0",
"schema-validator-prox": "^2.1.0"
}
}
Cleaned:
{
"dependencies": {
"date-fns": "^3.6.0"
}
}
Run npx depcheck for unused packages, then check each survivor on the npm registry. Here Node’s built-in fetch takes over the HTTP job from all three clients, and date-fns wins the date job over the long-deprecated moment. schema-validator-prox is the phantom: plausible name, no such package, and squatters register exactly these names.
6. Swallowed errors
Generated code catches errors to keep the demo alive, then returns a happy-path default that hides the failure. The empty object propagates until something unrelated crashes, far from the cause. Fix rule: fail loudly, log the context, and only catch where you can genuinely handle.
AI wrote:
async function getProfile(userId: string) {
try {
const res = await api.get(`/users/${userId}`);
return res.data;
} catch (e) {
return {}; // TODO: handle error
}
}
Cleaned:
async function getProfile(userId: string): Promise<Profile> {
try {
const res = await api.get(`/users/${userId}`);
return res.data;
} catch (err) {
logger.error("getProfile failed", { userId, err });
throw new ProfileFetchError(userId, { cause: err });
}
}
7. Happy-path-only logic
The model wrote the case the prompt described, and nothing else: no empty lists, no concurrent writes, no limits. Root cause: prompts describe the happy path, so that’s what gets built. Fix rule: your characterization tests from the safety-net step grow into edge tests, one per hole you find.
AI wrote:
function averageScore(reviews: Review[]) {
const sum = reviews.reduce((s, r) => s + r.score, 0);
return sum / reviews.length; // NaN on an empty array
}
Cleaned:
function averageScore(reviews: Review[]): number | null {
if (reviews.length === 0) return null;
const sum = reviews.reduce((s, r) => s + r.score, 0);
return sum / reviews.length;
}
test("returns null when there are no reviews", () => {
expect(averageScore([])).toBeNull();
});
8. Dead and unreachable code
Regenerating an approach leaves the previous attempts behind: commented-out blocks, V2 functions, files nothing imports. The dead copies aren’t neutral. They mislead the next reader, human or AI, about what the system actually does. Fix rule: delete ruthlessly. Git remembers everything, so “keeping it just in case” buys nothing.
AI wrote:
export function formatPrice(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
}
// Old approach, keeping just in case
export function formatPriceV2(cents: number, currency?: string) {
const value = (cents / 100).toFixed(2);
return currency ? `${value} ${currency}` : `$${value}`;
}
// function formatPriceLegacy(cents) { return "$" + cents / 100; }
Cleaned:
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
// Alternatives deleted. `git log -p` remembers them.
9. Comment noise and naming drift
Comments that restate the line below, plus four names for the same concept from four prompting sessions. It’s the least dangerous smell, which is exactly why it’s last: cosmetic debt waits, risk debt doesn’t. Fix rule: comments explain why, never what, and each concept gets one name enforced by search-and-replace.
AI wrote:
// This function gets the user data
async function getUserData(id: string) {
// Fetch the user from the database
const fetchedUserInfo = await db.users.findById(id);
// Return the user
return fetchedUserInfo;
}
// elsewhere: fetchUser(), retrieveUserRecord(), loadUserProfileData()
Cleaned:
async function getUser(id: string): Promise<User> {
return db.users.findById(id);
}
// One concept, one name: getUser, everywhere.
// Comments are reserved for "why", e.g. workarounds and invariants.
How do you keep AI-assisted code clean going forward?
Review is where the quality gap closes. LinearB’s 2026 benchmarks put the acceptance rate for AI-generated pull requests at 32.7%, versus 84.4% for manual ones (LinearB, 2026). Reviewers rejecting most raw machine output isn’t the process failing. That is the process, catching the smells above before they merge.
So the goal is guardrails, not abstinence; with 84% of developers using or planning to use AI tools (Stack Overflow, 2025), abstinence isn’t on the table anyway. Four gates keep the catalog from refilling:
- PR review on every AI-assisted change. Same scrutiny as human code, no direct-to-main generation.
- CI runs the characterization suite. The safety net becomes a permanent tripwire.
- Dependency audit in the pipeline.
npm auditplus a check that new packages actually exist and are maintained (smell #5, automated). - Lint rules tuned to the smells. No empty catch blocks, complexity ceilings, secret scanning as a pre-commit hook.
One boundary note: if AI is in your product, not just your workflow, meaning LLM calls, RAG pipelines, or agents, that attack surface needs its own review. That’s what an AI security audit covers, and it’s a different discipline from code cleanup.
When should you stop and call in an audit?
Stop when the smell count outruns your capacity. Audits of vibe-coded apps typically surface 8–14 significant issues each (Startrise, 2026), and solo cleanup works fine when your codebase is at the low end with isolated, catalog-shaped problems. Three signals say it isn’t:
- Every fix uncovers two more, and the branch never gets shorter.
- The security surface (smells 1–3) is bigger than the review capacity you can bring to it.
- You’re the founder who codes, and cleanup is eating the weeks your runway needs for shipping features.
In those cases a fixed-scope audit is the honest trade: vibe code cleanup starts at $2,500, runs 1–3 weeks, and returns every issue ranked by risk, with fixes shipped as reviewable PRs with tests, so the safety-net discipline holds even when someone else does the work. Whoever you bring in, hold them to the standard in our cleanup specialist checklist. And if the real question is fix-versus-rewrite economics or hiring, that’s the decide-and-hire playbook, not this guide.
You now know what to look for. An audit is just the fixed-price version of looking everywhere at once.
Questions we actually get
How do you clean up AI-generated code without breaking it?
Safety net first: commit and tag the working state in git, write characterization tests around the behavior you must preserve, then refactor in small reviewable steps, security issues first and cosmetics last. Never refactor untested AI code in place; that's how 'almost right' becomes 'down in production.'
What are the most common problems in AI-generated code?
Nine problems recur: hardcoded secrets, unvalidated input, missing auth on routes, copy-paste duplication (GitClear measured an 8x jump in duplicated blocks), phantom dependencies, swallowed errors, happy-path-only logic, dead code, and comment noise. Fix them in risk order, with the security smells first.
Is AI-generated code lower quality than human code?
On measured dimensions, unreviewed AI code fares worse: Veracode found OWASP Top 10 vulnerabilities introduced in about 45% of tasks, and a New Relic study found close to twice as many critical runtime issues versus peer-reviewed human code. Review and tests close most of the gap; the problem is skipping them.
Should I rewrite AI-generated code or refactor it?
Refactor with a safety net in almost all cases. Working behavior is worth keeping, and git plus characterization tests make incremental cleanup safe. Rebuild an individual component only when fixing it genuinely costs more than replacing it; for that decision framework, see our fix-a-vibe-coded-app playbook.
When should I get a professional audit instead of cleaning it up myself?
When every fix uncovers two more, when the security surface exceeds your review capacity, or when cleanup is eating the time you need for shipping. A fixed-scope audit (Startrise's starts at $2,500, takes 1–3 weeks) typically surfaces 8–14 significant issues ranked by risk, with fixes delivered as reviewable PRs with tests.