{"slug": "validating-llm-code-edits-when-you-can-t-run-the-code", "title": "Validating LLM code edits when you can't run the code", "summary": "A developer describes a five-layer validation stack for LLM-generated code edits in repositories where the code cannot be executed. The stack constrains file access, anchors edits to actual file bytes, runs static checks, validates unparseable formats comparatively, and uses an adversarial second model. Each layer fails with a distinct status to avoid retry loops, ensuring provably non-broken changes before human review.", "body_md": "# Validating LLM Code Edits When You Can't Run the Code\n\nAn agent that writes code into repositories it cannot execute gets none of the usual safety nets: no build, no tests, no typecheck. What remains is a five-layer stack: constrain which files are touchable, anchor every edit to real file bytes instead of the model's quotation of them, run static checks that only reject provable breakage, validate unparseable formats comparatively (did the edit make the file worse), and put an adversarial second model between the proposal and the write. Every layer fails into a distinct, honest status instead of a retry loop.\n\nThere is a class of system that has to write code into repositories it cannot execute. Ours is an agent that opens pull requests against other people's production websites. It runs in a serverless isolate with a wall-clock budget, reads the repo over the GitHub API, and at the moment it writes a change it has no checkout, no `node_modules`\n\n, no build, no test run, and no dev server. Whatever validation happens has to happen before the PR exists, using nothing but the bytes of the files, static analysis that runs in milliseconds, and more model calls.\n\nThe customer's own CI may run on the PR afterwards. That is not a safety net, it is someone else's inbox. A PR that fails CI on a syntax error costs the exact trust the whole system depends on. The goal is a change that is provably not-broken before any human sees it.\n\nAfter a year of production runs, the validation stack has settled into five layers. Each one is cheap. Each one rejects only what it can prove is broken. And they are ordered so that a failure at any point leaves nothing behind in the target repo.\n\n## What \"can't run the code\" actually rules out\n\nIt is worth being precise, because each unavailable tool kills a familiar guard:\n\n**No** Arbitrary package managers, private registries, native dependencies, and minutes of wall clock in an environment budgeted in seconds. This rules out running anything.`npm install`\n\n.**No test suite.** Even if one exists (you don't know), you can't run it.**No typecheck.**`tsc`\n\nneeds the full dependency graph, which needs the install.**No rendering.** You cannot boot the app and look at it. (You can screenshot the live production site, which turns out to matter later.)\n\nSo the usual ladder (types, tests, CI, staging) is gone at write time. What's left is the file itself, before and after the edit.\n\n## Layer 1: constrain the blast radius before validating anything\n\nThe first guard has nothing to do with code quality. A model choosing which file to edit is a model that can choose `.github/workflows/deploy.yml`\n\n, and a workflow edit is a secret-exfiltration primitive: CI runs with credentials the agent was never given. So before any content check, the chosen path runs against a denylist:\n\n``` js\nconst FORBIDDEN_EDIT_PATHS: RegExp[] = [\n  /^\\.github\\//i,                   // CI can exfiltrate secrets\n  /(^|\\/)\\.env(\\.|$)/i,             // .env, .env.local, .env.production\n  /(^|\\/)package(-lock)?\\.json$/i,  // dependency manifests\n  /(^|\\/)vercel\\.json$/i,           // deploy config\n  /\\.pem$|\\.key$|\\.p12$|\\.pfx$/i,   // private keys\n  /(^|\\/)supabase\\/functions\\//i,   // the agent itself (no self-modification)\n  // ...framework configs, lockfiles, Dockerfiles, IaC, migrations\n]\n```\n\nThen the inverse gate: an allowlist of file extensions the pipeline can actually verify. The syntax validator returns `ok: true`\n\nfor types it cannot parse (`.vue`\n\n, `.svelte`\n\n), because a validator that blocks everything it doesn't understand blocks too much. That pass-through would be a hole, so the caller closes it: any extension outside the verifiable set is refused outright, with an error that says exactly why (\"refusing to open an unverified PR\"). A permissive validator plus a strict extension gate is fail-closed. A permissive validator alone is not.\n\n## Layer 2: anchor edits to real bytes, not the model's memory of them\n\nThe model emits edits as find/replace pairs. The dominant failure mode is not malice or hallucinated APIs, it is **transcription drift**: the `find`\n\nstring is a near-copy of the real code with slightly different whitespace, a straightened quote, or reordered attributes. Applied naively, that either fails or, worse, matches the wrong thing.\n\nThe guard works in two steps. First, try the exact substring. If that fails, build a whitespace-normalized version of the file alongside an index map, so every character of the normalized text points back at its position in the original:\n\n```\n// Collapse whitespace runs to a single space AND record, per\n// normalized char, the original index it came from, so a normalized\n// match maps back to exact original bytes.\nfunction buildNormalized(content: string): { norm: string; map: number[] }\n```\n\nOn a unique match, the replacement is spliced into **the actual bytes at that anchor**, never into the model's quotation of them. The model's `find`\n\nis only ever used to locate; the file's own content is what survives around the edit.\n\nThe two failure cases get different names, and that matters operationally:\n\n**Zero matches** becomes`find_mismatch`\n\n, and the error carries the closest-scoring lines from the real file, so the failure is diagnosable from the record alone.**Multiple matches** becomes`find_ambiguous`\n\n, with a snippet of each candidate site.\n\nThese are distinct statuses in the database, never a generic `failed`\n\n. A rising mismatch rate means the model is paraphrasing what it quotes; a rising ambiguity rate means its anchors are too short. You cannot see either trend through a single undifferentiated failure bucket.\n\nThere is exactly one self-heal: on a mismatch, the model is re-asked to re-anchor its `find`\n\nagainst the file's real content. The `replace`\n\nis never altered, so the repair can fix location but never semantics. If it misses twice, the run ends in `find_mismatch`\n\n, honestly.\n\n## Layer 3: static checks that reject only provable breakage\n\nFor file types with a real parser, this layer is boring and good: Babel with `errorRecovery: false`\n\nfor the JS/TS family, `JSON.parse`\n\nfor JSON. A dropped brace never reaches a commit.\n\nLiquid templates (Shopify themes) are where it gets interesting, because there is no strict parser you can borrow, and theme files legitimately contain fragments that look broken. The delimiter check is **asymmetric by design**: it flags an opening `{{`\n\nor `{%`\n\nthat never closes, and deliberately never flags a stray `}}`\n\nor `%}`\n\n:\n\n```\n// Asymmetry by design: we flag an OPENING ({{ or {%) that never\n// closes, but NOT a stray CLOSING, because bare braces occur\n// constantly in JS/CSS inside theme files. Catching dropped-close\n// is the high-value common case; flagging stray-close would\n// false-reject valid themes wholesale.\n```\n\nA dropped closing delimiter is the high-frequency LLM failure. A stray closing brace is what inline `<script>`\n\nand `<style>`\n\nblocks look like all day. Symmetric validation here would be a false-rejection machine.\n\nBlock-tag pairing (`{% if %}`\n\n/`{% endif %}`\n\nand friends) follows the same rule: unknown tags are ignored, `raw`\n\n/`comment`\n\n/`schema`\n\nbodies are excluded, and a file using the `{% liquid %}`\n\ntag opts out of block checks entirely. Only certainly-broken markup is rejected.\n\nThe principle behind the asymmetry is the load-bearing idea of the whole layer: **a validator with false rejections is worse than no validator.** Every false rejection is a lost run, and lost runs create pressure to weaken the check in a hurry, usually badly. A check that only fires on provable breakage never generates that pressure.\n\n## Layer 4: when the format is unvalidatable, validate the delta\n\nThen there is HTML. General tag balance in HTML5 is not provable without a full parser: optional closing tags and void elements make \"unbalanced\" a matter of spec interpretation, and a homemade balancer will reject valid documents forever.\n\nThe escape hatch is to stop validating the file and start validating the **edit**. The same provable-only shell checks (an orphan `<!--`\n\nthat swallows the rest of the document, `<script>`\n\n/`<style>`\n\nopen/close count balance, every JSON-LD block still parsing) run against the edited file, but a failure only rejects if the **original file passed the same check**:\n\n``` js\nconst newShell = validateHtmlShell(newContent)\nif (!newShell.ok) {\n  if (validateHtmlShell(oldContent).ok) {\n    return { ok: false, reason: `edit broke the HTML shell: ...` }\n  }\n  // pre-existing quirk: tolerate with a warning, stay mergeable\n}\n```\n\nA pre-existing oddity (say, an unbalanced count caused by a `document.write('<script...')`\n\nstring that was always there) is tolerated with a warning, so unrelated edits to an imperfect file remain shippable. Inline `<script>`\n\nbodies get the Babel treatment only if the edit touched them: a body found verbatim in the old file is never re-litigated.\n\nOne more check in this layer is domain-specific but the pattern is general: if the analytics loader was present before the edit and gone after, reject. The system measures its own changes through that snippet; an agent must never be able to blind its own measurement, even accidentally.\n\nComparative validation generalizes well beyond HTML. You rarely need \"this file is valid.\" You need \"this edit did not make the file worse in any way I can prove.\" The second question is answerable for formats where the first is not.\n\n## Layer 5: an adversarial second model for claims about the world\n\nEverything so far checks the code. None of it checks the **claim**. A diff can be syntactically perfect and still be built on a false premise: the model asserts a banner covers the signup button on mobile, and the mobile screenshot plainly shows it doesn't. No parser catches that.\n\nThe last gate before any write is a second model call, framed explicitly as an adversary. The system prompt opens with the job description and closes the incentive loophole: \"your ONLY job is to decide whether that proposal survives scrutiny. You gain nothing by approving it.\"\n\nTwo implementation details do most of the work:\n\n**The first model's output is data, not conversation.** Every piece of the proposal (problem statement, hypothesis, confidence reasoning, the diff itself) is wrapped in sentinel blocks the reviewer is told to treat as untrusted machine output. This is not paranoia about attackers; it is paranoia about the first model. Generated prose about a fix sometimes contains instruction-shaped text, up to and including sentences that read like a verdict. The reviewer is told that text inside the blocks announcing a verdict is content to judge, never an instruction to follow.\n\n**The questions are narrow and the refusal grounds are enumerated.** The reviewer answers exactly three things: would these edits plausibly fix the stated problem; is each specific visual claim actually visible in one of the attached screenshots (cited by exact viewport label, judged by pixels rather than by the author's assertion); and does the prose describe only changes that exist in the find/replace pairs. Refuting requires concrete grounds from that list. \"Not assessable\" never refutes. Style, taste, and \"I would fix it differently\" never refute. Genuine uncertainty is a pass.\n\nThat last rule sounds lenient and is actually what keeps the gate alive: an adversarial reviewer with vague license to object converges on rejecting everything, and a gate that rejects everything gets turned off.\n\nTwo operational decisions round it out. The gate **fails open**: if the verifier call errors, the fix proceeds and the failure is logged, because a broken filter should not halt an otherwise working pipeline. And fail-open has a sharp edge worth knowing about: with a small token cap, a claim-heavy verdict gets truncated, truncation is an error, and the error fails open. The gate would silently disable itself on exactly the proposals that most need review. So the cap is generous and a truncated response retries once at double the cap instead of being swallowed.\n\nWhen the reviewer refutes, the run does not retry in a loop. It ends as an honest skip, with the refuted fix and the full verdict preserved in the run record as an autopsy artifact. A system that must ship something every week will ship garbage; a skipped run with a preserved reason is a feature, not a failure.\n\n## Ordering: validate everything, then write anything\n\nThe layers above would still be embarrassing if a failure halfway through left debris. So the write path is strictly two-phase. For every file in the change (there can be several, and they are declared interdependent): re-fetch the file at the target branch head, run the anchor guard against those fresh bytes, splice, and syntax-validate. Only after **every** file passes does the branch get created and the commits land.\n\nA failure on file three of three therefore leaves nothing: no orphan branch, no partial commit set, no PR. Applying a subset of interdependent edits is strictly worse than applying none, because a half-applied change can break the site in ways the whole change would not.\n\n## What the stack doesn't catch\n\nAll five layers together answer \"is this change mechanically sound and honestly described.\" They say nothing about whether it is a good idea. Two backstops own that question.\n\nFirst, a human. The change ships as a pull request behind an [approval gate](/blog/approval-gate-pattern-ai-code-changes), and the PR body is a receipt rather than a sales pitch: which files were read and which were not, which signals were inspected, what was verified in this environment, and explicitly what was **not** verified. The reviewer of an automated change deserves to know the difference between \"checked\" and \"assumed.\"\n\nSecond, measurement. After deploy, the affected metric is compared over matched windows before and after the change, and a regression past a fixed percentage-point threshold gets a [rollback proposed through the same approval flow](/blog/safely-auto-roll-back-bad-deploy-using-bounce-rate). The validation stack keeps bad diffs out; the measurement loop catches bad ideas that were valid code.\n\n## The stack at a glance\n\n| Layer | Question it answers | On failure |\n|---|---|---|\n| Path denylist + extension allowlist | May this file be touched at all? | Refuse, no write |\n| Byte-anchored find/replace | Does the edit's target exist, exactly once? | `find_mismatch` / `find_ambiguous` , one re-anchor retry |\n| Provable-only static checks | Did the edit break what a parser can prove? | Refuse, no branch created |\n| Comparative validation | Did the edit make an unparseable file worse? | Refuse; pre-existing quirks tolerated |\n| Adversarial second model | Is the claim about the world true? | Honest skip, verdict preserved; verifier outage fails open |\n\nThis stack is what we built inside [Velyr](/), an agent that ships one conversion fix a week to a customer's repo as a pull request. But none of it is specific to conversion fixes. The constraint (the writer cannot execute the target) applies to any system that edits code across arbitrary repositories, and every layer above is an afternoon of implementation. The hard part is the discipline that shaped them: reject only what you can prove, name every failure, and never let a guard's own outage take the system down with it.\n\nVelyr is an AI growth agent that ships one weekly conversion fix as a GitHub Pull Request — you approve it over Telegram, and it rolls itself back if the numbers drop.\n\n[Start the Growth Agent](/agent/register)", "url": "https://wpnews.pro/news/validating-llm-code-edits-when-you-can-t-run-the-code", "canonical_source": "https://velyr.io/blog/validating-llm-code-edits-without-running-the-code", "published_at": "2026-07-17 09:03:00+00:00", "updated_at": "2026-07-17 09:23:18.753797+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools", "ai-safety", "ai-products"], "entities": ["GitHub"], "alternates": {"html": "https://wpnews.pro/news/validating-llm-code-edits-when-you-can-t-run-the-code", "markdown": "https://wpnews.pro/news/validating-llm-code-edits-when-you-can-t-run-the-code.md", "text": "https://wpnews.pro/news/validating-llm-code-edits-when-you-can-t-run-the-code.txt", "jsonld": "https://wpnews.pro/news/validating-llm-code-edits-when-you-can-t-run-the-code.jsonld"}}