To clean up a vibe-coded app, put the checks in before you change any code. Install a lint gate, let it fail and record what it finds as a baseline. Then have your coding agent, an AI tool that edits code for you, fix one pillar at a time while the gate checks every change.
A gate is a check that has to pass before a change counts as done, and each Prickles pack runs its gate with one command. The Prickles canon holds 51 rules for code, called tenets, in nine groups called pillars, such as Style or Types.
This order matters because nothing checked the code the first time. An agent asked to “clean it up” with nothing checking it again produces a second, unreviewed rewrite. With the gate in place, the agent has a command that tells it whether the code is clean.
What vibe coding is, and why the code gets hard to change
Andrej Karpathy named it in a post on X on 2 February 2025. He described a way of coding where you “fully give in to the vibes, embrace exponentials, and forget that the code even exists.” In the same post he accepts every change without reading the diffs, which show each change line by line. He thinks it’s good enough for throwaway weekend projects.
Simon Willison’s Not all AI-assisted programming is vibe coding (2025) gives a tighter definition: “building software with an LLM without reviewing the code it writes.” Coding agents run on an LLM, or large language model.
That’s a quick way to find out whether an idea works, and plenty of useful apps started that way. The trouble arrives when the app needs to change. Every prompt adds unread code, so a fix in one place starts breaking something in another. Most vibe coding best practices come down to putting the review back. Tools can do the reading for you, as long as the agent has to satisfy them.
What AI-generated code quality looks like to a linter
A linter is a tool that checks code against a set of rules without running it. Code that nobody reviews drifts in ways a linter can measure: long functions, conditions nested inside conditions, numbers with no name and blocks pasted in twice. Whether you call that AI slop or just unreviewed code, a linter reports a 67-line function the same way whoever wrote it.
Copied code shows up at scale. GitClear’s AI Copilot Code Quality report (2025) looked at 211 million changed lines written between 2020 and 2024. It found four times as much cloned code, and copy-and-paste outnumbering moved code for the first time. It’s observational data from a company that sells code analysis, so it shows a trend rather than a cause.
Messy code costs the agent as well. Sonar’s Priyansh Trivedi and Olivier Schmitt tested this in Does Code Cleanliness Affect Coding Agents? (2026). They gave Claude Code pairs of repositories that behaved the same but differed in lint violations and complexity. On the cleaner code, the agent used 7 to 8% fewer tokens, the units a model reads and is billed by. It went back to files 34% less often and still passed just as many tasks.
A failing first run is normal
While release 3.0.0 was being prepared, Prickles tested its TypeScript and JavaScript packs on 12 October 2026, on new projects made with seven frameworks’ own starters. The lint step failed on the untouched starter code of five of them: Angular, Nest, Next.js, SvelteKit and Vue. Only Astro and Nuxt passed. That run came before the packs began failing on every comment, so a starter that holds comments has more to fix with the released packs. This is what it reported for a new Next.js 16.4 project, with the paths shortened:
src/app/layout.tsx
20:16 error Missing return type on function @typescript-eslint/explicit-function-return-type
src/app/page.tsx
3:16 error Function 'Home' has too many lines (67). Maximum allowed is 15 max-lines-per-function
3:16 error Missing return type on function @typescript-eslint/explicit-function-return-type
✖ 3 problems (3 errors, 0 warnings)
The Next.js starter’s home page is one function of 67 lines, against the canon’s limit of 15. Nest’s starter failed on formatting, and on the port number 3000 written into its entry file without a name. If a framework’s starter fails, an app built by prompting will give you a long list. That’s the gate doing its job.
Don’t switch the rules to warnings, or off, to make the list shorter. The tagline of TA1 Linter as Law says every gate is an error, never a warning. The baseline in the next section lets you live with a long list without hiding any of it.
How to record a baseline
A baseline is a record of every problem the gate finds today. The gate then fails on anything new and leaves the recorded problems to wait their turn. It’s your technical debt, written down and counted.
The TypeScript and JavaScript packs lint with ESLint, which has had this built in since version 9.24.0 (April 2025), under the name bulk suppressions. ESLint’s Bulk Suppressions page gives the commands. Format everything first, which changes layout and nothing else, then record what’s left:
npx prettier --write .
npx eslint . --suppress-all
That writes eslint-suppressions.json, a count of the errors for each rule in each file. You commit it with the code. After each round of fixes, remove the entries that no longer apply:
npx eslint . --prune-suppressions
Prickles tested this on 12 October 2026 with ESLint 9.39.5. A new problem failed the run. When a file went over its count for a rule, ESLint reported every error of that rule in that file, so the agent sees all of them. When a recorded problem was fixed, ESLint exited with an error until the file was pruned, so the baseline can’t go stale.
Bulk suppressions sit well with TA1. It forbids switching a rule off inline, and with a suppressions file nothing in the code says “ignore this”. It’s one file, every problem in it has a count and it only gets shorter. TA2 Duplication Detection makes the same kind of allowance for duplicated code in older parts of a codebase, as long as there’s a plan to pay it down.
A linter with no baseline file needs a few lines of your own. Count the problems per rule, save the counts and fail the build when any count goes up. The agent can turn the linter’s report into the counts. The comparison looks like this:
type RuleCounts = Readonly<Record<string, number>>;
export const rulesOverBaseline = (baseline: RuleCounts, current: RuleCounts): readonly string[] =>
Object.entries(current)
.filter(([rule, count]) => count > (baseline[rule] ?? 0))
.map(([rule]) => rule); type RuleCounts = Readonly<Record<string, number>>;
export const rulesOverBaseline = (baseline: RuleCounts, current: RuleCounts): readonly string[] =>
Object.entries(current)
.filter(([rule, count]) => count > (baseline[rule] ?? 0))
.map(([rule]) => rule); export const rulesOverBaseline = (baseline, current) =>
Object.entries(current)
.filter(([rule, count]) => count > (baseline[rule] ?? 0))
.map(([rule]) => rule); from collections.abc import Mapping
def rules_over_baseline(baseline: Mapping[str, int], current: Mapping[str, int]) -> list[str]:
return [rule for rule, count in current.items() if count > baseline.get(rule, 0)] public static List<String> rulesOverBaseline(
Map<String, Integer> baseline, Map<String, Integer> current) {
return current.entrySet().stream()
.filter(entry -> entry.getValue() > baseline.getOrDefault(entry.getKey(), 0))
.map(Map.Entry::getKey)
.toList();
} function rulesOverBaseline(array $baseline, array $current): array
{
$isOverBaseline = fn(int $count, string $rule): bool => $count > ($baseline[$rule] ?? 0);
return array_keys(array_filter($current, $isOverBaseline, ARRAY_FILTER_USE_BOTH));
} An empty list means the build passes. A count that falls is fine too, since the next saved baseline records the lower number.
How to clean up a vibe-coded app, one pillar at a time
- Commit everything as it is, so any change from here on can be undone. If the app isn’t in Git yet, put it there first.
- Download the pack for your language and agent from the implementation page, copy it into the repository and run its install command. It holds the rules your agent reads and the lint config the gate runs.
- Run the gate with the pack’s run command, and save what it reports.
- Record the baseline as shown above, then commit it. From here, run the gate in CI, the service that checks every push, so nothing new makes the code worse.
- Ask the agent for tests that pin what the app does today, starting with what people do most in it, such as signing up or paying. Michael Feathers calls these characterisation tests. They record what the code does today, bugs included. They’re new code, so the gate checks them in full.
- Pick a pillar and delete its rules’ entries from
eslint-suppressions.json. Each rule in the pack’seslint.config.mjsnames its tenet in a comment, so you or the agent can tell which rules belong to which pillar. - Have the agent plan the fix, read and approve the plan, then let it work until the gate and the tests pass.
- Prune the baseline, commit and go back to step 6 for the next pillar.
For the Python, Java and PHP packs, take the same steps with your saved counts in place of the suppressions file. Tests come before any change to behaviour, because P6 Leave it Better Than You Found it says not to refactor what you can’t test. It also asks for each tidy-up in a commit of its own, so every pass in step 8 is easy to review or undo.
Which pillar to fix first
The TypeScript pack’s lint config checks 19 of the 51 tenets, from seven of the nine pillars. For the four pillars covering how code is structured, this order keeps each pass safe:
| Order | Pillar | What the gate flags | Why at this point |
|---|---|---|---|
| 1 | Types & Schema | any, missing return types and @ts-ignore comments |
Real types let the compiler check every later change |
| 2 | Style | Deep nesting, complex conditions, long parameter lists and unnamed numbers | Most fixes are small edits inside one function |
| 3 | Foundations | Long functions and files, comments and repeated code | Splitting by responsibility is easier once each function reads clearly |
| 4 | Architecture | Imports across layers and import cycles | It moves files, so it’s safest with tests and types in place |
The Types rules come from T2 No Escape Hatches, and most of the Style limits from S2 Cyclomatic Caps. The pack’s accessibility rules for JSX, the markup inside React components, belong to the Process pillar and can take a pass of their own.
This order is a suggestion. Keep to one pillar per pass, so each diff holds one kind of change. Code smells lists the rule behind each smell, and refactoring covers the small steps inside each pass.
What to tell the agent
AI1 The Intern Pattern runs every task as plan, approve, execute and review. A cleanup pass fits it well. The plan names the files and the check. You approve it before any edit. Then the gate does the first review, so it’s what AI4 Verifiable Specs asks for, a spec the agent can run and grade itself against.
Anthropic’s Best practices for Claude Code, read on 12 October 2026, makes the case plainly:
Claude stops when the work looks done.
Without a check it can run, the page says, you become the check. It lists a linter among the checks that work. Rules the agent has to pass also hold better than rules it only reads. Yujun Zhou and colleagues tested six models on 19 tasks in a 2026 study. The agents met 55.0% of a user’s preference checks when the rules were given as prompt text, and 70.1% when the rules were compiled into checks that ran.
A prompt for one pass might read:
Fix the Style problems ESLint now reports. Plan first and wait for my approval.
Keep what the app does the same: the tests must pass before and after.
Don't change eslint.config.mjs, don't add entries to eslint-suppressions.json
and don't add eslint-disable comments.
You're done when the gate and the tests pass. Show me their output.
This prompt rules out changing the config because an agent asked to make a check pass can do it by changing the check. Anthropic’s own example prompt tells Claude to fix the root cause and not suppress the error.
Vibe coding security: what the gate doesn’t check
Prickles packs check how code is built. They can’t tell whether your database lets a stranger read every table, or whether a secret key ends up in code that runs in the browser.
Veracode’s 2025 GenAI Code Security Report tested more than 100 large language models on 80 coding tasks built around known weaknesses. The generated code introduced a security flaw in 45% of cases. Veracode says models are getting better at writing code that works but not at writing secure code. Larger models did not do significantly better than smaller ones. The tasks were built for the test, so the figure describes the models’ habits and says nothing about any one app.
The National Vulnerability Database’s record of CVE-2025-48757 describes a flaw in Lovable, an app builder, up to 15 April 2025. In the sites it generated, an insufficient row-level security policy let attackers who weren’t logged in read or write any database table. Row-level security means rules that decide which rows each user may read or change. Lovable disputes the record, saying each customer is responsible for protecting their own app’s data. Either way, the risk sits with whoever built the app.
Vibe coding tips for keeping it clean
Your gate stays after the cleanup. You can keep building by prompting, and every change still has to pass the gate before it’s merged. P6 keeps the rest moving. Each time you open a file, tidy it in a commit of its own, so the baseline shrinks as part of ordinary work.
- Keep the gate in CI, and don’t merge a change that fails it.
- Prune the baseline after every pass, and commit the smaller file.
- Lower jscpd’s threshold whenever the duplication falls.
- When the agent says it’s finished, ask for the gate’s output.
Once the baseline is empty, delete the file and set jscpd’s threshold back to 0. From then on, you run the gate with the pack’s command exactly as written.
