跪拜 Guibai
← Back to the summary

AI Stealth Edits: Five Ways Your Copilot Changes Code You Never Asked It To Touch

Ask AI to change one line of code, and the diff shows several changes you never asked for — anyone who codes with AI has run into this. I've catalogued the five most common "stealth-edit" patterns, each with real code and countermeasures.

It started with a simple bug

Recently I was fixing a state-update bug — a three-line change. After the AI made the fix, I glanced at the diff out of habit —

More than three lines had changed.

I checked recent commit records; this wasn't the first time. AI has a bad habit when editing code: it doesn't just change what you told it to. It also "helpfully" touches code it thinks "should be optimized."

Some changes are harmless, but others can blow up the project.

Here are the five most common AI stealth-edit patterns I've catalogued. Each one has appeared repeatedly across the community.


Stealth Edit #1: Deleting "unused" files — that the framework auto-loads

This is the most dangerous one.

During refactoring, AI scans code references. If a file isn't explicitly imported anywhere, it treats it as dead code and deletes it.

The problem: frontend frameworks rely heavily on convention-based files that don't need manual imports.

# AI thinks these files are "unreferenced" and deletes them
src/
├── middleware.ts          ← Next.js auto-loads it, no import needed
├── app/error.tsx          ← Next.js error boundary, convention filename
├── plugins/analytics.ts   ← Nuxt auto-loads the plugins directory
└── composables/useAuth.ts ← Nuxt auto-imports the composables directory
// middleware.ts — no file imports it, but Next.js executes it automatically
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('token');
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

AI sees zero references and flags it as dead code. Delete it — and authentication checks vanish from every page. Unauthenticated users can now access all routes directly. Tests will probably still pass before deployment because unit tests typically mock the auth logic.

Similar situations:

Prevention: When you see AI deleting a file, first ask whether it's a framework convention file. middleware, error, layout, loading, plugins/ directory, composables/ directory — these names carry built-in magic. Delete them and things break.


Stealth Edit #2: Converting sync to async — "elegantly" breaking the startup sequence

AI loves converting synchronous code to asynchronous. In its training data, "async = good" is practically consensus.

// Before: synchronous config read (must block and wait during startup)
const config = fs.readFileSync('./config.json', 'utf-8');
const parsed = JSON.parse(config);
initDatabase(parsed.db);
initCache(parsed.cache);
startServer();

AI decides readFileSync isn't elegant and changes it to:

// AI "optimization": async read
const config = await fs.promises.readFile('./config.json', 'utf-8');
const parsed = JSON.parse(config);
initDatabase(parsed.db);
initCache(parsed.cache);
startServer();

Looks fine? The problem is this code runs inside a CommonJS module without top-level await support. Or a subtler scenario: initDatabase internally depends on synchronous initialization timing. After the async conversion, other modules start initializing before the config has finished loading.

This kind of bug is extremely hard to diagnose — the startup sequence succeeds sometimes and fails other times, depending on the completion order of async operations.

Similar situations:

Prevention: When AI converts sync to async, ask yourself: "Is there a reason this was synchronous?" Startup sequences, constructors, validation logic — synchronous code in these three places is usually intentional.


Stealth Edit #3: Losing a break during refactoring

When AI moves or refactors code, it occasionally drops control-flow logic. The most common cases are break inside switch and early returns inside loops.

// Before
function processItems(items) {
  for (const item of items) {
    if (item.type === 'invalid') {
      logError(item);
      break; // stop processing when encountering an invalid item
    }
    transform(item);
  }
}

Ask AI to refactor this, for example by splitting it into smaller functions:

// After AI refactoring
function processItems(items) {
  for (const item of items) {
    handleItem(item);
  }
}

function handleItem(item) {
  if (item.type === 'invalid') {
    logError(item);
    return; // this return only exits handleItem, not the loop!
  }
  transform(item);
}

break became return, but the semantics are completely different. Originally the entire loop stopped on an invalid item; now it just skips that one item and continues processing the rest. If items after the invalid one depend on the earlier validation result, all subsequent processing produces dirty data.

Similar situations:

Prevention: The first thing to do after refactoring — search the diff for break, continue, return, throw. Any change to these four keywords must be checked against the original logic one by one.


Stealth Edit #4: Wrapping two lines of code in three layers of abstraction

You ask AI to write a utility function that checks for an empty string. You expect this:

function isEmpty(str: string): boolean {
  return str.trim().length === 0;
}

AI gives you:

interface StringValidator {
  validate(input: string): ValidationResult;
}

interface ValidationResult {
  isValid: boolean;
  reason?: string;
  metadata?: Record<string, unknown>;
}

class EmptyStringValidator implements StringValidator {
  private readonly trimBeforeCheck: boolean;
  
  constructor(options?: { trimBeforeCheck?: boolean }) {
    this.trimBeforeCheck = options?.trimBeforeCheck ?? true;
  }
  
  validate(input: string): ValidationResult {
    const processed = this.trimBeforeCheck ? input.trim() : input;
    return {
      isValid: processed.length > 0,
      reason: processed.length === 0 ? 'String is empty or whitespace' : undefined,
      metadata: { originalLength: input.length, processedLength: processed.length }
    };
  }
}

export const isEmpty = (str: string): boolean => {
  return !new EmptyStringValidator().validate(str).isValid;
};

Two lines of logic become thirty. Interface, class, configuration options, metadata — the full set.

This kind of stealth edit won't crash the project, but it will bloat the codebase fast. Three months later, nobody on the team dares touch these "professionally architected AI-written" blocks because they look too formal, and people are afraid of breaking things.

Similar situations:

Prevention: If the feature you asked AI to write can be described clearly in one sentence and the resulting code exceeds 20 lines — it's probably over-engineered. Delete it, ask it to rewrite, and add "implement with the minimum amount of code" to the prompt.


Stealth Edit #5: "Optimizing away" code you manually fixed

This is the most infuriating one.

You find a problem in AI-generated code and fix it manually. A moment later you ask AI to continue working on another area, and it "optimizes away" your fix — because its context remembers the version it generated earlier and considers that the "correct" one.

// AI's first generated version
useEffect(() => {
  fetchData();
}, []);

// You manually added a dependency
useEffect(() => {
  fetchData();
}, [userId]); // ← you added this manually

// You ask AI to fix another bug, and it "helpfully" reverts it
useEffect(() => {
  fetchData();
}, []); // ← AI changed it back to an empty array because it thinks that's "correct"

AI doesn't understand why you changed its code. From its perspective, an empty dependency array is the "standard pattern," and the [userId] you added is superfluous.

This happens especially often in long conversations. The longer the conversation, the more AI tends to overwrite your manual changes with its own earlier generated versions.

Prevention:


AI Stealth-Edit Prevention Quick-Reference

Stealth Edit Type Danger Level Detection Method Preventive Measure
Deleting "dead code" 🔴 Critical Search diff for deleted classes/files Classes with framework annotations are not dead code
Sync to async conversion 🔴 Critical Search diff for new async/await Sync in startup/constructors/validation is intentional
Losing break/return 🟡 High Search diff for break/continue/throw Check control flow one by one after refactoring
Over-abstraction 🟢 Low One-sentence requirement exceeds 20 lines of code Add "minimum code implementation" to prompt
Overwriting manual fixes 🔴 Critical Review the full diff, not just changed parts Start a new conversation after manual fixes

One universal principle: every time AI finishes editing code, review the full diff, not just the line you asked it to change.


This isn't AI's fault

Ultimately, these "stealth edits" aren't AI deliberately sabotaging you. It's doing what it thinks is right — cleaning dead code, optimizing performance, unifying style. The problem is it doesn't have your project's full context and doesn't know which "non-standard" code was written that way on purpose.

AI is a highly capable new colleague who knows nothing about your project's history. You wouldn't let a new hire push directly to main; treat AI the same way.

What's the most absurd AI "stealth edit" you've encountered? Let's talk in the comments.