From a37ce18fa1c43a7fd1d260a435ef205a1ec5838d Mon Sep 17 00:00:00 2001 From: Rati Rukhadze Date: Tue, 11 Aug 2026 17:35:40 +0400 Subject: [PATCH 1/3] feat: expand blocking findings in the guard, stop claiming migrations are missing, and add readme artwork --- .gitattributes | 2 + README.md | 29 +------- art/pillars.svg | 48 ++++++++++++++ art/social.svg | 50 ++++++++++++++ art/terminal.svg | 50 ++++++++++++++ src/Console/Commands/MigrateCommand.php | 40 ++++++++++- src/Console/Renderers/CheckupRenderer.php | 11 ++- tests/Feature/GuardDetailTest.php | 81 +++++++++++++++++++++++ tests/Feature/ProtectionTest.php | 4 +- 9 files changed, 284 insertions(+), 31 deletions(-) create mode 100644 art/pillars.svg create mode 100644 art/social.svg create mode 100644 art/terminal.svg create mode 100644 tests/Feature/GuardDetailTest.php diff --git a/.gitattributes b/.gitattributes index ef59482..85d5228 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,8 @@ * text=auto eol=lf /.github export-ignore +/art export-ignore +/docs export-ignore /tests export-ignore /.editorconfig export-ignore /.gitattributes export-ignore diff --git a/README.md b/README.md index 8302928..955c71d 100644 --- a/README.md +++ b/README.md @@ -9,39 +9,16 @@ Difflock reads your migrations and your database, tells you what is about to change, how badly it could go, and stops the changes that should not run unattended. -``` -Diff what changed. Analyze the risk. Lock dangerous changes. -``` +Difflock's three jobs: diff what changed, analyze the risk, lock dangerous changes ```bash composer require heyosseus/difflock --dev php artisan difflock:lint ``` -```text - Difflock · Migration Analysis - ──────────────────────────────────────── - - 2026_08_10_120000_remove_legacy_token +difflock:lint output — 14 critical and 125 high findings across 170 migrations, summarised in twenty lines - ✗ CRITICAL DROP COLUMN users.legacy_token - drop-column:14 · destructive, not reversible - Dropping a column destroys the values in it. A `down()` that adds the - column back gives you the column and not one row of what was in it. - The table holds 4,921,000 rows. - → Stop reading and writing the column in application code first, - deploy that, and drop it in a later migration once you are sure - nothing needs it. - - Risk - ✓ Safe: 0 - ⚠ Low: 1 - ⚠ Medium: 0 - ✗ High: 0 - ✗ Critical: 1 - - 3 migrations analysed. -``` +*Real output from a 170-migration production application. 251 findings, one screen — `-v` for all of them, `--rule=` for one at a time.* --- diff --git a/art/pillars.svg b/art/pillars.svg new file mode 100644 index 0000000..f81085f --- /dev/null +++ b/art/pillars.svg @@ -0,0 +1,48 @@ + + Diff · Analyze · Lock + + + + + + + + + + DIFF + What changed? + Compare the live schema against + a committed baseline, or another + connection. + difflock:diff + + + + + + ANALYZE + Is it risky? + Eleven rules, weighed against + real row counts and the indexes + that actually exist. + difflock:lint + + + + + + LOCK + Should it run? + Refuse to migrate when + something destructive is + about to run unattended. + difflock:migrate + + diff --git a/art/social.svg b/art/social.svg new file mode 100644 index 0000000..625d26f --- /dev/null +++ b/art/social.svg @@ -0,0 +1,50 @@ + + Difflock + + + + + + + + + + + + + + + + + + + + Difflock + Diff, analyze and protect your Laravel database schema. + + + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('legacy_token'); + }); + + + + + ✗ CRITICALDROP COLUMN users.legacy_token· 82,325 rows · not reversible + + + + The migration that passes code review + and drops a production column. + + composer require heyosseus/difflock --dev + MIT · PHP 8.3+ · Laravel 12–13 + diff --git a/art/terminal.svg b/art/terminal.svg new file mode 100644 index 0000000..4b39998 --- /dev/null +++ b/art/terminal.svg @@ -0,0 +1,50 @@ + + php artisan difflock:lint + + + + + + + + + + + php artisan difflock:lint + + + $ php artisan difflock:lint + + Difflock · Migration Analysis + ──────────────────────────────────────── + + ✗ CRITICAL 14drop-column, drop-table + ✗ HIGH125foreign-key, add-index, rename-column, unindexed-foreign-key + ⚠ MEDIUM 80change-column, large-table, drop-index + ⚠ LOW 32redundant-index, add-index + + Worst + DROP COLUMN activity_log.user_id + 2026_01_30_112417_change_user_id…:15 · destructive, not reversible + DROP COLUMN inventory_stocks.available_qty + 2026_04_20_120000_refactor_inventory…:43 · destructive, not reversible + + 170 migrations analysed · 251 findings. + + → difflock:lint -v every finding in full + → difflock:lint --rule=NAME one rule at a time + → difflock:report a shareable HTML report + + diff --git a/src/Console/Commands/MigrateCommand.php b/src/Console/Commands/MigrateCommand.php index 01166ae..3870785 100644 --- a/src/Console/Commands/MigrateCommand.php +++ b/src/Console/Commands/MigrateCommand.php @@ -96,7 +96,7 @@ public function handle(MigrationGuard $guard, Repository $config, ReportRenderer Banner::render($this->output, 'Difflock · Migration Guard'); - $renderer->render($this->output, $decision->report); + $this->report($renderer, $decision); if ($this->option('dry-run') === true) { $this->note('Dry run. Nothing was written to the database.'); @@ -117,6 +117,44 @@ public function handle(MigrationGuard $guard, Repository $config, ReportRenderer return $this->migrate($decision); } + /** + * Show what the guard found, in the detail the situation deserves. + * + * Everywhere else in Difflock a summary is right, because the reader is browsing. + * Here they are not: the guard has stopped them writing to a database, and the + * findings that caused it are the whole reason to read the output. So a blocked + * run prints every blocking finding in full and collapses only the rest. + * + * When nothing is in scope the renderer is skipped entirely — it would say "no + * migrations were found to analyse" and suggest fixing `--path`, which on an + * application whose migrations have all simply been applied is untrue. + */ + private function report(ReportRenderer $renderer, GuardDecision $decision): void + { + if ($decision->report->migrations === []) { + $this->note('No migrations are pending, so there was nothing to analyse.'); + + return; + } + + if (! $decision->blocked) { + $renderer->render($this->output, $decision->report, 'difflock:migrate'); + + return; + } + + $blocking = $decision->report->only(atLeast: $decision->threshold); + + $renderer->detail($this->output, $blocking); + + $remaining = $decision->report->summary()->total - $blocking->summary()->total; + + if ($remaining > 0) { + $this->note($remaining.' further finding'.($remaining === 1 ? '' : 's').' below ' + .$decision->threshold->label().' are not shown — php artisan difflock:lint'); + } + } + /** * Hand over to Laravel's own migrator. * diff --git a/src/Console/Renderers/CheckupRenderer.php b/src/Console/Renderers/CheckupRenderer.php index f79fd45..30f1926 100644 --- a/src/Console/Renderers/CheckupRenderer.php +++ b/src/Console/Renderers/CheckupRenderer.php @@ -37,8 +37,13 @@ public function render(OutputInterface $output, CheckupResult $result, bool $ter $this->diffs->render($output, $result->drift); } - if (! $terse || $result->report->fails($result->threshold)) { - $this->reports->render($output, $result->report); + // Nothing in scope is already stated by the migration line above. Letting the + // report renderer speak here would have it say "no migrations were found to + // analyse" and suggest fixing `--path` — which on an application whose 170 + // migrations have simply all been applied is false, and sends the reader + // debugging something that is not wrong. + if ($result->report->migrations !== [] && (! $terse || $result->report->fails($result->threshold))) { + $this->reports->render($output, $result->report, 'difflock:check'); } $output->writeln($result->failed() @@ -75,7 +80,7 @@ public function overview(OutputInterface $output, CheckupResult $result): void if ($summary->total > 0) { $worst = $summary->highest; - $this->reports->render($output, $result->report->only(atLeast: $worst)); + $this->reports->render($output, $result->report->only(atLeast: $worst), 'difflock:lint'); $remaining = $summary->total - $summary->count($worst); diff --git a/tests/Feature/GuardDetailTest.php b/tests/Feature/GuardDetailTest.php new file mode 100644 index 0000000..c5a26ae --- /dev/null +++ b/tests/Feature/GuardDetailTest.php @@ -0,0 +1,81 @@ +id(); + $table->string('legacy_token')->nullable(); + }); + + Schema::create('orders', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('customer_id'); + }); + + config()->set('difflock.migrations.paths', [fixtures()]); +}); + +/** + * Everywhere else a summary is right, because the reader is browsing. Here they are + * not: the guard has just stopped them writing to a database, and the findings that + * caused it are the entire reason to read the output. + */ +it('prints every blocking finding in full when it blocks', function (): void { + [$exit, $output] = runCommand('difflock:migrate'); + + expect($exit)->toBe(1) + ->and($output)->toContain('Migration blocked') + ->toContain('DROP COLUMN users.legacy_token') + // The explanation, not just the headline — this is the detail view. + ->toContain('Dropping a column destroys the values in it') + ->and(Schema::hasColumn('users', 'legacy_token'))->toBeTrue(); +}); + +it('collapses the findings that are not blocking', function (): void { + [, $output] = runCommand('difflock:migrate'); + + expect($output)->toContain('below CRITICAL are not shown') + ->toContain('difflock:lint'); +}); + +it('summarises rather than expanding when nothing blocks', function (): void { + [$exit, $output] = runCommand('difflock:migrate', [ + '--dry-run' => true, + '--path' => [fixtures('safe')], + '--realpath' => true, + ]); + + expect($exit)->toBe(0) + ->and($output)->toContain('Dry run') + ->and($output)->not->toContain('Migration blocked'); +}); + +/** + * An application whose migrations have all been applied has an empty pending scope. + * Saying "no migrations were found to analyse" there is false, and the advice that + * followed it — check your `--path` — sends the reader debugging nothing. + */ +it('says nothing is pending rather than claiming it found no migrations', function (): void { + runCommand('migrate', ['--path' => [fixtures()], '--realpath' => true, '--force' => true]); + + [, $output] = runCommand('difflock:migrate', ['--dry-run' => true]); + + expect($output)->toContain('No migrations are pending') + ->and($output)->not->toContain('No migrations were found to analyse') + ->and($output)->not->toContain('--path'); +}); + +it('does not tell difflock:check to fix its --path either', function (): void { + runCommand('migrate', ['--path' => [fixtures()], '--realpath' => true, '--force' => true]); + runCommand('difflock:diff', ['--save' => true]); + + [$exit, $output] = runCommand('difflock:check'); + + expect($exit)->toBe(0) + ->and($output)->toContain('No pending migrations') + ->and($output)->not->toContain('No migrations were found to analyse'); +}); diff --git a/tests/Feature/ProtectionTest.php b/tests/Feature/ProtectionTest.php index 72e25ce..e70b8ff 100644 --- a/tests/Feature/ProtectionTest.php +++ b/tests/Feature/ProtectionTest.php @@ -184,6 +184,8 @@ [$exit, $output] = runCommand('difflock:migrate', ['--path' => ['/definitely/not/a/directory'], '--realpath' => true]); expect($exit)->toBe(0) - ->and($output)->toContain('No migrations were found to analyse'); + // The guard says nothing is pending — not that it could not find any + // migration files, which would send the reader debugging their --path. + ->and($output)->toContain('No migrations are pending'); }); }); From 3a7d58573d775abdad6462867474c70f6227817a Mon Sep 17 00:00:00 2001 From: Rati Rukhadze Date: Tue, 11 Aug 2026 17:43:05 +0400 Subject: [PATCH 2/3] docs: put the artwork in the readme and drop the ascii diagram it replaces --- README.md | 20 ++++--------- art/pillars.svg | 74 +++++++++++++++++++++--------------------------- art/social.svg | 57 +++++++++++++++++-------------------- art/terminal.svg | 72 ++++++++++++++++++++++------------------------ 4 files changed, 98 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 955c71d..f89dc38 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Difflock — diff, analyze and protect your Laravel database schema +

+ # Difflock [![Latest Version](https://img.shields.io/packagist/v/heyosseus/difflock.svg)](https://packagist.org/packages/heyosseus/difflock) @@ -47,21 +51,7 @@ php artisan difflock:lint ## Overview -Difflock has three jobs, and it keeps them separate. - -```text - DIFFLOCK - │ - ┌────────────┼────────────┐ - │ │ │ - DIFF ANALYZE LOCK - │ │ │ - What changed? Is it risky? Should it run? - │ │ │ - └────────────┼────────────┘ - │ - DATABASE -``` +Difflock has three jobs, and it keeps them separate. The diff engine does not know how findings are rendered; the rules do not know Artisan exists; the guard consumes analysis rather than repeating it. [Architecture tests](tests/ArchTest.php) enforce each of those boundaries. | Command | Answers | | --- | --- | diff --git a/art/pillars.svg b/art/pillars.svg index f81085f..0c182e6 100644 --- a/art/pillars.svg +++ b/art/pillars.svg @@ -1,48 +1,40 @@ - + Diff · Analyze · Lock - - - - + - - - - DIFF - What changed? - Compare the live schema against - a committed baseline, or another - connection. - difflock:diff - + + + + + DIFF + What changed? + Compare the live schema against + a committed baseline, or another + connection. + difflock:diff + - - - - ANALYZE - Is it risky? - Eleven rules, weighed against - real row counts and the indexes - that actually exist. - difflock:lint - + + + + ANALYZE + Is it risky? + Eleven rules, weighed against + real row counts and the indexes + that actually exist. + difflock:lint + - - - - LOCK - Should it run? - Refuse to migrate when - something destructive is - about to run unattended. - difflock:migrate + + + + LOCK + Should it run? + Refuse to migrate when + something destructive is + about to run unattended. + difflock:migrate + diff --git a/art/social.svg b/art/social.svg index 625d26f..956acce 100644 --- a/art/social.svg +++ b/art/social.svg @@ -1,50 +1,45 @@ Difflock - - - - - - + - - - + + - - Difflock - Diff, analyze and protect your Laravel database schema. + Difflock + Diff, analyze and protect your Laravel database schema. - - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('legacy_token'); - }); + + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('legacy_token'); + }); + - - ✗ CRITICALDROP COLUMN users.legacy_token· 82,325 rows · not reversible - + + ✗ CRITICAL + DROP COLUMN users.legacy_token + · 82,325 rows · not reversible + - - The migration that passes code review - and drops a production column. + + The migration that passes code review + and drops a production column. + - composer require heyosseus/difflock --dev - MIT · PHP 8.3+ · Laravel 12–13 + + composer require heyosseus/difflock --dev + MIT · PHP 8.3+ · Laravel 12–13 + diff --git a/art/terminal.svg b/art/terminal.svg index 4b39998..792a55f 100644 --- a/art/terminal.svg +++ b/art/terminal.svg @@ -1,50 +1,46 @@ - + php artisan difflock:lint - - - - - - - + + + + - php artisan difflock:lint + php artisan difflock:lint + + + $ php artisan difflock:lint + + Difflock · Migration Analysis + ──────────────────────────────────────── + + ✗ CRITICAL + 14 + drop-column, drop-table - - $ php artisan difflock:lint + ✗ HIGH + 125 + foreign-key, rename-column, unindexed-foreign-key - Difflock · Migration Analysis - ──────────────────────────────────────── + ⚠ MEDIUM + 80 + change-column, large-table, drop-index - ✗ CRITICAL 14drop-column, drop-table - ✗ HIGH125foreign-key, add-index, rename-column, unindexed-foreign-key - ⚠ MEDIUM 80change-column, large-table, drop-index - ⚠ LOW 32redundant-index, add-index + ⚠ LOW + 32 + redundant-index, add-index - Worst - DROP COLUMN activity_log.user_id - 2026_01_30_112417_change_user_id…:15 · destructive, not reversible - DROP COLUMN inventory_stocks.available_qty - 2026_04_20_120000_refactor_inventory…:43 · destructive, not reversible + Worst + DROP COLUMN activity_log.user_id + 2026_01_30_112417_change_user_id…:15 · destructive, not reversible + DROP COLUMN inventory_stocks.available_qty + 2026_04_20_120000_refactor_inventory…:43 · destructive, not reversible - 170 migrations analysed · 251 findings. + 170 migrations analysed · 251 findings. - → difflock:lint -v every finding in full - → difflock:lint --rule=NAME one rule at a time - → difflock:report a shareable HTML report + → difflock:lint -v every finding in full + → difflock:lint --rule=NAME one rule at a time + → difflock:report a shareable HTML report From baa9d212071496f98f73a9dac94fedd7536f54ef Mon Sep 17 00:00:00 2001 From: Rati Rukhadze Date: Tue, 11 Aug 2026 17:44:36 +0400 Subject: [PATCH 3/3] docs: move the terminal image down to the migration linting section --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f89dc38..b1f56d6 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,6 @@ composer require heyosseus/difflock --dev php artisan difflock:lint ``` -difflock:lint output — 14 critical and 125 high findings across 170 migrations, summarised in twenty lines - -*Real output from a 170-migration production application. 251 findings, one screen — `-v` for all of them, `--rule=` for one at a time.* - --- ## Contents @@ -209,6 +205,10 @@ php artisan difflock:lint --accept # record what it found as accepted php artisan difflock:lint --path=database/migrations/legacy ``` +difflock:lint output — 14 critical and 125 high findings across 170 migrations, summarised on one screen + +*Real output from a 170-migration production application: 251 findings on one screen. The summary's length does not grow with the number of findings — `-v` expands every one, `--rule=` takes them a rule at a time.* + Only pending migrations are analysed by default. A migration that has already run cannot be made safer by a finding, and a build that fails over a drop committed two years ago is a build nobody keeps green. When nothing is pending — which is the normal state of a machine that is up to date — it audits every migration instead of printing nothing, and says that is what it did.