False positives, measured
A linter that cries wolf gets switched off, and the switch is never turned back on. So the question is not whether SQLens finds things — it is how often it complains about a migration that was fine. This page carries that number and, beside it, everything the number does not cover.
A false positive here is narrow and mechanical: a rule reports fail on a collection whose
ground truth says that rule should stay quiet. Nothing on this page is an opinion about whether
a finding was useful; the corpus records what each rule should say about each collection, and
the measurement compares.
The current measurement
| False positives | 0 |
| False negatives | 0 |
| Correct verdicts | 12 |
| Undetermined, expected | 1 |
| Undetermined, unexpected | 0 |
| Cases measured | 13 |
| Cases not measured | 0 |
| Collections | 1 |
| Rules with a corpus case | 13 |
| Rules with none | 228 |
| Rule catalog | d1d97e47ee5da809 |
| Server | MySQL 8.4.10 |
| Corpus version | 1 |
| Measured on | 2026-09-22 |
Thirteen cases is a small denominator, and the honest reading of a rate over it is "no rule has fired on this history yet", not "false positives do not happen". The two rows about rules are the ones to read twice: 228 of the 241 rules this build ships have no corpus case at all, so the number above is silent about them.
What the corpus is
One collection today: 14 migrations from BookStack,
copied unchanged, MIT-licensed, with the upstream license file beside them. They are a real
history — a decade of schema changes written by people solving their own problems, including the
awkward ones a synthetic example never produces: a migration whose up() is empty because the
work moved into a console command, three index drops behind Schema::hasIndex(), a
polymorphic-type rename that rewrites 36 rows.
Every case in the collection is annotated by hand, and every annotation carries its reasoning. The measurement refuses a collection whose manifest does not name the engine it targets, and it refuses to run over an empty corpus rather than reporting a flawless-looking rate over nothing.
How a case is classified
Each annotated rule produces exactly one case per collection. Several findings from one rule are reduced to the most serious outcome it reached, so a rule that failed on one migration and passed on another has failed for that collection — otherwise the answer would depend on file order.
| The ground truth says | The rule said | The case is |
|---|---|---|
expected_fail | fail | a correct verdict |
expected_fail | anything else | a false negative |
expected_pass | fail | a false positive |
expected_pass | pass, or nothing | a correct verdict |
expected_undetermined | undetermined | a correct verdict |
| anything | undetermined where none was expected | not measured |
The last row is why the table above carries two undetermined columns. A run that could not reach a verdict has not passed and has not failed: counting it either way would move the rate by hiding a case nobody measured. See Understanding undetermined.
By engine
| Engine | Cases | False positives |
|---|---|---|
| MySQL | 13 | 0 |
| PostgreSQL | 0 | — |
One row per engine the corpus reaches, and PostgreSQL is in the table rather than left out on purpose: a missing row reads as an engine nobody thought about, and an em dash where a rate would stand says there is nothing to divide. Its collection is the next thing this corpus needs.
By level
The rate is taken with every rule admitted — level 9, every category — because the claim is about the whole pack rather than about one project's appetite. A case belongs to the level its rule declares, whether or not the rule spoke.
| Level | Cases | False positives |
|---|---|---|
| 0 · capture | 2 | 0 |
| 1 · destructive | 2 | 0 |
| 2 · blocking DDL | 1 | 0 |
| 3 · lock hygiene | 4 | 0 |
| 4 · backward compatibility | 2 | 0 |
| 7 · performance heuristics | 1 | 0 |
| 8 · conventions | 1 | 0 |
Levels 5, 6 and 9 have no case on this collection. What the levels mean is on The strictness levels.
The blind spots, named
- PostgreSQL has no collection yet. Its rate is unknown, and unknown is not zero. Every number on this page is a statement about the MySQL pack on MySQL 8.4.
- 228 rules have never met this corpus. A rule with no case is neither right nor wrong here; it is unmeasured, and the report lists all 228 by id rather than counting them.
- The capture is a pretend run against an empty database. A rule that needs table statistics, an existing index or row counts cannot reach a verdict, and its silence is not evidence. That is also why the collection annotates only the rules it genuinely presents.
- One engine version. The same migrations classify differently on MySQL 8.0 and 8.4 — a rule that speaks about an operation one release made instant has a different verdict on each — so the report records the version it met, and so does the table above.
- A rule that changes its predicate under the same id is invisible to the staleness check. The catalog fingerprint moves when a rule id appears or disappears; a rewritten rule keeping its id does not move it. Hashing more would fire on changes that cannot affect the rate, until nobody believed the check.
Reproduce it
The measurement is a development command, so it is not part of the sqlens:* surface a consumer
gets. It runs from a clone of the repository:
git clone https://github.com/pushery/sqlens-for-laravel
cd sqlens-for-laravel && composer install
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 \
DB_DATABASE=sqlens_corpus DB_USERNAME=root DB_PASSWORD= \
vendor/bin/testbench sqlens:corpus-measure
It needs a MySQL 8.4 server and an empty database on it; the run never writes there, because
the capture is a pretend one, but it does connect and ask the server for its version. Point
DB_PORT at the server you mean — a report taken against 8.0 is a different measurement, and
the server_version field in the report is what says which one you got. The run takes a few
seconds.
The report lands in tests/Fixtures/Corpus/corpus-report.json and is committed. Two runs over
one state produce a byte-identical file: the timestamps this page carries are in the prose, never
in the artifact, so a diff on it means the measurement moved.
Every figure in the tables above is checked against that file by the package's own test suite. A number edited here and nowhere else turns the suite red — which is the only way a published rate survives the next rule that lands.