On this page

← Blog
Engineering7 min read

Your coding agent is guessing which file to edit.

Every tool that applies a fix has to answer one question before it writes: which file? When the error does not name one, something has to guess, and most guesses are worse than you think.

ai-debuggingcoding-agentsdeveloper-toolsdebuggingcode-safetypostmortem

Before any tool can apply a fix, it has to answer a question that nobody talks about: which file.

When the error names a file, this is trivial. App.tsx:12 is a file and a line and there is nothing to decide. But a large share of real errors do not name a file you can write to. A React warning points into node_modules. A promise rejection surfaces with frames like at li and at App, no path, no line. A console error from a third-party script names a bundle URL that maps to nothing on disk.

In every one of those cases, something in your tool chain still has to pick a destination. And the thing it usually picks, in tool after tool, is whatever file you happen to have open.

I know this because I built one that did it, and then watched it append a JSX snippet to the bottom of index.html. Twice.

Four ways a tool decides where to write

Rank them by how much they can be trusted, because the ranking is the whole design.

  1. An exact string match in a file the user submitted. The strongest one. If the fix arrives as "replace this exact text with that exact text", and the text is found once in the file, the destination is not in question. Nothing is inferred.
  2. A path parsed from the error itself. Strong, with one caveat: the path has to survive a sanity check. A path into node_modules, a virtual module URL, or one of the synthetic sources a runtime invents (<eval>, VM12345, blob:) is a real string that is not a real file you should write to.
  3. A path from the debugger. Underused, and better than it sounds. A debug adapter's output event carries a sourcemapped source.path and line, which means it can tell you App.tsx:12 while the text of the same error is still talking about a bundle. If your tool parses the error text and ignores the adapter, it is throwing away the better answer.
  4. Ambient editor state. The active tab. The last focused document. This is not a source of truth. It is a coincidence between your attention and the error, and it is correct often enough to hide how often it is not.

The failure I want to describe is what happens when 1 through 3 all decline, and 4 is still sitting there at the end of the chain, ready to answer.

The five conditions, all reasonable, one bad outcome

This is a worked example from a debugging tool I build. The specifics belong to my code. The shape belongs to any tool that writes fixes.

I was testing a feature that captures browser errors and turns them into something you can act on. I picked a React key warning out of a list, and got back a generic diagnosis with a live Apply button.

Five conditions had to hold at once:

  1. The warning's source pointed into node_modules. The guard that refuses library paths did its job. Correctly. So there was no anchor path.
  2. The error text carried no path either. Frames read at li and at App. Nothing to parse.
  3. Resolution fell through to the active editor, which was index.html, because that is what I had open.
  4. index.html is not a library path, so the check that disables Apply for library files saw nothing to complain about. Apply stayed enabled.
  5. The fix carried no line hint. With no hint, the applier does a keyword search to find an insertion point. The first line of the fix was a comment. No match.

Step 5 is where it turns from wrong file into wrong file and garbage, because of a default:

ts
const fixFirstLine = fixCode.trim().split(/\r?\n/)[0].toLowerCase();
let insertAt = fileLines.length;      // the default
let anchored = false;
const defMatch = fixFirstLine.match(/def\s+(\w+)|class\s+(\w+)|function\s+(\w+)/);

insertAt starts at the end of the file. The search only ever moves it earlier. Miss the search, and "insert" silently means "append".

Warning: A fallback that means "I could not find the target" should not be spelled the same as "the target is the end of the file". That single line of code is the difference.

That default had been in place for a long time without incident, because until this feature existed, every path into the applier came from an error that named a file. The landmine was old. The feature is what made stepping on it routine.

The other half: it was answering blind and nobody read the warning

The reason the fix was generic has the same root. No resolved file meant no editor was opened, which meant no code snippet went up with the request. The demo project had never been indexed, so no retrieval either.

The model received an error string and nothing else. To its credit, it said so in its own output: without code, it could not cite exact lines.

So the analysis layer correctly reported that it was working blind, and the client took that blind answer and offered to write it to disk.

Note: An honest upstream does not protect you. If a component can report "I had nothing to work with", every consumer downstream has to actually read that field and change behaviour. Otherwise the honesty is decoration.

Three rules for anything that writes to disk

These are what I changed, stated so they are useful outside my codebase.

Never infer a destination silently. If the target file came from ambient state rather than from the error, that fact has to travel with the request. Downstream code cannot make a good decision about a guess it does not know is a guess. In my case the request now carries a flag saying the target was inferred.

The writer validates, not just the caller. A caller-side check protects the one code path you were thinking about the day you wrote it. The function that rewrites the file is the piece that cannot be routed around, so the refusal belongs there. Mine grew two guards: one that refuses a snippet whose language cannot live in the target file (a bare JS or Python statement bound for markup or a stylesheet), and one that refuses the end-of-file append when the caller admits its target was inferred.

Unknown should disable the write, not pick a default. "I could not find where this goes" has exactly one safe behaviour: return the file unchanged and let the caller offer copy instead. Every other behaviour is a guess wearing the clothes of a decision.

A fourth, softer one, from the same incident: any list that hides items has to explain the hiding. The React warning was classified as never-toast, which I had read as "cannot interrupt you". The history list that fed the quick pick included it anyway, rendered identically to a real exception. Suppressed and unreachable are different promises, and a suppressed item shown in the same font as a real one has had its suppression quietly undone.

Sixty seconds to test whatever you use

I am not going to tell you how any other tool behaves, because I have not read their source and neither has anyone who confidently posts about it. Run this yourself, on whatever assistant, agent, or internal script you point at your repo.

bash
# 1. Open an unrelated file. index.html, a README, anything not code.
# 2. Produce an error with no writable path in it. A React key warning works,
#    so does any exception raised inside a dependency.
# 3. Hand it to your tool and go all the way to the apply step.
# 4. Watch which file it opens the diff against.

Three outcomes, in descending order of how much I would trust the tool:

  • It tells you it cannot locate the file and offers the fix as text. Good.
  • It shows you a diff against a file, and the file is the one you had open. Now you know its resolution chain ends in ambient state. Useful to know before it matters.
  • It writes without showing you a diff at all. That is the one worth finding out about on a scratch repo rather than on your work branch.

The test costs a minute and it tells you something about your tooling that no changelog will.

FAQ

Is "use the open file" a reasonable default, given it is usually right?

It is usually right, which is the problem. A heuristic that is correct 90% of the time and silently destructive the other 10% is worse than one that refuses, because you stop checking. Correctness rate is the wrong metric for a destructive operation. The right one is what happens on the miss.

Does refusing to apply make the tool less useful?

Only in the cases where it was about to be wrong. Errors that name a file still get one-click apply, which is most of them. The narrow rule is: if the target file was inferred rather than named, refuse the write that depends on the guess and offer the fix as text.

Why not require the model to always output a file path?

Because it will always output one. That is the thing models are good at. A required field gets filled whether or not the information exists, and a confidently invented path is harder to catch than a missing one. Absent is a better signal than fabricated.

Where did this actually happen?

On a demo app in /tmp, on an unreleased branch, which is why this is a blog post rather than an apology. The applier guards are the part that shipped, and they default to previous behaviour for every existing caller.


DebugAI reads the stack trace, pulls the files that matter, and hands you a fix you can apply in one click. It now also knows when it does not know where to put one.

Debug faster starting today.

Free VS Code extension · 10 sessions/day · no credit card

Install free →

Related posts

Engineering

The problem with AI code review is not that it misses bugs

9 min read

Engineering

Not one team could verify an AI fix in a single deploy.

8 min read

← All posts