DEV Community

Dakota Huang
Dakota Huang

Posted on

You Drafted the Patch. Now Prove It Changed Nothing.

AI writes patches faster than humans review them. That is the new bottleneck. Diff review checks what you notice. A characterization gate checks what the code does. The script is the reviewer. The snapshot is the memory.

This workflow pins current behavior first. Then it runs your patch through a before/after comparison. If observable output is identical, the patch is safe to review. If not, the diff shows exactly what changed.

Everything here runs with free resources. The gate is one Node script plus one JSON file. No dependencies. No new services. Free resources keep this reproducible for every reader.

The reviewer problem

You asked a model to refactor legacy code. It returns a clean diff. The diff looks correct. Your eye scans for renamed variables and obvious bugs. That is not testing. A patch that compiles is not a patch that behaves.

Legacy code hides contracts in magic numbers. It hides side effects in a module-level cache. It hides rounding rules in the order of operations. Nobody documented those. Characterization tests record what the code actually does today.

The messy function

Here is the target. One pricing function with a cache, a console log, and three discount rules.

// legacy/pricing.js
const TAX_RATE = 0.075;
let cache = {};

function priceItem(item, user, force = false) {
  if (!item || !user) throw new Error("missing pricing args");
  const key = item.sku + ":" + user.tier;
  if (cache[key] && !force) return cache[key];

  let base = item.price;
  if (user.tier === "vip") base = base * 0.9;
  if (item.category === "digital" && user.country === "US") {
    base = base * 0.95;
  }
  if (base < 1) base = 1;

  let tax = user.taxExempt ? 0 : base * TAX_RATE;
  let total = Math.round((base + tax) * 100) / 100;

  if (total > 100 && user.tier !== "vip") {
    total = Math.round(total * 0.98 * 100) / 100;
  }

  cache[key] = total;
  console.log("[pricing] " + key + " -> " + total);
  return total;
}

module.exports = { priceItem, __cache: cache };
Enter fullscreen mode Exit fullscreen mode

Note what is observable. Return values. Cache hits. Console lines. Each one is a contract.

Step 1: Pin behavior, not intentions

Build a small input matrix. Five cases cover every branch. Run each case twice to expose cache behavior. Each case isolates one branch combination.

// guard/cases.json
[
  { "name": "basic-us-physical", "item": { "sku": "A1", "price": 20, "category": "physical" }, "user": { "tier": "basic", "country": "US", "taxExempt": false } },
  { "name": "basic-ca-physical-high", "item": { "sku": "A2", "price": 200, "category": "physical" }, "user": { "tier": "basic", "country": "CA", "taxExempt": false } },
  { "name": "vip-digital-us", "item": { "sku": "D1", "price": 40, "category": "digital" }, "user": { "tier": "vip", "country": "US", "taxExempt": true } },
  { "name": "vip-de-physical", "item": { "sku": "P1", "price": 50, "category": "physical" }, "user": { "tier": "vip", "country": "DE", "taxExempt": false } },
  { "name": "basic-low-price", "item": { "sku": "L1", "price": 0.3, "category": "physical" }, "user": { "tier": "basic", "country": "DE", "taxExempt": false } }
]
Enter fullscreen mode Exit fullscreen mode

The guard records every observable signal. It stores return values, cache types, logs, and cache size. Baseline mode writes the snapshot. Verify mode compares against it.

// guard/guard.js
const fs = require("node:fs");
const path = require("node:path");

const SNAPSHOT = path.join(__dirname, "snapshot.json");

function capture() {
  const logs = [];
  const realLog = console.log;
  console.log = (...args) => logs.push(args.join(" "));

  const { priceItem, __cache } = require("../legacy/pricing.js");
  const cases = JSON.parse(fs.readFileSync(path.join(__dirname, "cases.json"), "utf8"));

  const observations = cases.map((c) => {
    const key = c.item.sku + ":" + c.user.tier;
    const first = priceItem(c.item, c.user);
    const second = priceItem(c.item, c.user);
    return { name: c.name, first, second, cacheStores: typeof __cache[key] };
  });

  console.log = realLog;
  return { observations, logs, cacheEntries: Object.keys(__cache).length };
}

function diff(prefix, a, b) {
  if (Object.is(a, b)) return;
  if (a === null || b === null || typeof a !== "object" || typeof a !== typeof b) {
    console.log("  " + prefix + ": " + JSON.stringify(a) + " -> " + JSON.stringify(b));
    return;
  }
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
  for (const k of keys) diff(prefix + "." + k, a[k], b[k]);
}

if (process.argv.includes("--baseline")) {
  fs.writeFileSync(SNAPSHOT, JSON.stringify(capture(), null, 2));
  console.log("Baseline saved.");
} else {
  const before = JSON.parse(fs.readFileSync(SNAPSHOT, "utf8"));
  const after = capture();
  if (JSON.stringify(before) === JSON.stringify(after)) {
    console.log("PASS: behavior unchanged.");
    process.exit(0);
  }
  console.log("FAIL: behavior changed.");
  diff("changes", before, after);
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Save the baseline

Run the gate once against the current code.

node guard/guard.js --baseline
Enter fullscreen mode Exit fullscreen mode

The snapshot records today's behavior. Bugs included. The minimum-price floor is included. The 2 percent top-end discount is included. That is the point. The baseline is not a test of intent. It is a test of history.

Commit the snapshot. It becomes the executable contract for the next refactor. Blame it, review it, version it like code.

Step 3: Draft the smallest refactor

Give the model one constraint. Keep observable behavior identical. Keep the signature. Keep the log line. Keep the cache. The model must not change the contract. The free model access in MonkeyCode drafts a patch.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The proposal looks clean. The model extracts nothing. It simplifies two lines instead. Both changes look like improvements. One hides a type change. One hides a deleted branch.

// proposed refactor (model-drafted)
const TAX_RATE = 0.075;
let cache = {};

function priceItem(item, user, force = false) {
  if (!item || !user) throw new Error("missing pricing args");
  const key = item.sku + ":" + user.tier;
  if (cache[key] && !force) return cache[key];

  let base = item.price;
  if (user.tier === "vip") base *= 0.9;
  if (item.category === "digital" && user.country === "US") base *= 0.95;

  let tax = user.taxExempt ? 0 : base * TAX_RATE;
  let total = (base + tax).toFixed(2);

  if (total > 100 && user.tier !== "vip") {
    total = (total * 0.98).toFixed(2);
  }

  cache[key] = total;
  console.log("[pricing] " + key + " -> " + total);
  return total;
}

module.exports = { priceItem, __cache: cache };
Enter fullscreen mode Exit fullscreen mode

toFixed(2) returns a string. The original returns a number. The cache now stores strings. Any caller doing arithmetic on the result breaks silently.

The model also dropped the price floor. It called the branch dead. The matrix includes a 0.30 item. The floor is exercised. The model missed it.

Step 4: Run the gate

Apply the patch to the working tree. Run the verify mode. One command.

node guard/guard.js
Enter fullscreen mode Exit fullscreen mode

The gate reports every delta. Output abridged for clarity.

FAIL: behavior changed.
  changes.observations.0.first: 21.5 -> "21.50"
  changes.observations.0.second: 21.5 -> "21.50"
  changes.observations.1.first: 210.7 -> "210.70"
  changes.observations.2.first: 34.2 -> "34.20"
  changes.observations.3.first: 48.38 -> "48.38"
  changes.observations.4.first: 1.08 -> "0.32"
  changes.observations.4.second: 1.08 -> "0.32"
  changes.observations.4.cacheStores: "number" -> "string"
  changes.logs.0: "[pricing] A1:basic -> 21.5" -> "[pricing] A1:basic -> 21.50"
Enter fullscreen mode Exit fullscreen mode

Two distinct failures. Every numeric result became a string. The low-price case lost its floor. Its output changed from 1.08 to 0.32. A human diff review missed both. The gate did not. String returns break callers. Number returns survive refactors. This is the difference between a refactor and a bug with a clean diff.

I ran the verification on MonkeyCode's free server option. It needed Node and a checkout. Nothing else.

Revert the trap lines. Keep a pure extraction if you want one. Run the gate again.

PASS: behavior unchanged.
Enter fullscreen mode Exit fullscreen mode

Now the diff is reviewable as a refactor. One behavior pinned. One change applied. Zero surprise. That is the whole argument for this workflow.

Why this beats diff review

  • Diff review checks what you see. The gate checks what the code does.
  • Diff review misses side effects. The gate records cache state and console output.
  • Diff review is subjective. The gate exits zero or it exits one.
  • Diff review scales with attention. The gate scales to every future patch.

The gate is plain Node and JSON. It does not depend on any model. It runs on any server. The same command works as a pull-request job. Run it on every pull request from a model.

Who should not use this

  • Teams deleting features on purpose. The gate locks old behavior. Update the baseline deliberately when the behavior change is the task.
  • Security fixes. Characterization pins the vulnerability. Write a regression test for the new behavior first. Then rebase the snapshot.
  • Non-deterministic code. Random values break snapshots. Seed the randomness and inject the clock before pinning.
  • Greenfield projects with spec tests. Those contracts already exist. Write intent tests instead.
  • Thin wrappers around third-party APIs. The contract lives upstream. Pin upstream responses, not the wrapper.

The matrix is not coverage. A case you omit is a behavior you cannot see.

The rule

Characterize first. Draft after. Gate before merge.

The model saves you typing time. The gate saves you trust time. You still sign the review. Now you sign it with evidence.

Try this on one function this week. Ten minutes of scripting. One less midnight incident.

Top comments (0)