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..b1f56d6 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) @@ -9,40 +13,13 @@ 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 - - ✗ 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. -``` - --- ## Contents @@ -70,21 +47,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 | | --- | --- | @@ -242,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. diff --git a/art/pillars.svg b/art/pillars.svg new file mode 100644 index 0000000..0c182e6 --- /dev/null +++ b/art/pillars.svg @@ -0,0 +1,40 @@ + + 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..956acce --- /dev/null +++ b/art/social.svg @@ -0,0 +1,45 @@ + + Difflock + + + + + + + + + + + + + Difflock + Diff, analyze and protect your Laravel database schema. + + + + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('legacy_token'); + }); + + + + + + ✗ CRITICAL + DROP 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..792a55f --- /dev/null +++ b/art/terminal.svg @@ -0,0 +1,46 @@ + + php artisan difflock:lint + + + + + + + + php artisan difflock:lint + + + $ php artisan difflock:lint + + Difflock · Migration Analysis + ──────────────────────────────────────── + + ✗ CRITICAL + 14 + drop-column, drop-table + + ✗ HIGH + 125 + foreign-key, rename-column, unindexed-foreign-key + + ⚠ MEDIUM + 80 + change-column, large-table, drop-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 + + 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'); }); });