From 9627c43efa447dabd35c7cda521960c48ae10464 Mon Sep 17 00:00:00 2001 From: Rati Rukhadze Date: Tue, 11 Aug 2026 14:12:36 +0400 Subject: [PATCH] feat: add difflock:report, warn about secret-shaped defaults, and show progress on a tty --- src/Console/Commands/CheckCommand.php | 9 +- .../Concerns/InteractsWithDifflock.php | 33 ++ src/Console/Commands/DiffCommand.php | 38 +++ src/Console/Commands/LintCommand.php | 5 +- src/Console/Commands/ReportCommand.php | 145 +++++++++ src/Console/Formatters/HtmlReport.php | 301 ++++++++++++++++++ src/DifflockServiceProvider.php | 2 + src/Support/SecretHeuristics.php | 176 ++++++++++ tests/Feature/ReportAndSecretsTest.php | 178 +++++++++++ 9 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 src/Console/Commands/ReportCommand.php create mode 100644 src/Console/Formatters/HtmlReport.php create mode 100644 src/Support/SecretHeuristics.php create mode 100644 tests/Feature/ReportAndSecretsTest.php diff --git a/src/Console/Commands/CheckCommand.php b/src/Console/Commands/CheckCommand.php index 93cad8b..0d194d5 100644 --- a/src/Console/Commands/CheckCommand.php +++ b/src/Console/Commands/CheckCommand.php @@ -5,6 +5,7 @@ namespace Difflock\Console\Commands; use Difflock\Checkup; +use Difflock\CheckupResult; use Difflock\Console\Commands\Concerns\InteractsWithDifflock; use Difflock\Console\Formatters\JsonReport; use Difflock\Console\Renderers\Banner; @@ -74,7 +75,13 @@ public function handle(Checkup $checkup, Repository $config, CheckupRenderer $re $connection = $this->option('connection'); try { - $result = $checkup->run($threshold, is_string($connection) && $connection !== '' ? $connection : null); + $result = $this->whileWorking( + 'Reading the schema and analysing migrations', + fn (): CheckupResult => $checkup->run( + $threshold, + is_string($connection) && $connection !== '' ? $connection : null, + ), + ); } catch (Throwable $exception) { $this->components->error('Difflock could not complete the check: '.$exception->getMessage()); diff --git a/src/Console/Commands/Concerns/InteractsWithDifflock.php b/src/Console/Commands/Concerns/InteractsWithDifflock.php index f9e4f7d..4dd4dff 100644 --- a/src/Console/Commands/Concerns/InteractsWithDifflock.php +++ b/src/Console/Commands/Concerns/InteractsWithDifflock.php @@ -88,6 +88,39 @@ protected function writeJson(array $document): void $this->output->writeln(JsonReport::encode($document), OutputInterface::OUTPUT_RAW); } + /** + * Run something slow, saying so while it runs. + * + * Reading a large schema is a few hundred queries and several seconds of silence, + * which is long enough for somebody to wonder whether the command has hung. The + * notice is written and then erased, so it exists only while the work does. + * + * Only on a decorated terminal: a CI log has no cursor to move, a JSON document + * must not gain a line, and a test buffer should assert on the report rather than + * on the reassurance. + * + * @template TResult + * + * @param callable(): TResult $work + * @return TResult + */ + protected function whileWorking(string $message, callable $work): mixed + { + if (! $this->output->isDecorated() || $this->wantsJson()) { + return $work(); + } + + $this->output->write(' '.$message.'…'); + + try { + return $work(); + } finally { + // Back to the start of the line, blank it, and back again — so whatever + // prints next starts on a clean line rather than after the notice. + $this->output->write("\r".str_repeat(' ', mb_strlen($message) + 4)."\r"); + } + } + /** * @return list */ diff --git a/src/Console/Commands/DiffCommand.php b/src/Console/Commands/DiffCommand.php index 4655ee8..9fd7ef8 100644 --- a/src/Console/Commands/DiffCommand.php +++ b/src/Console/Commands/DiffCommand.php @@ -15,6 +15,8 @@ use Difflock\Exceptions\InvalidSnapshot; use Difflock\Exceptions\MissingBaseline; use Difflock\Schema\Baseline; +use Difflock\Schema\DatabaseSchema; +use Difflock\Support\SecretHeuristics; use Illuminate\Console\Command; use Illuminate\Contracts\Config\Repository; use Throwable; @@ -145,6 +147,8 @@ private function save(SchemaInspector $inspector, Baseline $baseline, ?string $c $this->output->writeln(' ✓ Baseline recorded: '.$tables.' table' .($tables === 1 ? '' : 's').'.'); + $this->warnAboutSecrets($schema); + foreach (Text::wrap('Written to '.$baseline->path().'. Commit it, and future runs of ' .'difflock:diff will report anything that no longer matches.', ' ') as $line) { $this->output->writeln(''.$line.''); @@ -155,6 +159,40 @@ private function save(SchemaInspector $inspector, Baseline $baseline, ?string $c return self::SUCCESS; } + /** + * Say so before a column default that looks like a credential goes into git. + * + * Printed after the file is written rather than before, and it does not refuse: + * these are shapes, not certainties, and a tool that blocked on a heuristic + * would be wrong often enough to be turned off. Deleting the file and setting + * `snapshot.defaults` is a ten-second fix — noticing a year later is not. + */ + private function warnAboutSecrets(DatabaseSchema $schema): void + { + $suspects = SecretHeuristics::suspects($schema); + + if ($suspects === []) { + return; + } + + $this->output->writeln(''); + $this->output->writeln(' ⚠ '.count($suspects).' column default' + .(count($suspects) === 1 ? '' : 's').' in this file look like they may hold a credential:'); + + foreach (SecretHeuristics::describe($suspects) as $described) { + $this->output->writeln(' · '.$described); + } + + foreach (Text::wrap( + 'Difflock recognises shapes, not secrets, so check before you commit. To keep defaults out ' + .'of the baseline entirely, set `snapshot.defaults` to false in config/difflock.php and ' + .'record it again.', + ' ', + ) as $line) { + $this->output->writeln(''.$line.''); + } + } + private function connection(string $option): ?string { $value = $this->option($option); diff --git a/src/Console/Commands/LintCommand.php b/src/Console/Commands/LintCommand.php index 1f61116..d25d5f6 100644 --- a/src/Console/Commands/LintCommand.php +++ b/src/Console/Commands/LintCommand.php @@ -100,7 +100,10 @@ public function handle(MigrationAnalyzer $analyzer, Repository $config, ReportRe $scope = $this->option('all') === true ? MigrationScope::All : MigrationScope::Pending; $paths = $this->paths(); - $report = $analyzer->analyze($scope, $paths); + $report = $this->whileWorking( + 'Analysing migrations', + fn (): MigrationReport => $analyzer->analyze($scope, $paths), + ); // Nothing pending is the ordinary state of a machine that is up to date, and // printing nothing there is how a useful tool gets mistaken for a broken one. diff --git a/src/Console/Commands/ReportCommand.php b/src/Console/Commands/ReportCommand.php new file mode 100644 index 0000000..7f01102 --- /dev/null +++ b/src/Console/Commands/ReportCommand.php @@ -0,0 +1,145 @@ +setHelp(<<<'HELP' + Runs exactly what difflock:check runs and writes it to a file instead of + the terminal, exiting with the same codes. + + php artisan difflock:report + php artisan difflock:report --output=build/difflock.html + + The HTML has no external stylesheet, font or script, so it renders correctly + as a CI artifact opened straight from disk. Everything in it — table names, + column names, rule messages — is escaped, because all of it comes from a + database or from migration source rather than from this package. + + --format=json writes the same document as difflock:check, for anything + that would rather parse than read. + HELP); + } + + public function handle(Checkup $checkup, Repository $config, Filesystem $files, HtmlReport $html): int + { + if (! $this->enabled($config)) { + return self::INVALID; + } + + $threshold = $this->threshold($config); + + if (! $threshold instanceof RiskLevel) { + return $this->unknownThreshold(); + } + + $connection = $this->option('connection'); + + try { + $result = $this->whileWorking( + 'Building the report', + fn (): CheckupResult => $checkup->run( + $threshold, + is_string($connection) && $connection !== '' ? $connection : null, + ), + ); + } catch (Throwable $exception) { + $this->components->error('Difflock could not complete the report: '.$exception->getMessage()); + + return self::INVALID; + } + + $json = $this->option('format') === 'json'; + $path = $this->path($json); + + $files->ensureDirectoryExists(dirname($path)); + $files->put($path, $json + ? JsonReport::encode(JsonReport::check($result->drift, $result->report, $threshold, $result->failed()))."\n" + : $html->render($result, $this->generatedAt(), $this->application($config))); + + Banner::render($this->output, 'Difflock · Report'); + + $this->output->writeln(' ✓ Written to '.$path); + + foreach (Text::wrap( + $result->failed() + ? 'The run failed — the report says why, and this command exits with the same code ' + .'difflock:check would have.' + : 'Nothing at or above the threshold.', + ' ', + ) as $line) { + $this->output->writeln(''.$line.''); + } + + $this->output->writeln(''); + + return $result->failed() ? self::FAILURE : self::SUCCESS; + } + + private function path(bool $json): string + { + $output = $this->option('output'); + + if (is_string($output) && $output !== '') { + return $this->absolute($output); + } + + return $this->laravel->storagePath('difflock/report.'.($json ? 'json' : 'html')); + } + + /** + * A fixed-format timestamp rather than a localised one, so two reports of the + * same run diff cleanly. + */ + private function generatedAt(): string + { + return gmdate('Y-m-d H:i').' UTC'; + } + + private function application(Repository $config): ?string + { + $name = $config->get('app.name'); + + return is_string($name) && $name !== '' ? $name : null; + } +} diff --git a/src/Console/Formatters/HtmlReport.php b/src/Console/Formatters/HtmlReport.php new file mode 100644 index 0000000..e0ce459 --- /dev/null +++ b/src/Console/Formatters/HtmlReport.php @@ -0,0 +1,301 @@ +` must render as a column called `