test(guards): make the guards prove what they claim about themselves - #101
Merged
Conversation
A guard built on a glob or a walk fails in a way that looks identical to success: the collection comes back empty and every assertion over it holds vacuously. Nothing noticed - renaming beantester/ would have silenced four guards at once, on green. Two halves, both mutation-checked: - test_the_repository_scanners_actually_read_files names one file each of the seven collectors must return, plus a floor on the count. The anchor catches a collector pointed at the wrong root, the floor catches one that narrowed. - test_the_repository_scanners_stay_out_of_what_is_not_in_the_repository keeps the whole-tree scans off internal_tools/, .claude/, crashes/ and the private notes. Measured: the dash scan covered 183 files, 12 of them git-ignored, so it measured a different set here than in CI - and a red CI cannot reproduce teaches that red is local noise. crashes/latest-crash.txt was in that set, carrying arbitrary text from OS exceptions. Convention 33 keeps its coverage of the private notes: the check moves to the Stop hook, which runs exactly where those files exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…in prose "Verified by mutation" is this project's strongest claim about its own tests. It appears over twenty times in the notes and in a dozen docstrings, and nothing checked a single one - convention 5's own evidence was exactly the unguarded prose convention 5 exists to catch. The claim is now data, in three states that mean three different things: - MUTATIONS: re-runnable today, five entries, proven by internal_tools/mutate.py - PROVEN_BY_HAND: a dated session did it and wrote no patch, so nothing repeats it - NOT_PROVEN: no mutation at all, named out loud The suite checks the bookkeeping, which is the half that rots: every named test exists, none is filed twice, and every search pattern still occurs exactly once, so an entry goes red the day the code moves rather than the day someone runs the rig. The rig itself lives outside git and carries a mandatory canary - a tree that fails to compile also exits non-zero, so without it a run can report "all caught" and prove nothing. First full run: 5 caught, 0 survived, canary BROKEN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nobody reads this code line by line, so nothing notices a function reaching three hundred lines over four sessions that each added twenty. A ceiling is the only mechanism that notices, and it costs nothing while nothing grows. Set at today's maxima - 167 logic lines for a function (theme.py::init_style), 1299 for a module (gui/app.py) - so nothing needed rewriting to make it pass. Down is routine work, up is the owner's decision: a threshold bent to fit the code has stopped being a threshold. Comments and docstrings are free, and that was measured before the metric was chosen: 9 776 of the package's 17 770 lines are logic, so 45% of this package is explanation. A raw-line cap would have been a cap on explaining, making deletion of the paragraph that says why a constant is 0.30 the cheapest way back under the limit. A second test pins that property directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Zero allocations" has sat in the hot-path section for a long time with nothing checking it. test_hot_path.py guards the neighbouring rule - no syscalls on the packet threads - and this catches the other class: something starting to be kept once per packet. At ~14k packets a second that is tens of megabytes a minute, and the symptom is not slowness but a session that dies after an hour. Ceilings are 64 blocks and 4096 bytes over 5000 calls, against a measured floor of 13 and 608 that does not move with duplication, latency, the NAT flow table or four times the ports. It reads TWO meters, and the reason is the useful part of this commit: the first version counted allocated blocks only, and the mutation that makes decide() append to an ever-growing list SURVIVED it. The appended value was a cached small int, so no object was created and the block count went from 6 to 7. Bytes see the same case as 42 032 against 208. The meter canary now proves both meters can rise, including the references-only case that defeated the first one. Transient garbage is still not caught, and the file says so: both meters are net, and CPython has no cheap deterministic counter of total allocations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four commits, one theme: the guards had claims about themselves that nothing checked.
1. The repository scanners now prove they scanned something
A guard built on a glob or a walk fails in a way that looks identical to success: the collection comes back empty and every assertion over it holds vacuously. Renaming
beantester/would have silenced four guards at once, on green.test_the_repository_scanners_actually_read_filesnames one file each of the seven collectors must return, plus a floor on the count. The anchor catches a collector pointed at the wrong root, the floor catches one that quietly narrowed.test_the_repository_scanners_stay_out_of_what_is_not_in_the_repositorykeeps the whole-tree scans offinternal_tools/,.claude/,crashes/and the maintainer's private notes.Measured: the dash scan covered 183 files, 12 of them git-ignored - so the same test measured a different set locally than in CI, and a red CI cannot reproduce teaches that red is local noise.
crashes/latest-crash.txtwas in that set, carrying arbitrary text from OS exceptions.2. "Verified by mutation" became data instead of prose
That phrase is this project's strongest claim about its own tests. It appears over twenty times in the internal notes and in a dozen docstrings, and nothing checked a single instance.
tests/test_mutation_registry.pyholds it in three states that mean three different things:MUTATIONS(re-runnable now),PROVEN_BY_HAND(a dated session did it, wrote no patch, so nothing repeats it) andNOT_PROVEN(no mutation, said out loud). The suite checks the bookkeeping - every named test exists, none is filed twice, and every search pattern still occurs exactly once, so an entry rots the day the code moves rather than the day someone runs the rig.The rig itself is a maintainer script (outside the repo, one subprocess suite run per entry) and carries a mandatory canary: a tree that fails to compile also exits non-zero, so without it a run can report "all caught" and mean nothing. Current state: 9 caught, 0 survived, canary BROKEN.
3. Size ceilings, as a ratchet
Function at 167 logic lines, module at 1299 - today's maxima, so nothing had to be rewritten to make it pass. Down is routine work, up is the owner's decision.
Comments and docstrings do not count, and that was measured before the metric was chosen: 9 776 of the package's 17 770 lines are logic, so 45% of this package is explanation. A raw-line cap would have been a cap on explaining, making deletion of the paragraph that says why a constant is 0.30 the cheapest way back under the limit. A second test pins that property directly.
4. The hot path is gated against retaining anything per packet
"Zero allocations" sat in the performance section with nothing checking it. Ceilings are 64 blocks and 4096 bytes over 5000 calls, against a measured floor of 13 and 608 that does not move with duplication, latency, the NAT flow table or four times the ports.
🔴 It reads two meters, and that is the useful part. The first version counted allocated blocks only, and the mutation that makes
decide()append to an ever-growing list survived it - the appended value was a cached small int, so no object was created and blocks went from 6 to 7. Bytes see the same case as 42 032 against 208. The meter canary now proves both meters can rise, including the references-only case that defeated the first one.Transient garbage is still not caught and the file says so: both meters are net, and CPython has no cheap deterministic counter of total allocations.
Testing
python -m pytest tests: 918 passed, exit 0 on an elevated shell.python smoke_gui.py: OK. Full mutation registry: 9 caught, 0 survived, canary BROKEN.Every new guard in this PR is itself mutation-checked - emptying a collector, moving the walk to a missing root, letting
internal_toolsback into the scanned set, making comments count as logic, and makingdecide()retain per packet each turn the matching test red.🤖 Generated with Claude Code