Skip to main content

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 positives0
False negatives0
Correct verdicts12
Undetermined, expected1
Undetermined, unexpected0
Cases measured13
Cases not measured0
Collections1
Rules with a corpus case13
Rules with none228
Rule catalogd1d97e47ee5da809
ServerMySQL 8.4.10
Corpus version1
Measured on2026-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 saysThe rule saidThe case is
expected_failfaila correct verdict
expected_failanything elsea false negative
expected_passfaila false positive
expected_passpass, or nothinga correct verdict
expected_undeterminedundetermineda correct verdict
anythingundetermined where none was expectednot 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

EngineCasesFalse positives
MySQL130
PostgreSQL0

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.

LevelCasesFalse positives
0 · capture20
1 · destructive20
2 · blocking DDL10
3 · lock hygiene40
4 · backward compatibility20
7 · performance heuristics10
8 · conventions10

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.