Delete it only after two things are true: a tool shows nothing imports it, and a written check of the affected flows passes before and after. That is how to find unused code in a repo and remove it safely: Knip for JavaScript and TypeScript, Vulture for Python, a duplication scan for copied logic, and one removal per commit.
What dead code and duplicated logic are, and what dead code elimination means
Dead code is code no running path reaches, and duplicated logic is one rule implemented more than once. Dead code elimination usually means a compiler or bundler dropping unused code from the build output. That keeps the shipped file small and leaves the confusing source in place, which is the part a cleanup has to delete.
Removing both is the first control in the engineering standards for AI-assisted teams, because every later change is cheaper in a smaller codebase. How it piles up in an AI-built app, as I read it: each prompt tends to add a new file or a new copy of a handler, and nothing in the loop asks for the old one to go.
The phrase has two meanings, and this page is about the second: deleting unused code from the source. webpack’s tree shaking guide calls tree shaking “a term commonly used in the JavaScript context for dead-code elimination”, and it describes an unused export as dead code “that should be dropped” from the bundle. That happens at build time. The source files, the package.json entries and the old routes all stay in the repository, where the next person or the next prompt will read them.
| Kind | How it usually got there (my reading) | How it is found |
|---|---|---|
| Unused file | A prompt wrote a new version and the old one stayed | Knip reports unused files |
| Unused export | Callers moved to a new function; the old one is still exported | Knip reports unused exports; ts-prune reports exports with no detected use |
| Unused dependency | A package added for an attempt that was later replaced | Knip reports unused dependencies |
| Unreachable branch | An if whose condition became always true after an edit | Vulture reports unreachable code in Python; VS Code fades out the else of an always-true if in JavaScript |
| Commented-out block | Kept “just in case” during an edit | A text search; not stated as a finding type in Knip’s or Vulture’s docs |
| Abandoned route or feature | A page or API route nothing links to any more, still deployed | The host’s request logs; Knip starts from framework entry points, so a route file counts as used |
Deliverable 10.1 of the Production Hardening Sprint, “Dead code and duplicate logic cleanup”, reads in full: “Remove dead code and consolidate duplicated logic while preserving required behavior.”
What goes wrong without it
Leftover code costs nothing until someone changes the app, and then it costs in five ways.
| What is lying around | What happens | Who notices |
|---|---|---|
| The same logic copied in two places | A fix lands in one copy, the other keeps the old behavior, and the fix “did not work” for some users | A customer, through support |
| A dead copy of a file the assistant can still see | A prompt to change the pricing rule edits a file nothing imports, and the change appears to do nothing | You, after a deploy that changed nothing |
| Packages the app never imports | They stay in the lockfile and can show up in a dependency audit | Whoever runs the audit |
| Two copies of one calculation | Two screens disagree about the same number | A user comparing pages |
| No clear “real” file | Every change starts with working out which of three similar files runs | Every developer, on every change |
How the first row’s drift happens after an AI edit is the subject of duplicated logic that drifted apart, and its security version, where one copy keeps an auth check and the other does not, is a pass in the secure code review checklist. The fourth row is how two screens show different values, and the last is a question of what belongs in a README.
Packages are the cheapest row to clear. A small web app I audited declared runtime dependencies it never imported, and one of those was the only reason three advisories sat in the tree. My take on that finding: on a dependency list, the cheapest cleanup is a removal, and it is worth doing before you run the npm audit command.
AI code slop is the informal name for this pile: generated code nobody read, kept because deleting it felt risky. An AI slop code cleanup starts with the parts nothing uses.
The Maintainability & Evolvability pillar averages 61.1 out of 100 across the third-party apps I audited, scored on all 21 of the 21. Those 21 are 11 public vibe-coded apps audited exhaustively across all 12 pillars and 10 held-out apps the engine had never seen, audited blind, in June and July 2026. They are a selected set, not a random sample, so the average describes those apps and not AI-built apps in general. Measuring the wider debt, with the triage commands and a tracked duplication number, belongs to technical debt in AI-generated code.
How to find unused code in a repo, and remove it in an order that cannot surprise you
Unused code in a repo is found with one finder per stack: Knip for JavaScript and TypeScript, Vulture for Python, a duplication scanner for copied blocks. It is removed in a fixed order: prove the key flows work, remove one item, re-run the flows, commit. One removal per commit makes any mistake a single revert.
What each tool reports comes from its own documentation, read on 3 October 2026; the order around the tools is my recommendation.
Prove the flows first: the regression test after removing old code
A regression test after removing old code is the same list of flows run twice: once before the first deletion and once after each batch. It covers every entry point, including the ones no user clicks: webhooks, scheduled jobs and links in old emails.
Write the list before anything is deleted, and record a passing run of each flow. Where a smoke suite exists, run it. Where none exists yet, knowing how to write end-to-end smoke tests pays off here, but a written script works too: a short list of steps a person follows, with the result noted. Work on a branch, with the last good deploy restorable. Writing the flow list and making the app restorable are the first two steps of how to clean up a vibe-coded app, so I do not repeat them here.
| Flow | How it is checked | Result before | Result after |
|---|---|---|---|
| Sign up with a new email | Smoke test, or a written script followed step by step | pass / fail | pass / fail |
| Log in and log out | Smoke test, or a written script | pass / fail | pass / fail |
| The paid action (checkout, upgrade) | Test-mode payment, written script | pass / fail | pass / fail |
| Each webhook the app receives | Send a test event from the provider’s dashboard | pass / fail | pass / fail |
| Each scheduled job | Trigger it once and read its log line | pass / fail | pass / fail |
| The admin view | Open it as an admin and as a normal user | pass / fail | pass / fail |
| A link from an old email (reset, invite, deep link) | Open a saved link from a real past email | pass / fail | pass / fail |
The last three rows are the ones people forget, because nobody clicks them during a demo.
Find it: one finder per stack, and the false alarms to expect
Run one finder per stack, read its report, and treat every line as a candidate, never a verdict. The quick triage (big files, churn, duplicate names) belongs to the technical-debt article above; the table below is only what each tool reports for removal.
| Tool | Stack | What it reports | Install and run line |
|---|---|---|---|
| Knip | JavaScript and TypeScript | Unused files, exports and dependencies in one run; 150+ plugins for tools and frameworks such as Next.js, Vite and Jest | npx knip (expects typescript and @types/node installed), or npm init @knip/config then npm run knip |
ESLint no-unused-vars | JavaScript, inside one file | Unused variables, functions and function parameters | On in the recommended config from @eslint/js |
| depcheck | Node.js packages | How each dependency is used, which are useless, which are missing; archived on Jun 16, 2025 | npx depcheck |
| Vulture | Python | Unused functions, classes, imports and variables, plus unreachable code, each with a confidence value from 60% to 100% | pip install vulture, then vulture myscript.py mypackage/ |
| jscpd | 220+ languages (dead code: JavaScript, TypeScript and Python) | Copy-pasted blocks across files, with a duplication percentage; with --dead-code, also unused files, exports, declarations and imports | npx jscpd ., or npx jscpd --dead-code . |
| ts-prune | TypeScript | Exports with no detected use elsewhere in the project | npx ts-prune |
| VS Code Go to References | One symbol at a time | Every reference to the symbol under the cursor | Shift+F12 (Windows, Linux) |
Knip is the one I would start with on a JavaScript or TypeScript app, because it covers files, exports and packages in one run. ESLint’s no-unused-vars rule works inside a file; its page does not cover unused exports in ES or CommonJS modules, so it does not replace Knip. depcheck says “Depcheck is no longer actively maintained” and recommends switching to Knip, so I would not start a cleanup with it. ts-prune’s repository says the project “has been resurrected after a long hiatus” and names no replacement. Vulture warns that it is “likely to miss some dead code” and that “code that is only called implicitly may be reported as unused”, and its README says to run it again after deleting, because it may find more. jscpd finds the copied blocks the duplicates step below starts from.
Knip’s own docs make the key point about false alarms: a surprising result “is usually a real finding or a configuration gap”, because Knip could not reach that code from an entry file. The gap is where cleanups break apps. These are the entry points an AI-built app tends to have that no import points at.
| False positive | Why the finder flags it | How to tell |
|---|---|---|
| Next.js routes, layouts and middleware | The framework finds them by file name, not by import; a missing or incomplete plugin leaves them unreached | Check the framework’s file conventions and the request logs for the route |
| Dynamic imports and string-built paths | Knip’s docs say dynamic import specifiers are not resolved | Search the repo for the file name as a string |
| Files referenced only from config (Tailwind, Jest, ESLint plugins) | With no plugin for the tool, Knip’s docs say config files and their packages may be reported as unused | Search every config file for the name |
| Serverless and cron handlers set up in the host’s dashboard | The reference lives in the host, not in the code | Read the host’s cron and function settings |
| Webhook routes called only by a provider | No code calls them; the provider does | Check the provider’s webhook settings and delivery log |
| Exports used by another package in a monorepo | The caller sits outside the folder that was scanned | Scan from the repo root with workspaces configured |
| Code behind a feature flag or an environment check | It runs only in one environment | Search for the flag and check each environment’s values |
| Database functions and triggers called from SQL | The caller is in the database, not the app | Search the migrations and the database’s function list |
The “why” and “how to tell” columns are my reading, except where they cite Knip’s docs. For any route on that list, my working rule is to look for hits in the logs over about the last 30 days before calling it unused. Check first that the host keeps logs that long: Vercel keeps runtime logs for 1 hour on Hobby, 1 day on Pro and 30 days only with Observability Plus, and even then you can view up to 14 consecutive days at a time. On a shorter window, my working rule is to add a log line to each candidate route, send its hits somewhere that keeps them for the whole window, and wait the 30 days out before deleting it. A security scanner is a different tool with a different job, and knowing what a code scan is keeps its report apart from a finder’s.
Remove unused packages from a project
Unused packages are removed in 6 steps: list them with Knip, search configs for each name, npm uninstall a few at a time, reinstall from the lockfile and rerun build and flows, commit per batch, and fix packages in the wrong list. Plugins and CLIs work without an import. A Python project follows the same order with its dependency file.
- 01 Take the unused-dependencies list from Knip, not depcheck, which is archived
- 02 Search the whole repo, including config files and package.json scripts, for each package name, because CLIs, presets and plugins are used without an import
- 03 Run npm uninstall on a few packages at a time rather than deleting lines from package.json yourself, so the lockfile is updated too
- 04 Reinstall from the lockfile, then build, type check and run the flow list
- 05 Commit each batch with the package names in the commit message
- 06 Note any package in devDependencies that production really needed, and any production dependency that was only a dev tool, and move each to the right list
npm uninstall removes the package from the dependency lists in package.json and, “if you have a package-lock.json, npm will update that file as well”. npm’s prune command does a different job: it removes “extraneous” packages, which npm defines as those in node_modules that are not listed as any package’s dependency. It cleans the installed folder, not your dependency list. For Python, the same order runs against the project’s dependency file, and when Vulture reports code that is really used, its README recommends a whitelist file: vulture mydir --make-whitelist > whitelist.py. Fewer packages also means a shorter list for the npm audit command above.
Same logic in two places: choosing the copy that survives
Duplicated logic is merged in 5 steps: find the pairs, list every difference between the copies, pin the wanted behavior with a test, move every caller to one named module and delete the other, then run the flows. Read both copies first: two that look alike can differ, and one may hold the fix.
- 01 Find the pairs: run jscpd, then search for the domain words that matter in your app, such as price, tax, permission, discount and quota
- 02 Read both copies line by line and list every difference, because one of them may hold the bug fix or the auth check the other lacks
- 03 Write a test or a written check that pins the behavior you want, including each difference you decided to keep
- 04 Keep one copy in a named module, point every caller at it and delete the other copy
- 05 Run the flow list and commit
Never merge duplicates and change behavior in the same commit: if the flows fail, you will not know which change did it. Pinning behavior with a few tests before touching a tangled file is covered in two or three tests as behavior pins. Types make step 4 safer, because, with strict mode on, a caller that passes the wrong shape fails the type check. That is a good reason to learn how to enable TypeScript strict mode before a large merge.
The copy people point to and the copy that runs can be two different files. A health-data product I audited had a 462-line safety-policy YAML file, exactly what a buyer or auditor would point to as the guardrail; nothing loaded it at runtime, and the real enforcement was written separately and had already drifted from it. The longer account is in a policy file nothing loaded. Duplicated state on the client is the two-screens problem above; smells in general are part of code quality checks.
Refactoring safely: spaghetti code, duplicated logic and the tools that rewrite for you
Refactoring is changing the internal structure of code without changing its external behavior, so the flow checks come first. Spaghetti code is untangled in 3 moves: delete what is dead, merge duplicates, then extract and rename in small commits. Editor refactors follow references; an AI assistant knows only the behavior your tests show it.
Martin Fowler’s definition of refactoring is “a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior”. The flow list is your record of that external behavior, which is why it comes before any of this. For refactoring spaghetti code in an AI-built app, my order is: delete what is dead (less to untangle), merge the duplicates, then extract and rename in small steps, one kind of change per commit. The one-file “god file” itself is covered in the harder-to-change article above.
Code refactoring tools come in four kinds, and the automated refactoring tools among them are only as safe as the references they can follow.
| Tool category | What it does safely | What it cannot know (my reading) | Example |
|---|---|---|---|
| Editor refactors | Rename a symbol across files, extract a method or function, update import paths when a file moves | Calls built from strings or made from outside the code | VS Code: Rename Symbol (F2), Extract Method, Update imports on file move |
| Codemods | Apply one mechanical change across many files | Whether the change is right for every file it touches | jscodeshift for JavaScript and TypeScript; OpenRewrite for Java |
| Linters with auto-fix | Apply the rule’s own fixes | Anything the rule does not cover | ESLint; VS Code can also apply chosen Code Actions when you save a file |
| AI assistants | Propose a new structure for a file or function | Which behavior other parts of the app, or customers, rely on | Cursor, Claude Code, ChatGPT |
VS Code’s refactoring docs list the editor’s built-in refactors, and note that some languages support renaming a symbol across files. jscodeshift is “a toolkit for running codemods over multiple JavaScript or TypeScript files”, run as jscodeshift -t myTransform.js src. Pick the refactor tool by the kind of change: a rename is an editor job, the same edit across many files is a codemod job, and a new structure is a job you read line by line.
AI refactoring can propose a cleaner structure for code, but an assistant works only from what you give it or connect, so it can miss a change in behavior someone depends on. My rules for an AI code refactor: one file or one function per request, tests or written checks in place first, the diff read before it is accepted, never a refactor and a feature in one change, and a line in the assistant’s rules file against creating a new copy of a helper that already exists. How to ask for one safe change with a restore point is a step of the cleanup guide above, and I do not repeat it.
Code review and refactoring belong together: the reviewer’s job on a refactor is to confirm the diff only moved code, which is one of the code review best practices. What the result needs before it ships is in how to write production-ready code, and whether the app is worth cleaning at all is whether to rebuild a vibe-coded app or fix it. Refactoring keeps behavior and changes structure; optimization changes speed or size.
How to verify it
A dead code cleanup is verified by 6 things: a removal log with evidence per item, a clean second finder run, passing build and flows, before-and-after counts, one demonstrated revert, and a week or so of watching the error tracker for missing imports and the request logs for 404s.
- 01 The removal log exists, with one row per removal or merge, the evidence it was unused (finder output, zero references, zero route hits in logs that cover the whole window) and the commit id. Evidence: the log
- 02 The finder's second run is clean, or every remaining item has a written reason, such as a Knip ignore entry with a comment in knip.jsonc. Evidence: the second report
- 03 Build, type check and the flow list pass on the final commit, compared with the run from before the first deletion. Evidence: both runs
- 04 File count, dependency count and duplication percentage are recorded before and after with the same tools, and each count drops where the log shows a removal or merge of that kind; a count with no logged change can stay flat. Evidence: the two tool outputs
- 05 One removal is reverted on a test branch to show that one commit equals one revert, and the flows pass after it. Evidence: the revert commit and the flow run
- 06 After deploy, the error tracker is watched for import errors, and the host's request logs and each provider's webhook delivery log for 404s on removed routes, with the dates written down. Evidence: the dated notes
For check 2, Knip lists ignore options such as ignoreFiles and ignoreDependencies, and its configuration reference says to use JSONC “if you want to use comments”, so each ignore can carry its reason. Knip’s docs also say to reach for ignore options “only as a last resort”. For check 4, jscpd prints the duplication percentage at the end of a run. For check 6, my working rule is about a week. An error tracker may not record a 404, so the logs matter: on a host that keeps a day of logs, check daily; on Vercel’s Hobby plan, which keeps 1 hour, a daily look sees only the last hour, so the webhook delivery logs and the error tracker carry this check.
| Item | Kind | Evidence it was unused | Commit | Flows re-run | Result |
|---|---|---|---|---|---|
| (file path or package name) | unused file / export / dependency / duplicate merged | finder output, reference search, route hits in the log window | (commit id) | (which flows) | pass / fail |
On the Production Hardening Sprint, deliverable 10.1 is verified this way: “Record removed or consolidated code and run regression checks on affected flows.”
Where the sprint does this
Deliverable 10.1’s result, the work completed and its verification evidence go into the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Building new product features or modules, completing unfinished core features or business workflows, and rebuilding core functionality that does not yet perform its intended job sit outside the sprint. Every deliverable and its verify line is listed in the published scope.
Common questions about dead code, duplicates and refactoring
Can ChatGPT refactor code?
Yes, for a function it can see whole, but it works only from the code and context you give it or connect, so it can miss behavior that other parts of your app or your customers rely on. Give it one function per request, keep your flow checks in place, read the diff, and let the flow run decide whether the refactor stays.
What is the rule of three for refactoring?
The rule of three, “Three strikes and you refactor”, says two instances of similar code do not require refactoring, but when similar code is used three times, it should be extracted into a new procedure. Wikipedia attributes it to Don Roberts and says Martin Fowler popularized it in Refactoring. In generated code a third copy can arrive quickly, so search for an existing helper before prompting for a new one.
What is refactor in VS code?
Refactor in VS Code is the Refactor command (Ctrl+Shift+R on Windows and Linux), which lists the refactorings available for the code under the cursor, such as Extract Method. The light bulb and Quick Fix (Ctrl+.) show the same refactorings together with fixes, and in some languages Rename Symbol (F2) renames a symbol across files.
Is regression testing the same as UAT?
No. Regression testing checks that what worked before still works; user acceptance testing checks that something new is what the customer asked for. A cleanup changes nothing the customer should see, so it needs only the regression run.
What is an example of spaghetti code?
An illustrative example is one page component that fetches data, validates the form, works out the price and renders the screen in a single file, while the API route repeats the same price math. Changing the price means finding both, and nothing tells you the second one exists.
Owning an app means being able to run it, change it and recover it without guessing. The sprint below leaves you with the runbooks and documentation to do that.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase