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 none248
Rule catalogf42be5e0950f9ebe
ServerMySQL 8.4.10
Corpus version2
Measured on2026-09-30

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: 248 of the 261 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.
  • 248 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 248 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

export DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 \
DB_DATABASE=sqlens_corpus DB_USERNAME=root

vendor/bin/testbench migrate:install # once per database
vendor/bin/testbench sqlens:corpus-measure

It needs a MySQL 8.4 server and an empty database on it with Laravel's migration table, which migrate:install creates. The run reads that table to decide which of the collection's migrations are pending, so the table has to exist and has to be empty: on a database without it, or one that lists a corpus migration as already run, the command refuses and names the reason instead of writing a report over migrations it never judged. Apart from that table nothing is written there, because the capture is a pretend one, but the run does connect and ask the server for its version. Set DB_PASSWORD as well if the account has one. 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.