On this page
"Just fix it" is the feature everyone wants from a linter, and the one most likely to go wrong.
Finding a problem is read-only. Applying the fix writes to your files, and a careless write can delete the wrong thing, change what the code does without saying so, or fight another fix and rewrite the same line over and over until a built-in limit cuts it off. Mature fixers carry the scars: ESLint documents circular fixes that undo one another, Prettier treats non-idempotence as a bug, and rustfmt has fixed the same class of failure.
None of those failures is new. Compiler and refactoring research tell us why meaning is hard to preserve. Text editors teach us what happens when ranges move or collide. Rewrite theory explains loops. Security engineering asks who gets to act with your authority. A modern fixer turns those ideas into rules it can test and explain.
Four questions separate a found problem from a fix safe enough to apply on its own:
- Does it change meaning?
- Where exactly does it cut?
- Will it ever stop?
- Who gave it permission to run?
Each question has a long history. We will take them in order, using alint, a general linter, as the running example. The alint details below are based on v0.17.0.
Safe, unsafe, and the large grey middle
Some fixes are low-risk when their scope is clear. Adding a final newline to ordinary source text is a good example. The same byte change can matter in a signed artifact, a byte-exact fixture, or an enclosing format. Trimming trailing whitespace looks cosmetic until the file is Markdown, where two trailing spaces can request a hard line break. Deleting a file or rewriting source by regex raises the cost of a targeting mistake further. A useful safety label depends on the target and on what it would cost to be wrong.
For a language-aware rewrite, observational equivalence is still the ideal. Two fragments of code are observationally equivalent when no surrounding context can tell them apart: substitute one for the other anywhere, and the program's observable behavior is unchanged. James Morris gave the idea its formal shape in 1968[1], and it is the bar two older disciplines work under. A refactoring is behavior-preserving when its preconditions hold (Opdyke, 1992)[2]. An optimizing compiler may rewrite code under the as-if rule: observable behavior must stay the same. Repository fixes are broader: they also create files, change data and permissions, and mutate Git state. The equivalence lens is useful whenever a fixer claims a source rewrite preserves behavior.
Equivalence cannot be decided in general. Rice's theorem (1953)[3] rules out a general decider for non-trivial semantic properties of programs. A real fixer has to work with narrower preconditions and a hand-picked classification. That classification can still be wrong. Ruff says Safe fixes are intended to preserve runtime behavior and asks users to report a Safe fix that breaks code. The label is a promise from the tool, not a proof.
The grey middle is where a rewrite is right about the value and still not equivalent. Ruff's safe-versus-unsafe split is this idea in a shipping tool, and its stock example lives in that gap: rewriting list(x)[0] to next(iter(x)) returns the same first element and runs faster, but on an empty input the first raises IndexError and the second raises StopIteration. The raised exception is observable, so a caller that writes except IndexError is a distinguishing context: it separates the two fragments, which means they are not equivalent, which means the rewrite is unsafe.
list(x)[0] and next(iter(x)) return the same value for a non-empty input, but a caller that catches IndexError distinguishes them when the input is empty. One counterexample makes this particular rewrite Unsafe. More generally, a Safe classification is a scoped engineering promise, not a proof of arbitrary program equivalence.alint puts fixes into four tiers. Safe is eligible for a bare alint fix. Unsafe waits for --unsafe-fixes. A Suggestion carries a proposed change, but alint will not write it automatically. ESLint makes a similar distinction between fixes and suggestions. Never is an internal state used for provenance. It is neither applied nor offered. These tiers govern what the engine may do under the configured policy. They do not certify meaning preservation for every possible file.
The same reasoning sorts the harder cases. Reindenting a file from tabs to spaces preserves behavior in many formats, until the many bites: a bare fix aimed at a Makefile recipe would turn its leading tab into spaces, and make rejects the file with a missing separator error. alint therefore defaults reindentation, deletion, and regex replacement to Unsafe. A user can override a tier for a narrowly scoped rule. The tier is a policy about target, intent, reversibility, and the cost of a mistake, not a timeless fact about an edit in isolation.
Where to cut, and what happens when edits collide
A located fix is an edit: replace the bytes from offset m to n with this text. Recovering a small sequence of edits between two strings is the classical edit-script and diff problem, developed through edit distance (Levenshtein, 1965)[4], the string-to-string correction algorithm (Wagner and Fischer, 1974)[5], and Myers's diff algorithm (1986)[6]. alint does not infer a shortest script from two files, though. A rule proposes an operation, and the engine decides whether and how that operation may run.
alint fixes more than byte ranges. Some fixes transform a whole file, with several transforms composed in memory. Others create, remove, or rename paths, change permissions, touch Git, or run a command. Those operations need different safeguards. For now, consider the range case, where one stale coordinate is enough to corrupt a file.
One range is easy. The trouble starts with more than one, because a byte offset is not a stable address: apply one edit and every offset after it shifts, so the next edit, computed against the old text, now points at the wrong place. Collaborative editors face a richer version of this hazard. Operational transformation (Ellis and Gibbs, 1989)[7] transforms positions against concurrent edits. Conflict-free replicated data types (Shapiro and colleagues, 2011)[8] can use stable identities plus deterministic conflict rules. alint uses neither system, but their central lesson applies: coordinates only make sense relative to the state that produced them.
alint takes the simpler route a one-shot fixer can afford: every located range addresses captured file bytes. It sorts proposals by (start, end, rule index, violation index), then accepts the first eligible, in-bounds edit for each compatible span. A later overlapping edit, or one in an already-used isolation group, is skipped. A tier-gated suggestion reserves no range. This produces a deterministic compatible subset, not necessarily the largest or globally best set.
The accepted ranges are then spliced from the highest offset down, so a later edit cannot move the bytes addressed by an earlier one. Verification is deliberately narrower than "prove the fix correct." Structured set_value, remove_value, and matching replacements can carry a parse-and-query postcondition. If that check fails against the composed bytes, the edit becomes a suggestion and the survivors are recomposed. Other operations carry no semantic verifier. A normalizer's transform is its specification, while paths, metadata, Git, and commands have their own checks.
Making it stop
The simplest way a fixer never stops is a fix whose own output still matches the rule that triggered it. Say a rule flags the token foo and its fix rewrites each match, but the replacement still contains foo. The first pass produces foofoo. The next produces foofoofoo. The file gains another foo on every pass and never settles. Nobody writes that fix on purpose, but it is easy to hit by accident. A rename from foo to foo_bar with a plain substring replacement loops for the same reason. That is the termination question, and a fixer cannot avoid it.
A fixer can be viewed as a rewrite system: rules replace matches with something new, possibly over several passes. Two classical properties sharpen the risks. A system terminates if no rewrite sequence runs forever. It is confluent, the Church-Rosser property (1936)[9], if diverging rewrites can always rejoin, so order does not change the final result. A terminating, confluent system has a unique normal form where no rewrite applies. A formatter often wants an even more practical property, idempotence: one pass already reaches a stable result, so a second pass does nothing.
That looping fix has no fixed point. There is nothing to converge to. Termination of an arbitrary rewrite system is undecidable, by reduction to the halting problem (Turing, 1936)[10], so a general fixer cannot promise it in advance. A restricted system can sometimes prove termination with a measure that falls on every step. Without such a proof, the engine needs a pass limit. Ruff sets MAX_ITERATIONS = 100, while ESLint sets MAX_AUTOFIX_PASSES to 10. The exact number matters less than refusing to call an unbounded loop success.
alint attacks the loop from both sides. First it catches the most obvious self-loop: the regex replace fixer drops a proposed replacement if those replacement bytes still match the rule's source pattern. That blocks foo to foo_bar, but it cannot prove that adjacent splices will not form a new match or that two different rules will not undo each other.
The backstop is bounded iteration. A real fix evaluates the current tree, applies one deterministic pass, then re-walks after any progress. If a pass applies nothing, the engine has settled under the active tier and scope and reports whatever remains. The tree may still contain Unsafe fixes, Suggestions, unfixable findings, skipped writes, or errors. In that case the command can exit 1. After ten applying passes, alint makes one non-mutating confirmation pass. If work is still available, it reports the applying rules, marks the run non-convergent, and exits 2. An idempotent fixer helps, but several fixers can still interact or undo one another.
exit 2.With the same inputs, alint resolves proposals in the same order. That still does not produce one canonical answer. The result may contain findings, and a command invoked by a rule may bring its own sources of variation.
Trusting the fix, and where it came from
The first three questions show up in every auto-fixer. The fourth is sharper in alint because its configs compose. A root file can extend a bundled ruleset, a local file, or a SHA-256-pinned URL. A monorepo can also opt into nested configs. The rule editing your files may have been written somewhere else and fetched a moment ago. Even checking is not always passive. A trusted root kind: command rule may run a diagnostic process, so process authority matters before the fix command enters the picture.
Security engineering has names for this problem. Saltzer and Schroeder's principles of least privilege and fail-safe defaults say to grant the minimum authority and deny by default[11]. Capability systems tie authority to what a component holds (Dennis and Van Horn, 1966)[12]. Hardy's confused deputy is a privileged program tricked into using its authority for someone else[13]. A linter applying a downloaded fix with your filesystem and shell faces the same risk. alint is not a formal object-capability system, but it borrows these ideas.
Two things decide how much authority a fix receives: what it can do and where it came from. Root .alint.yml and .alint.d/ drop-ins have the broadest authority. Bundled rulesets, local-path extensions, and allowlisted remote URLs may retain their content-editing tiers, but they still cannot introduce process-spawning behavior. Content-changing fixes from untrusted remote or nested sources are capped at Suggestion. Any spawning behavior from those sources is rejected while the config loads. Fixed protective operations, such as stripping an invisible Trojan-Source control character, keep their declared tier.
That provenance has to survive composition. Otherwise an untrusted rule could borrow a trusted template, or a later trusted field could complete an untrusted template. alint carries a private untrusted-source bit through rule merges and template expansion, then caps the finished fixer. A later field cannot erase the history of an earlier source. Listing a URL under trusted_extends: restores content authority. It never grants process authority.
One file is not a transaction
One boundary remains: what happens after the engine decides to write. For a content rewrite, alint creates a uniquely named temporary file beside the target. It writes the new bytes, copies the original permissions, calls fsync, and renames the temporary file over the target. If that sequence fails, the original is not left half-written. When the target is a symlink, alint updates the file it points to without replacing the link itself.
That guarantee stops at one file. There is no repository-wide transaction covering content writes, renames, permission changes, Git operations, and commands. If file A and file B succeed before file C fails, A and B remain changed. alint records the failure and returns a nonzero status when appropriate. It does not roll the earlier work back. Version control is the recovery mechanism, which is why it still matters to know your starting state and inspect the final diff.
What alint can promise
None of these safeguards proves that a repository is correct. They keep smaller failures under control. Safety tiers decide which changes need consent. Snapshot ranges keep accepted edits aimed at the bytes that produced them. A verifier can reject a bad structured edit, and the pass limit catches a configuration that keeps rewriting. Provenance keeps inherited rules within their authority.
So what does a bare alint fix promise? Every operation must clear the active safety tier, source policy, path scope, and conflict rules. A structured edit that carries a verifier has to pass it. Content transforms for one file are composed before that file is replaced. Anything unresolved, skipped, or failed stays in the report. The pass loop is bounded, and inherited config cannot smuggle in process authority.
The promise stops well short of "the repository is now correct." A successful edit can leave other findings behind, and the command can still exit 1. A later file can fail after earlier files have changed. alint fix --dry-run and alint fix --diff touch nothing, but they show only the first composed pass. They cannot predict later cascades, an I/O failure, a loop that reaches the pass limit, or the side effects of a command. A general linter also cannot know whether the result compiles or passes the repository's tests.
In practice, the workflow is ordinary:
git status --short # know the starting statealint fix --diff . # inspect the first composed passalint fix . # apply eligible fixes to a bounded fixpointalint check . # inspect what remainsgit diff --check # inspect what actually changed# run the repository's formatter, tests, and buildGit gives you a way back. Tests cover behavior a general linter cannot infer. alint's report tells you what changed and what did not. That is the practical version of "just fix it": keep the automatic part bounded, visible, and easy to review.
The fixing guide covers the tiers and per-rule overrides. For implementation details, see the alint-core engine and located-edit pipeline, including the located-edit tests and bounded overlap proof.
References
- J. H. Morris Jr. (1968). Lambda-Calculus Models of Programming Languages. PhD thesis, MIT. dspace.mit.edu
- W. F. Opdyke (1992). Refactoring Object-Oriented Frameworks. PhD thesis, University of Illinois at Urbana-Champaign. PDF
- H. G. Rice (1953). Classes of recursively enumerable sets and their decision problems. Transactions of the American Mathematical Society 74(2), 358-366. doi:10.1090/S0002-9947-1953-0053041-6
- V. I. Levenshtein (1966). Binary codes capable of correcting deletions, insertions, and reversals. Soviet Physics Doklady 10(8), 707-710 (English translation of the 1965 Russian original). mathnet.ru
- R. A. Wagner, M. J. Fischer (1974). The string-to-string correction problem. Journal of the ACM 21(1), 168-173. doi:10.1145/321796.321811
- E. W. Myers (1986). An O(ND) difference algorithm and its variations. Algorithmica 1(2), 251-266. doi:10.1007/BF01840446
- C. A. Ellis, S. J. Gibbs (1989). Concurrency control in groupware systems. Proceedings of ACM SIGMOD 1989, 399-407. doi:10.1145/67544.66963
- M. Shapiro, N. Preguica, C. Baquero, M. Zawirski (2011). Conflict-free replicated data types. Proceedings of SSS 2011, 386-400. doi:10.1007/978-3-642-24550-3_29
- A. Church, J. B. Rosser (1936). Some properties of conversion. Transactions of the American Mathematical Society 39(3), 472-482. doi:10.1090/S0002-9947-1936-1501858-0
- A. M. Turing (1936). On computable numbers, with an application to the Entscheidungsproblem. Proceedings of the London Mathematical Society s2-42(1), 230-265. doi:10.1112/plms/s2-42.1.230
- J. H. Saltzer, M. D. Schroeder (1975). The protection of information in computer systems. Proceedings of the IEEE 63(9), 1278-1308. doi:10.1109/PROC.1975.9939
- J. B. Dennis, E. C. Van Horn (1966). Programming semantics for multiprogrammed computations. Communications of the ACM 9(3), 143-155. doi:10.1145/365230.365252
- N. Hardy (1988). The confused deputy. ACM SIGOPS Operating Systems Review 22(4), 36-38. doi:10.1145/54289.871709
Comments