Nobody Broke It
updated
“That which is everybody’s business is nobody’s business.” — Izaak Walton, The Compleat Angler (1653)
I wrote a lint rule that reads the clock.
It looks at a deprecation deadline declared in a schema, calls new Date(), compares, and if the deadline has passed it fails. Eighteen rules in that plugin; that’s one of them. It’s published as a library and other repos consume it.
I also gave it a gracePeriodDays option. You set a number, the rule adds that many days to the deadline, and the failure goes away for a while.
So I shipped the problem and the workaround in the same commit. Three months on, both are still there. I checked today.
The tell is in the error message, which I also wrote:
Deprecation deadline "" has passed.
Remove the deprecated type or update the deadline.
Two fixes offered. One is do the work. The other is move the deadline. I wrote a rule whose own error text tells you how to switch it off, and I did that because some part of me already knew what was going to happen when the date arrived and didn’t want to look at it.
It took three months to see why. That rule can be perfectly correct — deterministic, no opinion, same input same verdict, every run — and still have no business failing anyone’s build. Because when it fires, there is nothing to point at. The code didn’t move. The schema didn’t move. Nobody did anything. A date arrived.
BLUF
- A check that imposes a consequence owes two answers, not one: why is this failing now, and whose obligation is this? Most checks never have to think about the second, because the diff answers both at once — the change explains the timing and the author is right there.
- A clock-triggered rule answers the first and not the second, and that’s the failure. The deadline explains the timing perfectly well. What it can’t do is point at anyone, so the consequence lands on whoever happened to be standing there when the check ran. They can usually clear it, and that’s the trap — being able to pay a bill is not the same as owing it.
- So supply the recipient separately. For new debt, a recorded prior-accepted state — a baseline that ratchets — narrows the trigger back to things a change actually caused. For the debt already in the baseline, a named owner and a scheduled path. Those are two different jobs and a baseline alone only does the first.
- Deterministic is necessary and not sufficient. I’ve argued before that only mechanised checks get to block, because a deterministic verdict is checkable and a model’s is a probable reading. I still think that. It turns out to be half the test. My deadline rule passes it comfortably and should still not be allowed to block, and finding that out is what this post is.
- The practical move: for anything a clock or a policy change can trigger, don’t reach for a diff. Fail only on the delta against a record of what you already accepted — and put a name and a date against the record itself, or you’ve bought a quiet build and lost the policy.
The version where the diff works
The other thing I’ve built is a multi-pass code reviewer. Detectors notice things on a change; a deep pass adjudicates the ones worth adjudicating.
It has the boundary. A detector hit only becomes a review lead if the hit’s evidence line is in the diff’s changed lines — literally, the line has to be in change.changedLines[path]. A hit on a pre-existing comment or an untouched branch gets dropped and never reaches the deep pass. That’s deliberate. Flagging code the author didn’t write in this change is how a review tool teaches people to stop reading it.
Then I went looking for evidence that the deep pass was working, and I forgot my own boundary.
To measure it I needed changes that would produce a lot of adjudications, so I wrote a corpus miner to find dense candidates. The miner counted detector hits anywhere in the head file. It found what looked like a rich seam. I ran the reviews.
Zero structured verdicts. Not few — zero. Everything the miner had counted was pre-existing, and the product did exactly what I built it to do: dropped all of it as unattributable to the change.
I fixed the miner to count only hits whose evidence line was in the changed lines, re-mined, and one single-file commit then produced four leads and four structured verdicts.
The number that matters there isn’t four. It’s that the boundary was already correct in the product and I dropped it anyway the moment I built a second tool that looked at the same code from a different angle. One incident is not a rate, and I’m not going to tell you this happens to everyone. It tells me the default — look at the whole file, count everything you find — is always sitting right there, always looks like more coverage, and beat me once on code where I had already written the correct version myself.
One honest limit: those leads aren’t a gate. They’re review judgment, nothing about them blocks a merge, and that repo’s CI validates rules consistency and nothing else — a working document in it still treats which checks are even gateable as unsettled, with a calibration pass agreeing with itself on nineteen of forty conditions. So this is the cheap version of the consequence: someone has to answer the lead. The expensive version is the plugin, which reports an error, and what an error does depends on a consuming repo’s CI I haven’t surveyed.
The version where there is no diff
Take the deadline rule and try to give it the same treatment. Compare against the diff, fail only on what the change introduced. It doesn’t work, and it’s worth walking through why, because every candidate fix fails in a slightly different and instructive way.
Compare against the diff. ESLint’s unit of work is a file. A rule receives one file’s source and reports against nodes in it. Nothing in the API carries a diff, so there’s nothing to compare to. You can bolt on a wrapper that filters reports by changed line — people do — and for the deadline rule it produces the wrong answer: the violating line is a deadline field somebody wrote months ago and nobody has touched since. Filter to changed lines and the rule silently never fires again.
Use the commit date. For any fresh branch that’s approximately now, so the rule fires on everyone.
Use git blame. This one is the most tempting and the most unfair. Blame resolves cleanly — it names the author who added the deprecated usage. That author did nothing wrong. When they wrote it the deadline was six months out and the usage was entirely legal. Blame will hand you a name with total confidence and the name is innocent.
Use the deadline itself. The deadline is the trigger, and it corresponds to no commit at all. There is no point in the repository’s history where someone did the thing that made this fail.
That’s the finding. A time-triggered rule has no attributable change by construction. Not “hard to attribute,” not “attributable to someone else” — the failing event is not a change. Each of those four ways of asking who did this takes a diff or a commit as its input. Here there is no such input, so each one still returns an answer, and each answer names somebody who did nothing.
gracePeriodDays is what that looks like from the inside. Not a grace period — a postponement knob, on a rule that was going to start reporting an error nobody had agreed to owe the moment a date I picked myself went past, wherever it happened to be running.
Deterministic isn’t the whole test
I’ve drawn one line here before: what gets to block. Only the mechanised checks, because a deterministic rule’s pass or fail isn’t a matter of opinion and a red build on it is honest, whereas a model’s judgment is a probable reading and making it a required green trains the next human to defer to it.
The deadline rule sits on the safe side of that line and always has. It’s mechanised. It’s deterministic in the sense that matters — same schema, same clock, same verdict, no model anywhere near it. Its output is a checkable fact.
And a red build on it is not honest, because the thing it asserts — someone here has an obligation they haven’t met — isn’t true of the person it stops. It’s true of the organisation, in the aggregate, on a Tuesday.
So the test I had was necessary and not sufficient. “Is this verdict checkable” is a question about the check. The second question is about the consequence: who receives it, and is the obligation theirs?
Not can they clear it. They usually can — that’s the whole trouble. The developer my rule stopped could go and delete the deprecated usage, and if they wanted their build green badly enough they would. Capability isn’t the scarce thing; it’s what makes the arrangement work as a lever rather than merely a nuisance. The build holds their change hostage until they do a piece of work that was never theirs, and they pay because paying is cheaper than arguing. What they don’t have is standing — no say in whether the deprecation was worth it, no authority to decide it can wait, nothing to appeal to. Handing a bill to someone who can pay it is not the same as handing it to someone who owes it.
That’s also not the same as asking why it fired now, and I want to be careful here because I spent a while thinking it was. My deadline rule explains its own timing perfectly well — the date passed, that’s the whole story, no mystery. Accounting for the timing doesn’t tell you whose bill it is. For nearly every check we write the two questions collapse into one, because a diff answers both at once: the change explains the timing, and the person who made it both can fix it and owes the fix. You only notice how much work the diff was doing when the two come apart.
Two axes, then, and a blocking check needs both:
- Is the verdict checkable, or is it a reading? Readings don’t block. That’s the earlier line, and it stands.
- Is the obligation the recipient’s own? If the only person it reaches is whoever happened to run the build, it doesn’t get to block — however checkable it is, and however easily they could make it go away.
The second axis narrows the first rather than replacing it. Mechanised is still the entry requirement for blocking. It just isn’t the whole of it.
What the boundary is instead
If a diff can’t scope a time-triggered rule, what can?
State, not dates. Record the set of violations you have accepted, as a file in the repo. Fail on anything not in that record. Shrink the record over time and never let it grow — a ratchet. The snapshot isn’t a date at all, which is the point; it means as of when we last accepted this, and the clock can’t move it.
That dissolves the attribution problem rather than solving it. There’s no longer a question of who caused the failure, because the check no longer fires on the passage of time. It fires when a change makes things worse than the last state you signed for, and that’s a change, and it’s yours. Trigger narrowed, recipient found.
It also isn’t exotic. ESLint ships this natively — --suppress-all writes the baseline, the suppressions file is checked in, --prune-suppressions shrinks it. It landed in 9.24, and my plugin develops against 9.36, so it was sitting there the whole time. I built four hand-rolled per-rule escape hatches, permanently, for a problem the platform solves generically one layer up.
For the new violations, and only those. A baseline is a no-new-debt device and nothing more. It stops the pile growing. It does nothing at all about the usages already inside the record — which are exactly the ones the deadline was written for. Ratchet and stop there and you’ve bought a quiet build and a dead policy, and the suppressions file becomes the place violations go to live.
So the other half isn’t a trigger at all, it’s a recipient. The accepted set needs a name against it and a date in somebody’s real queue: an owner, a scheduled review, an escalation that lands on a person who can either spend the time or decide the deprecation wasn’t worth it. That’s a different kind of consequence from a red build, and it should be. A red build is a consequence you inflict on whoever arrives next. A ticket with an owner on it is a consequence you hand to the person whose call it is.
Two halves, two jobs. Narrow the trigger so the build only stops changes that caused something. Name the recipient so the debt you’ve agreed to carry still has someone carrying it.
Concretely, the end state I want: a change that adds a use of a deprecated type after the deadline goes red, and it should — that one is yours. A use that was already in the record the day you cloned the repo does not go red for you; it sits in a file that is only ever allowed to shrink, and someone whose name is on it gets asked, on a schedule, when it’s coming out. Enforced that way, the deadline lands on the people who can make the decision. Enforced the way I built it, it lands on whoever pushed next.
I haven’t shipped either half. Three months of not shipping it is part of the receipt.
The strongest objection
A deadline announced in advance is a perfectly honest anchor. It was written into the schema, everyone could see it, nobody is surprised. Announcing a date and then enforcing it is what a deadline is. Give it an owner and a scheduled escalation and you have a fair consequence without any of this.
This is right, and it isn’t really a counter-argument — it’s the second half of the fix arriving from the other direction. A predeclared deadline with a named owner and a real escalation path is an honest consequence. What it isn’t is a red build on the pull request of someone who has never touched the deprecated type. Precommitment establishes that the obligation exists; it says nothing about who should be made to pay for it. I was treating those as one fact for three months. An owner supplies the missing recipient, a baseline narrows the trigger, and either one gets you somewhere honest. Neither licenses charging the bystander.
The tell is that my rule had nowhere to put a name. A deadline field, a call to new Date(), and a knob to move the date. There was no owner in the design, and no place one could go. That’s not a gap in the implementation. That’s the thing the design was missing.
Second objection: then don’t write rules that read the clock. But the policy is real. Deprecation deadlines exist because otherwise removal never happens, and deleting the rule deletes the policy along with it. The question was never whether the date matters. It’s what the rule is allowed to do when the date arrives.
Third: this is just the pre-existing-failure problem. Unrelated red CI shows up on your PR, you didn’t cause it, everyone hates it, nothing new here.
They do look alike from outside — both are a person blocked by a failure they didn’t cause. One operational difference separates them: whether a culprit exists. In the familiar case there is an attributable change, it just isn’t yours. Someone broke main, you’re standing where it landed, and the standard remedies all assume that — find the commit, revert it, quarantine the test, page the author. They work because there’s someone to find.
For a clock-triggered rule there’s no such commit anywhere in the history. The usage was legal when it landed and legal every day after, until a date. No revert fixes it, no author erred, and the culprit-shaped remedies have nothing to bite on — which is why the fix has to come from somewhere else, either a record of what you accepted or a name against what you haven’t. This is not someone else broke it. Nobody broke it.
What this isn’t
Not an argument against blocking. If your change introduces a violating line — including newly adding prohibited usage after a deadline has passed — that’s fully attributable and it should absolutely stop you. The easy case stays easy. This is about the case where an unchanged existing usage starts failing and nothing else moved.
Not the coverage question. I’ve written about the other half of this — every method that narrows your view excludes something, and you owe the excluded region a compensating check. That post drew its own boundary and reserved this one explicitly: “Not the question of what gets to say no … Adjacent, separate.” Correct then, correct now. Aiming an instrument at what you excluded and deciding what an instrument may punish you for are different problems.
Not a claim about reaching outside the file. I spent a while convinced that rules reading more than one file were inherently less attributable, and it isn’t true: a cross-file cycle your PR creates is fully attributable, and if lint gates your merges the repo stays clean inductively — the change that first forms the cycle never lands. Input scope isn’t the variable. Whose obligation the consequence lands on is.
How sure I am: The zero-to-four is measured and it’s mine, but it’s small — one commit, one detector, and it’s a corpus-mining result, not a production incident. Read it as a demonstration that the boundary is easy to drop, which is what I’m using it for, and not as a rate. The plugin facts are from experience plus a source check against the current branch today: the wall-clock call, the grace-period option, the error message offering to move the deadline, and three months of the fix not shipping are all verifiable, and all mine. I have not surveyed anyone consuming that library, so I’m making no claim that this has hurt anybody downstream — the harm I can testify to is that I designed an escape hatch instead of a boundary and it took me until now to notice. Both halves of the prescription are prescriptions. The baseline mechanism exists and ships in the platform, but I haven’t migrated to it, so I can’t tell you what it costs on a real repo. The owner-and-schedule half I have neither built nor watched anyone else run, and it’s the half doing the actual enforcing, so treat it as the shape of an answer rather than a tested one. The two-axis test is my inference, not a measurement. If this is wrong somewhere, it’s most likely there: I’m asserting that a consequence with a named recipient gets acted on, and the honest position is that I’ve seen the failure mode and not yet the fix.
Sources
- The targeting result — my own instrumented run on a multi-pass reviewer I built. The product promotes a detector hit to a review lead only when the evidence line is in the change’s changed lines; a corpus miner I wrote counted hits anywhere in the head file, and the first “dense” reviews returned 0 structured verdicts because everything counted was pre-existing. Miner fixed to intersect with changed lines, re-mined, one single-file commit produced 4 leads and 4 structured verdicts. Author’s own, genericised. Related: Noticing Is Cheap, Judgment Is Scarce, which is the same pipeline reporting the inverse failure — a zero caused by a scan that didn’t run.
- The lint plugin — mine, eighteen rules, published as a library. Verified against the current development branch on the day of writing: the deadline rule calls
new Date()at lint time, takes agracePeriodDaysoption defaulting to zero, and reports a message whose suggested remedies are “remove the deprecated type or update the deadline.” Three further rules carry a cross-file opt-out. From experience plus current-source verification; genericised. - ESLint bulk suppressions —
--suppress-all, a checked-in suppressions file, and--prune-suppressions. Shipped in ESLint 9.24.0 (April 2025); my plugin’s dev dependency is 9.36, though its declared peer range is>=9.0.0, which admits versions that predate the feature. Prior art for the baseline ratchet, and the reason the hand-rolled per-rule version was avoidable. Published release notes plus a check of the plugin’s own manifest. - The blocking line this post extends — Teaching the Reviewer, which argues only mechanised checks get to block and model judgment stays advisory. This post accepts that line and narrows the conditions under which a deterministic rule may block; it does not revise the earlier distinction.
- The lane this post doesn’t enter — Buy Back the Coverage, which reserved “the question of what gets to say no” as adjacent and separate.
Related: Teaching the Reviewer — what gets to block, and why judgment stays advisory; Buy Back the Coverage — the adjacent question this one leaves alone; Noticing Is Cheap, Judgment Is Scarce — the same pipeline, the opposite zero; Keep the Factory, Ship the Result — candidates, promotion, and who is allowed to do which.