ccdwyer/assertion-guardian

A Claude Code mod that refuses edits weakening test files: removed or loosened assertions, deleted cases, added skips, swallowed failures, snapshot rewrites and shell-based test tampering, with an allow-once override band above the prompt.
ccdwyer/assertion-guardian

Assertion Guardian is a Claude Code plugin that stops the agent from making tests pass by making the tests weaker. Before any Edit, Write or NotebookEdit to a test file runs, it computes the whole file before and after the change and refuses the change if the tests got weaker. A light scanner separates code from comments and string literals so assertions inside docstrings, comments or fixture strings are not counted. It detects removed assertions, deleted test cases, changed or dropped expected values, loosened or replaced exact matchers, vague matchers, constant assertions, flipped polarity, removed table rows, added skips or focus markers, commented-out assertions, swallowed failures, dead branches, hand-rewritten snapshots, bulk snapshot updates, and test files changed through the shell (rm, mv, sed -i, >, tee, git rm, find -delete, git checkout, inline python -c/node -e). Deleted test files are compared against the last committed version via git, so rewriting from scratch does not bypass the check. The refusal lists the signals that fired and tells the model to fix the code under test or ask the user. Normal refactors pass. Override via an allow-once/dismiss band above the prompt or the /allow-test-change command, tied to the exact tool call. Test files are recognised by common naming patterns and snapshot paths. Hooks session.start, command.run{allow-test-change}, tool.call and ui.render{AbovePrompt}. Runs entirely locally with no network access or telemetry; license MIT.
Check the author's README for the marketplace and plugin name first. Commands may change as the repository evolves.
claude plugin marketplace add ccdwyer/assertion-guardian claude plugin install assertion-guardian

The model is asked to change an expected value so a failing test passes. Assertion Guardian refuses and names the signal, the band above the prompt offers allow once, and the real bug gets fixed instead. MP4
A Claude Code mod that stops the agent from making tests pass by making the tests weaker.
Before any Edit, Write or NotebookEdit to a test file runs, the mod works out the whole file before and after the change. It refuses the change if the tests got weaker. A light scanner first separates code from comments and string literals, so an expect( inside a docstring, a comment or a fixture string is never counted as code.
| Signal | Example |
|---|---|
| Assertions removed | expect(...), assert, assert.strictEqual, XCTAssert*, assertThat, t.Error, #expect |
| Test cases deleted | it(...), test(...), def test_*, func Test*, @Test |
| Expected values changed or dropped | Jest, Node assert, unittest/pytest, AssertJ, chai, RSpec, XCTest: toBe(3) changed to toBe(4), toHaveBeenCalledWith(1) changed to toHaveBeenCalled(), an edited inline snapshot, assert f() == 1 changed to == 2 |
| Exact matchers replaced or loosened | toBe(3) changed to toBeTruthy(), toBeGreaterThanOrEqual(3) or toBeCloseTo(3), toStrictEqual changed to toEqual, toBeGreaterThan(3) changed to toBeGreaterThanOrEqual(3) or toBeLessThan(3), toBeCloseTo(3) changed to toBeCloseTo(3, 0) |
| Vague matchers added | toEqual(0) changed to toEqual(expect.any(Number)) |
| Constant assertions added | expect(3).toBe(3), assert.strictEqual(3, 3), assert 3 == 3, XCTAssertEqual(3, 3), assert True |
| Assertion polarity flipped | .toBe(3) changed to .not.toBe(3), strictEqual changed to notStrictEqual, assert x == 3 changed to != 3, is None changed to is not None |
| Test data rows removed | a row deleted from a Go table test or a test.each table |
| Skips added | it.skip, xit, it.todo, test.fail, this.skip(), @pytest.mark.skip/xfail, @Disabled, @Ignore, t.Skip, XCTSkip |
| Focus added | .only / fit(, which silently drops every other test |
| Assertions commented out | a line that held an assertion reappears as a comment |
| Assertion failures swallowed | a try around an assertion whose catch / except / rescue doesn't rethrow, pytest.raises(AssertionError), expect(() => expect(...)).toThrow() |
| Dead branches | if (false) { expect(...) }, return before the assertions |
| Snapshot files rewritten by hand | *.snap, __snapshots__/ |
| Snapshots updated in bulk | jest -u, npm test -- -u, --update-snapshots, cargo insta accept, INSTA_UPDATE=1, loki update, backstop approve, chromatic --auto-accept-changes. Wrappers such as env, npx, pnpm exec, pnpm --filter and python -m are looked through |
| Test files changed through the shell | rm, mv, sed -i, perl -pi, > redirects, tee, git rm, rm -rf tests, find ... -delete, git checkout <rev> -- file, bash -c "...", one-off python -c / node -e scripts that name a test file |
Suppose a test file has been deleted and is then written again from scratch. The new version is compared with what git last committed, so deleting the file doesn't get around the check.
The refusal lists which signals fired. It tells the model to fix the code under test, or to ask you if it thinks the test itself is wrong.
Normal refactors pass: renaming the value under test, reformatting, reordering, moving assertions between tests, adding tests, switching quote style, swapping in stronger matchers, and changing toBe(null) to toBeNull().
Override: a band above the prompt shows the last blocked change, with allow once and dismiss buttons. Or run /allow-test-change, which runs immediately, even mid-turn. An allowance is tied to that exact call (same tool, same file, same content), so the model has to retry the identical change. A different edit to the same file is checked from scratch.
This is a guardrail, not a sandbox. It checks what tool calls say they'll do. A determined process could still change files some other way, such as a script that never names the test file.
Test files are recognised by *.test.*, *.spec.*, test_*.py, tests.py, *_test.{py,go,rb,ex,dart}, *_spec.rb, *Tests.swift, *Test.{kt,java,cs}, and snapshot paths. Code files under __tests__/, test/, tests/ or spec/ count too, except those inside fixtures/, support/, helpers/, factories/, __mocks__/ or testdata/.
/plugin marketplace add ccdwyer/claude-mods
/plugin install assertion-guardian@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=allow-test-change}tool.callui.render{component=AbovePrompt}Engine calls it makes: $.command.register, $.fs.exists (via previous), $.fs.read (via previous), $.process.run (via previous), $.state.get, $.state.set, $.ui.resolve.
A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod uses that only for the behaviour described above.
It runs entirely on your machine. It sends nothing over the network. It runs git locally to read the previous version of a test file.
The mod collects no analytics or telemetry, and its author receives no data from it.
Full policy: PRIVACY.md.
MIT