# Why does monkeypatching break agent edits?

> Rule AR-18: No monkeypatching in production code. In tests, patch through the fixture/mock API at the import site.

Monkeypatching replaces a function or attribute while the program runs, so the code that executes is not the code in the file that defines it. An agent that edits the original can't see the replacement, and one that edits the replacement doesn't know what it stands in for. In production code, write the new behaviour as a function or class that is called directly. In tests, use the framework's patch API on the name the code under test looks up, so the replacement is scoped, named and undone.

## Why it happens
**Python.** Modules and classes are mutable objects, and `module.name = other` or `Class.method = other` rebinds a name for every later reader. The patch can live anywhere that runs before the call: an import-time statement, a `conftest.py`, a plugin. The reader of the file that defines `charge` sees no sign of it.

The standard library's mock docs state the rule that makes tests work and that trips up edits: `patch()` "works by (temporarily) changing the object that a *name* points to with another one. There can be many names pointing to any individual object, so for patching to work you must ensure that you patch the name used by the system under test." And: "you patch where an object is *looked up*, which is not necessarily the same place as where it is defined." Their example: if `b.py` does `from a import SomeClass`, patching `a.SomeClass` "will have no effect on our test", because `b` already holds a reference to the real class; patch `b.SomeClass`. If `b` does `import a` and uses `a.SomeClass`, patch `a.SomeClass`. The pytest docs say the same: "Prefer patching the reference that your code uses instead of patching the original object in the standard library", with `from os import getcwd` meaning you patch the name in your own module.

That is why a refactor that moves a function can leave a patch silently aimed at a name nobody looks up any more. The string form of the target (`"payments.client.charge"`) is invisible to rename tools and to caller searches. [CH-02](https://beta.llmgraph.in/agent-readable/ch-02.md) walks through that failure with an autouse fixture, and AR-34 and AR-35 cover the pytest side; this page does not repeat them.

Mock objects add a second blind spot. A plain mock accepts any call. The mock docs describe the fix: with `autospec`, "any functions and methods (including constructors) have the same call signature as the real object", so a call with the wrong arguments raises `TypeError`, and the mocks "fail in the same way as your production code if they are used incorrectly". Ruff's PT008 flags the lambda form of a patch, because the lambda "better conveys the intent" as `return_value` and doesn't adapt to changes in the patched function's signature.

**TypeScript and JavaScript.** ES modules are harder to patch. MDN states that imported bindings are "live bindings because they are updated by the module that exported the binding, but cannot be re-assigned by the importing module", and the example `myValue = 3` throws a `TypeError`. MDN also describes the module namespace object as sealed. So production code in TypeScript patches by other routes: assigning onto a property of an exported object (`api.fetchUser = wrapped`), changing a prototype, or setting a global. These are all runtime rebinds that no import or type declaration records. Test runners offer module mocks: Jest's `jest.mock` replaces a module with an auto-mocked version when it is required (for the file that calls it), and Vitest's `vi.mock` substitutes all imports of the given path with another module.

## Bad and good
A payments package wants to log every charge and retry on timeouts.

```python tab
# bad: payments/__init__.py (production monkeypatch)
from payments import gateway

_orig_charge = gateway.charge

def _charge_with_retry(customer_id, amount_cents, **kw):
    log.info(f"charge {customer_id} {amount_cents}")
    for attempt in range(3):
        try:
            return _orig_charge(customer_id, amount_cents, **kw)
        except TimeoutError:
            continue
    raise

gateway.charge = _charge_with_retry     # every caller of gateway.charge now runs this
```

```ts tab
// bad: payments/index.ts (production monkeypatch)
import { gateway } from "./gateway";      // gateway.ts: export const gateway = { charge, refund };

const origCharge = gateway.charge;

(gateway as any).charge = async (customerId: string, amountCents: number) => {
  console.log("charge", customerId, amountCents);
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await origCharge(customerId, amountCents);
    } catch (e) {
      continue;
    }
  }
  throw new Error("charge failed");
};
```

An agent asked to add a `currency` parameter to `gateway.charge` edits the definition, updates the direct calls it can find, and checks that the type checker passes. The wrapper still takes `(customer_id, amount_cents, **kw)` and forwards what it gets, so nothing fails until a path that goes through the patch drops the new argument. The patch only exists if `payments/__init__.py` has been imported first, so even the set of callers that get the retry depends on import order (AR-06).

```python tab
# good: payments/charging.py
from payments import gateway

def charge_with_retry(customer_id: str, amount_cents: int, currency: str = "usd") -> gateway.ChargeResult:
    log.info(f"charge {customer_id} {amount_cents}")
    last: Exception | None = None
    for _ in range(3):
        try:
            return gateway.charge(customer_id, amount_cents, currency)
        except TimeoutError as exc:
            last = exc
    raise RuntimeError("charge failed") from last
```

```ts tab
// good: payments/charging.ts
import { charge, type ChargeResult } from "./gateway";

export async function chargeWithRetry(
  customerId: string,
  amountCents: number,
  currency = "usd",
): Promise<ChargeResult> {
  console.log("charge", customerId, amountCents);
  let last: unknown;
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await charge(customerId, amountCents, currency);
    } catch (e) {
      last = e;
    }
  }
  throw new Error("charge failed", { cause: last });
}
```

Callers import `charge_with_retry` and a search for it finds every one that gets retries. A signature change in `gateway.charge` is an error in this one wrapper, since it calls `charge` by name with typed arguments.

In tests, the same rule applies one level down. Patch the name the code under test looks up, and ask for a signature-checked mock:

```python tab
# good: tests/test_checkout.py
from unittest.mock import patch
from payments import gateway

def test_checkout_charges_the_cart_total():
    with patch("checkout.charge", autospec=True) as charge:     # checkout.py does `from payments.gateway import charge`
        charge.return_value = gateway.ChargeResult(ok=True, charge_id="ch_test")
        receipt = checkout("cus_42", [1200, 800])
    charge.assert_called_once_with("cus_42", 2000, "usd")
    assert receipt.charge_id == "ch_test"
```

```ts tab
// good: checkout.ts takes the dependency as a parameter, test passes a stand-in
export async function checkout(
  customerId: string,
  cents: number[],
  charge: (id: string, amount: number, currency: string) => Promise<ChargeResult> = defaultCharge,
): Promise<Receipt> {
  const result = await charge(customerId, cents.reduce((a, b) => a + b, 0), "usd");
  return { chargeId: result.chargeId };
}

// test
const calls: Array<[string, number, string]> = [];
const fake = async (id: string, amount: number, currency: string) => {
  calls.push([id, amount, currency]);
  return { ok: true, chargeId: "ch_test" };
};
await checkout("cus_42", [1200, 800], fake);
```

The Python test names its target at the point of use, is undone when the `with` block ends, and fails with `TypeError` if `checkout` calls `charge` with arguments the real function wouldn't accept. The TypeScript version avoids patching: the dependency is a typed parameter with a default, so the test passes its own function and the type checker verifies it.

## Trade-offs and when monkeypatching is acceptable
- **In tests it is the practical tool.** `monkeypatch` and `patch` undo themselves, pytest's docs list the methods (`setattr`, `setitem`, `setenv`, `chdir`, `context` and more) and say all changes "will be undone after the requesting test function or fixture has finished". For code that wasn't written with injection in mind, a scoped patch is the cheapest way in.
- **Passing the dependency in is more work up front.** A parameter or constructor argument costs a line at the definition and at each call site. It buys a call the type checker can follow and no patch to chase.
- **Patching builtins and library internals is risky.** The pytest docs warn that patching builtins like `open` or `compile` "can break pytest's internals", and the same goes for the standard library and third-party libraries. Scope it with `monkeypatch.context()`.
- **Environment, clock and filesystem patches are an acceptable use.** `setenv`, `chdir` and a fixed clock don't hide a call; they change an input. Keep them in a named fixture.
- **Instrumentation and tracing tools often patch by design.** Keep them out of application code paths, in one module that says what it patches.
- **Gradual migration.** A legacy patch you can't remove yet should live in one file with a comment naming the target and the reason (AR-19 covers the neighbouring pattern of assigning methods onto objects).

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| ruff | `B010` set-attr-with-constant | `setattr(obj, "name", value)` with a literal name, which should be an assignment. It does not catch `module.name = value` | 2026-10-30 |
| ruff | `PT008` pytest-patch-with-lambda | a lambda used as the replacement in a pytest patch; use `return_value`. A lambda that uses its arguments is not flagged | 2026-10-30 |
| pyright | `reportAttributeAccessIssue` (error in basic, standard and strict) | attribute access problems, per its docs description; checked with pyright 1.1.414: it also covers assigning an undeclared attribute ("Attribute is unknown") and assigning a value of the wrong declared type | 2026-10-30 |
| mypy | default checking | assigning to a method or module attribute with an incompatible type (checked with mypy 2.4.1: `method-assign` and `assignment` on a method patch, `assignment` on a module attribute, all on by default) | 2026-10-30 |
| unittest.mock | `patch(..., autospec=True)`, `create_autospec` | a mocked call with arguments the real signature doesn't accept (raises `TypeError`) | 2026-10-30 |
| pytest | `monkeypatch.setattr(..., raising=True)` (the default; the docs describe `raising` as controlling whether a missing target errors) | a patch aimed at a name that doesn't exist | 2026-10-30 |
| typescript-eslint | `no-explicit-any` (in `recommended`) | the `(x as any).prop = ...` cast that most TypeScript monkeypatches need | 2026-10-30 |

I found no rule in the ruff, pyright or typescript-eslint pages checked that flags a bare `module.name = value` rebind as monkeypatching. Review for it, and use the type checker's attribute rules to catch the cases where the module or class doesn't declare the name.

## Rule block for AGENTS.md / CLAUDE.md
```markdown
## Monkeypatching
- Don't assign over functions, methods or module attributes in production code (`module.fn = ...`, `Class.method = ...`, `(x as any).prop = ...`, prototype edits). Write a wrapper function and call it, or pass the dependency in as a parameter.
- In tests, use `monkeypatch.setattr` or `unittest.mock.patch` on the name the code under test looks up (`patch("checkout.charge")` when checkout.py did `from ... import charge`). Prefer `autospec=True`.
- No autouse fixtures that patch application code unless the fixture is named for what it does and the patch is mentioned in the test's own docstring or setup.
- Before changing a function's signature, search for patches of it (`patch(`, `monkeypatch.setattr`, `.name =`) and update them in the same change.
```

## Edge cases
- **`patch.object(Class, "method")`** names the attribute with a string, so it has the same rename problem as a dotted-path string. Prefer the object form where you can, and always with `autospec`.
- **Decorators that replace functions** (`@cache`, `@retry`) rebind the name at definition time. Preserve the signature (AR-12).
- **`from x import y` captured before the patch.** A module that already imported `y` keeps the real function, per the mock docs. Patch the importer's name.
- **`monkeypatch.setattr` with a string target** like `"module.attr"` is resolved at runtime. A renamed target fails when the test runs, since `raising` defaults to erroring on a missing target.
- **Test doubles for TypeScript classes** are cleaner with a shared interface (AR-15) that both the real class and the fake implement.
- **Patching in `conftest.py` or setup files** reaches tests that never mention it. See [CH-02](https://beta.llmgraph.in/agent-readable/ch-02.md).

## What LLMGRAPH shows
LLMGRAPH does not read string patch targets: a `patch("checkout.charge")` never appears in the callers of `charge`. A production rebinding shows as `passed as a value in 1 place` notes, not as a blind spot.

## References
- Python mock library, "where to patch", `patch()`, autospec: https://docs.python.org/3/library/unittest.mock.html#where-to-patch
- pytest, how to monkeypatch and mock modules and environments: https://docs.pytest.org/en/stable/how-to/monkeypatch.html
- Ruff PT008: https://docs.astral.sh/ruff/rules/pytest-patch-with-lambda/
- Ruff B010: https://docs.astral.sh/ruff/rules/set-attr-with-constant/ · rule list: https://docs.astral.sh/ruff/rules/
- Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
- MDN, `import` (live, read-only bindings; sealed namespace objects): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
- typescript-eslint `no-explicit-any`: https://typescript-eslint.io/rules/no-explicit-any/
- Chimera CH-02, pytest autouse fixture that monkeypatches a service: chimeras/CH-02-pytest-autouse-monkeypatch.md

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved.
- 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2, eslint 9.39.5, ruff 0.17.3, mypy 2.4.1)
- 2026-10-30: copy-gate wording fixes

## Related

- [AR-06: Why does my agent not see what importing this module does?](https://beta.llmgraph.in/agent-readable/ar-06.md)
- [AR-19: Why does reassigning methods hide callers from agents?](https://beta.llmgraph.in/agent-readable/ar-19.md)
- [AR-34: Why does my agent break pytest fixtures it can't see?](https://beta.llmgraph.in/agent-readable/ar-34.md)
- [AR-35: Where should I patch in pytest?](https://beta.llmgraph.in/agent-readable/ar-35.md)
- [CH-02: Why do my pytest tests pass for a reason I can't see?](https://beta.llmgraph.in/agent-readable/ch-02.md)

## Chimeras that use this rule

- [CH-02: Why do my pytest tests pass for a reason I can't see?](https://beta.llmgraph.in/agent-readable/ch-02.md)

Last verified 2026-10-30
