From 38955816bffe9f8337c68ef696978d5b3ccae15e Mon Sep 17 00:00:00 2001 From: Rati Rukhadze Date: Tue, 11 Aug 2026 15:50:43 +0400 Subject: [PATCH] feat: serve difflock to ai agents over mcp, and brief on one migration with difflock:explain --- README.md | 39 ++++ skills/difflock/SKILL.md | 78 +++++++ src/Console/Commands/ExplainCommand.php | 279 ++++++++++++++++++++++++ src/Console/Commands/McpCommand.php | 67 ++++++ src/DifflockServiceProvider.php | 4 + src/Mcp/Server.php | 227 +++++++++++++++++++ src/Mcp/Tool.php | 41 ++++ src/Mcp/Tools/LintMigration.php | 86 ++++++++ src/Mcp/Tools/SchemaDrift.php | 58 +++++ src/Mcp/Tools/TableContext.php | 116 ++++++++++ src/Version.php | 17 ++ tests/Feature/McpTest.php | 213 ++++++++++++++++++ 12 files changed, 1225 insertions(+) create mode 100644 skills/difflock/SKILL.md create mode 100644 src/Console/Commands/ExplainCommand.php create mode 100644 src/Console/Commands/McpCommand.php create mode 100644 src/Mcp/Server.php create mode 100644 src/Mcp/Tool.php create mode 100644 src/Mcp/Tools/LintMigration.php create mode 100644 src/Mcp/Tools/SchemaDrift.php create mode 100644 src/Mcp/Tools/TableContext.php create mode 100644 src/Version.php create mode 100644 tests/Feature/McpTest.php diff --git a/README.md b/README.md index 4663cd4..7afc146 100644 --- a/README.md +++ b/README.md @@ -582,6 +582,45 @@ use Difflock\Database\FixedTableStatistics; $statistics = new FixedTableStatistics(['orders' => 8_421_392]); ``` +## AI agents + +An agent writing a migration cannot see what Difflock can see. It does not know the table has eight million rows, that two indexes are built on the column it is about to drop, or that the schema drifted last Tuesday. So it writes the migration that passes review and takes production down — the same failure as always, generated faster. + +Difflock ships an MCP server that closes the loop. + +```jsonc +// .mcp.json — Claude Code, Cursor, Laravel Boost, anything speaking MCP +{ + "mcpServers": { + "difflock": { "command": "php", "args": ["artisan", "difflock:mcp"] } + } +} +``` + +Three tools, in the order a careful developer would use them: + +| Tool | Answers | +| --- | --- | +| `difflock_table_context` | What does this table look like — rows, columns, indexes, foreign keys? | +| `difflock_lint_migration` | I just wrote this migration; what is wrong with it? | +| `difflock_schema_drift` | Has this database already diverged from the baseline? | + +It is a **standalone stdio server**, not a Boost plugin. Boost publishes no documented API for third-party tool registration, and writing against an undocumented internal is how a package breaks on someone else's patch release. This works with Boost and with everything else. + +### A skill for coding agents + +`skills/difflock/SKILL.md` teaches an agent the workflow — check the table, write the migration, lint it, fix, *then* show the user — and the things it must not do, such as silencing a finding to make a check pass. Copy it into `.claude/skills/`. + +### `difflock:explain` + +```bash +php artisan difflock:explain 2026_08_11_120000_drop_legacy_token +``` + +A Markdown briefing on one migration: what it touches, the live state of every table involved, every finding, and what the analysis could not see. + +**Nothing in it is generated.** This does not ask a language model whether your migration is safe — that would be the unfalsifiable guessing this package exists to argue against. Difflock supplies the facts; you or your agent supply the judgement. No API key, no network call, no model provider in a package whose whole argument is that it only says what it can check. + ## Programmatic API ```php diff --git a/skills/difflock/SKILL.md b/skills/difflock/SKILL.md new file mode 100644 index 0000000..cbcbd05 --- /dev/null +++ b/skills/difflock/SKILL.md @@ -0,0 +1,78 @@ +--- +name: difflock +description: Use when writing, editing or reviewing a Laravel database migration — before showing it to the user. Checks the migration against the live database for destructive operations, lock risk, cascading deletes and columns that will fail on populated tables. Also use before schema work to check whether the database has already drifted. +--- + +# Writing safe Laravel migrations with Difflock + +You cannot see what the database looks like. Difflock can. A migration that is +correct in isolation — `dropColumn('legacy_token')`, `$table->string('status')` — +is a data-loss incident or a failed deploy depending on facts that exist only in +the database: how many rows the table holds, what is indexed, what points at it. + +**Never present a migration to the user without checking it first.** Getting this +wrong is not a style problem; it is how production columns get dropped. + +## The loop + +``` +1. difflock_table_context → what am I dealing with? +2. write the migration +3. difflock_lint_migration → what's wrong with it? +4. fix and repeat until nothing is above `low` +5. show the user, quoting anything that remains +``` + +If the MCP tools are unavailable, the same facts come from the CLI: + +```bash +php artisan difflock:lint --path=database/migrations/2026_08_11_x.php --realpath +php artisan difflock:explain 2026_08_11_x +``` + +## Reading a finding + +Each carries a **risk** (`safe` → `critical`) and two facts that are not opinions: + +- `destructive` — this removes data or structure. +- `reversible` — a `down()` with a body exists. **It does not mean the data comes + back.** A dropped column's `down()` recreates the column and none of its rows. + +`context` holds the facts about that specific occurrence — `82,325 rows`, +`covered by users_email_index`. That is usually the field that decides what to do. + +## Rules that most often change what you write + +| Finding | What to do instead | +| --- | --- | +| `add-not-null-column` on a table with rows | Add `->nullable()` or `->default(...)`. A NOT NULL column with no default has nothing to put in existing rows and most engines refuse the statement. | +| `drop-column` / `drop-table` | Split it: stop reading the column, deploy, drop it in a later migration. Say plainly that the data does not come back. | +| `foreign-key` with a cascade | Prefer `restrictOnDelete()` or `nullOnDelete()` unless children are worthless without the parent. Cascades run inside the database — no model events, no observers, no soft deletes. | +| `rename-column` | The zero-downtime shape is add / write both / backfill / switch reads / drop. During a rolling deploy the old release is still querying the old name. | +| `unindexed-foreign-key` | Add `$table->index('customer_id')` next to `constrained()`. PostgreSQL indexes neither side automatically; MySQL does. | +| `add-index` on a large table | Consider building it outside the deploy with the engine's concurrent form. | +| `sensitive-column` | Ask before storing it: encryption, retention, and whether it belongs in the database at all. | + +## Never do these + +- **Do not silence a finding to make the check pass.** Not `--fail-on`, not + `ignore`, not `--accept`. Those are the user's decisions, not yours. Fix the + migration or explain why the finding is acceptable and let them choose. +- **Do not run `difflock:migrate` without `--dry-run`** unless the user has asked + you to migrate. It writes to their database. +- **Do not treat an empty findings list as "safe".** Read `warnings` first — a + migration that builds table names from config, loops, or calls `DB::statement()` + is one Difflock could only partly read, and it says so there. +- **Do not report a row count of `null` as zero.** `null` means the engine would + not say. The distinction is the difference between "nothing to backfill" and "we + have no idea". + +## Saying it to the user + +Lead with what will happen, not with the rule name: + +> This drops `users.legacy_token`, which holds 82,325 rows. The data is not +> recoverable from `down()` — that recreates the column empty. Two indexes are +> built on it and go with it. + +Then the options. Difflock reports risk, not permission — the decision is theirs. diff --git a/src/Console/Commands/ExplainCommand.php b/src/Console/Commands/ExplainCommand.php new file mode 100644 index 0000000..d29e0fe --- /dev/null +++ b/src/Console/Commands/ExplainCommand.php @@ -0,0 +1,279 @@ +setHelp(<<<'HELP' + Gathers everything Difflock knows about one migration — what it changes, the + state of every table it touches, and every finding against it — and writes it + as Markdown you can paste into a chat or hand to an agent. + + Nothing here is generated. This command asks no language model whether + your migration is safe; it supplies the facts so that whoever does decide — + you, a reviewer, or an agent with your codebase in front of it — is deciding + from evidence rather than from a guess. + + Agents can reach the same facts directly over MCP: see difflock:mcp. + + Exit codes: 0 the briefing was written, 2 no such migration. + HELP); + } + + public function handle( + MigrationAnalyzer $analyzer, + DatabaseContextFactory $contexts, + Repository $config, + ): int { + if (! $this->enabled($config)) { + return self::INVALID; + } + + $wanted = $this->argument('migration'); + $wanted = is_string($wanted) ? $wanted : ''; + + $report = $analyzer->analyze(MigrationScope::All, $this->searchPaths($wanted)); + $named = $this->matching($report, $wanted); + + if ($named === null) { + $this->components->error('No migration matching "'.$wanted.'" was found.'); + + return self::INVALID; + } + + [$name, $findings] = $named; + $database = $contexts->make(); + $tables = $this->tables($report, $name); + + if ($this->wantsJson()) { + $this->writeJson([ + 'difflock' => JsonReport::VERSION, + 'migration' => $name, + 'tables' => $this->tableFacts($tables, $database), + 'findings' => array_map( + static fn (MigrationFinding $finding): array => $finding->toArray(), + $findings, + ), + 'warnings' => $report->warnings(), + ]); + + return self::SUCCESS; + } + + $this->markdown($name, $findings, $tables, $database, $report); + + return self::SUCCESS; + } + + /** + * @param list $findings + * @param list $tables + */ + private function markdown( + string $name, + array $findings, + array $tables, + DatabaseContext $database, + MigrationReport $report, + ): void { + $out = $this->output; + + $out->writeln('# Migration briefing: '.$name); + $out->writeln(''); + $out->writeln('Facts gathered by Difflock. Nothing below is generated — every line is either'); + $out->writeln('read from the database or determined from the migration source.'); + $out->writeln(''); + + $out->writeln('## Tables it touches'); + $out->writeln(''); + + if ($tables === []) { + $out->writeln('- None that could be determined from the source.'); + } + + foreach ($tables as $table) { + $live = $database->table($table); + $rows = $database->rows($table); + + if (! $live instanceof Table) { + $out->writeln('- **'.$table.'** — does not exist on the inspected database.'); + + continue; + } + + $out->writeln('- **'.$table.'** — ' + .($rows === null ? 'row count unknown' : number_format($rows).' rows') + .', '.count($live->columns).' columns, '.count($live->indexes).' indexes, ' + .count($live->foreignKeys).' foreign keys.'); + } + + $out->writeln(''); + $out->writeln('## Findings'); + $out->writeln(''); + + if ($findings === []) { + $out->writeln('None. Check the warnings below before concluding it is safe.'); + } + + foreach ($findings as $finding) { + $out->writeln('### '.$finding->risk->label().' — '.$finding->rule); + $out->writeln(''); + $out->writeln('- **What:** '.$finding->message); + + if ($finding->context !== null) { + $out->writeln('- **Context:** '.$finding->context); + } + + $out->writeln('- **Destructive:** '.($finding->destructive ? 'yes' : 'no') + .' · **Reversible:** '.($finding->reversible ? 'yes' : 'no') + .($finding->conditional ? ' · **Conditional:** may not run' : '')); + $out->writeln('- **Why it matters:** '.$finding->explanation); + + if ($finding->suggestion !== null) { + $out->writeln('- **Suggested remedy:** '.$finding->suggestion); + } + + $out->writeln(''); + } + + $warnings = $report->warnings(); + + if ($warnings !== [] || ! $database->available) { + $out->writeln('## What this analysis could not see'); + $out->writeln(''); + + if (! $database->available) { + $out->writeln('- The database could not be reached, so no row count or live-schema'); + $out->writeln(' fact above was available.'); + } + + foreach ($warnings as $warning) { + $out->writeln('- '.$warning); + } + + $out->writeln(''); + } + + $out->writeln('## Deciding'); + $out->writeln(''); + $out->writeln('Difflock reports risk, not permission. `reversible` means a `down()` exists,'); + $out->writeln('never that the data comes back. Weigh the findings against what this change is'); + $out->writeln('for, and whether the tables above are large enough for the cost to be felt.'); + } + + /** + * @return list + */ + private function searchPaths(string $wanted): array + { + // A path narrows the search to that file; a bare name searches the configured + // migration paths. + return str_ends_with($wanted, '.php') ? [$this->absolute($wanted)] : []; + } + + /** + * @return array{0: string, 1: list}|null + */ + private function matching(MigrationReport $report, string $wanted): ?array + { + $needle = str_replace('.php', '', basename($wanted)); + + foreach ($report->migrations as $migration) { + if ($migration->name === $needle || str_contains($migration->name, $needle)) { + return [$migration->name, $report->findingsFor($migration->name)]; + } + } + + return null; + } + + /** + * @return list + */ + private function tables(MigrationReport $report, string $name): array + { + foreach ($report->migrations as $migration) { + if ($migration->name === $name) { + return $migration->tables(); + } + } + + return []; + } + + /** + * @param list $tables + * @return array + */ + private function tableFacts(array $tables, DatabaseContext $database): array + { + $facts = []; + + foreach ($tables as $table) { + $live = $database->table($table); + + $facts[$table] = [ + 'exists' => $live instanceof Table, + 'rows' => $database->rows($table), + 'columns' => $live instanceof Table ? count($live->columns) : null, + 'indexes' => $live instanceof Table ? count($live->indexes) : null, + ]; + } + + return $facts; + } +} diff --git a/src/Console/Commands/McpCommand.php b/src/Console/Commands/McpCommand.php new file mode 100644 index 0000000..ce242d6 --- /dev/null +++ b/src/Console/Commands/McpCommand.php @@ -0,0 +1,67 @@ +get('difflock.enabled') === false) { + // Written to STDERR: STDOUT is the protocol. + fwrite(STDERR, "Difflock is disabled, so its tools would report nothing.\n"); + + return self::INVALID; + } + + $server = new Server([ + new TableContext($contexts), + new LintMigration($analyzer), + new SchemaDrift($checkup), + ]); + + $server->serve(STDIN, STDOUT); + + return self::SUCCESS; + } +} diff --git a/src/DifflockServiceProvider.php b/src/DifflockServiceProvider.php index f0469a3..efb1722 100644 --- a/src/DifflockServiceProvider.php +++ b/src/DifflockServiceProvider.php @@ -8,7 +8,9 @@ use Difflock\Console\Commands\DiffCommand; use Difflock\Console\Commands\DifflockCommand; use Difflock\Console\Commands\DoctorCommand; +use Difflock\Console\Commands\ExplainCommand; use Difflock\Console\Commands\LintCommand; +use Difflock\Console\Commands\McpCommand; use Difflock\Console\Commands\MigrateCommand; use Difflock\Console\Commands\ReportCommand; use Difflock\Contracts\MigrationAnalyzer; @@ -172,7 +174,9 @@ public function boot(): void CheckCommand::class, DiffCommand::class, DoctorCommand::class, + ExplainCommand::class, LintCommand::class, + McpCommand::class, MigrateCommand::class, ReportCommand::class, ]); diff --git a/src/Mcp/Server.php b/src/Mcp/Server.php new file mode 100644 index 0000000..f8320ee --- /dev/null +++ b/src/Mcp/Server.php @@ -0,0 +1,227 @@ + */ + private array $tools = []; + + /** + * @param list $tools + */ + public function __construct(array $tools = []) + { + foreach ($tools as $tool) { + $this->tools[$tool->name()] = $tool; + } + } + + /** + * Read requests until the input closes. + * + * The streams are typed `mixed` because PHP has no `resource` type to declare; + * the docblock carries what they actually are. + * + * @param resource $input + * @param resource $output + */ + public function serve(mixed $input, mixed $output): void + { + while (($line = fgets($input)) !== false) { + $line = trim($line); + + if ($line === '') { + continue; + } + + $response = $this->dispatch($line); + + // A notification has no id and takes no reply. Answering one is a protocol + // violation that some clients treat as fatal. + if ($response !== null) { + fwrite($output, json_encode($response, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n"); + fflush($output); + } + } + } + + /** + * Handle one line of input, returning the response to write, or null for a + * notification. + * + * @return array|null + */ + public function dispatch(string $line): ?array + { + try { + $message = json_decode($line, true, 32, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + return $this->error(null, -32700, 'Parse error: '.$exception->getMessage()); + } + + if (! is_array($message)) { + return $this->error(null, -32600, 'A request must be a JSON object.'); + } + + $id = $message['id'] ?? null; + $method = $message['method'] ?? null; + + if (! is_string($method)) { + return $this->error($id, -32600, 'A request must name a method.'); + } + + // Notifications carry no id and expect no answer. + if ($id === null) { + return null; + } + + $params = $message['params'] ?? []; + + return match ($method) { + 'initialize' => $this->result($id, [ + 'protocolVersion' => self::PROTOCOL, + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => ['name' => 'difflock', 'version' => Version::CURRENT], + ]), + 'ping' => $this->result($id, []), + 'tools/list' => $this->result($id, ['tools' => $this->describe()]), + 'tools/call' => $this->call($id, $this->named($params)), + default => $this->error($id, -32601, 'There is no method called '.$method.'.'), + }; + } + + /** + * @return list> + */ + private function describe(): array + { + $described = []; + + foreach ($this->tools as $tool) { + $described[] = [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'inputSchema' => $tool->schema(), + ]; + } + + return $described; + } + + /** + * @param array $params + * @return array + */ + private function call(mixed $id, array $params): array + { + $name = $params['name'] ?? null; + + if (! is_string($name) || ! isset($this->tools[$name])) { + return $this->error($id, -32602, 'There is no tool called '.(is_string($name) ? $name : '?').'.'); + } + + try { + $result = $this->tools[$name]->handle($this->named($params['arguments'] ?? [])); + } catch (Throwable $exception) { + // Reported as a tool result rather than a protocol error, because the tool + // failing is something the agent can reason about and recover from; a + // protocol error is something it can only give up on. + return $this->result($id, [ + 'isError' => true, + 'content' => [['type' => 'text', 'text' => 'Difflock could not answer: '.$exception->getMessage()]], + ]); + } + + return $this->result($id, [ + 'content' => [[ + 'type' => 'text', + 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + ]], + ]); + } + + /** + * Whatever arrived from JSON, reduced to the named arguments a tool expects. + * + * JSON arrays decode with integer keys, so a client sending `[1, 2]` where an + * object was expected would otherwise reach a tool as positional data it has no + * way to interpret. Keys that are not names are dropped at the boundary, and the + * tool contract stays honest about receiving named arguments. + * + * @return array + */ + private function named(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $named = []; + + foreach ($value as $key => $argument) { + if (is_string($key)) { + $named[$key] = $argument; + } + } + + return $named; + } + + /** + * @param array $result + * @return array + */ + private function result(mixed $id, array $result): array + { + return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]; + } + + /** + * @return array + */ + private function error(mixed $id, int $code, string $message): array + { + return ['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => $code, 'message' => $message]]; + } +} diff --git a/src/Mcp/Tool.php b/src/Mcp/Tool.php new file mode 100644 index 0000000..90ff4a3 --- /dev/null +++ b/src/Mcp/Tool.php @@ -0,0 +1,41 @@ + + */ + public function schema(): array; + + /** + * @param array $arguments + * @return array + */ + public function handle(array $arguments): array; +} diff --git a/src/Mcp/Tools/LintMigration.php b/src/Mcp/Tools/LintMigration.php new file mode 100644 index 0000000..c5fc614 --- /dev/null +++ b/src/Mcp/Tools/LintMigration.php @@ -0,0 +1,86 @@ + 'object', + 'properties' => [ + 'path' => [ + 'type' => 'string', + 'description' => 'Path to the migration file, absolute or relative to the ' + .'application root. A directory analyses every migration in it.', + ], + ], + 'required' => ['path'], + ]; + } + + public function handle(array $arguments): array + { + $path = $arguments['path'] ?? null; + + if (! is_string($path) || $path === '') { + return ['error' => 'A path is required.']; + } + + $report = $this->analyzer->analyze(MigrationScope::All, [$path]); + + if ($report->migrations === []) { + return [ + 'error' => 'No migration was found at '.$path.'.', + 'hint' => 'Give a path to a .php migration file or a directory of them.', + ]; + } + + return [ + 'analysed' => count($report->migrations), + 'risk' => $report->highestRisk()->value, + 'counts' => $report->summary()->counts, + 'findings' => array_map( + static fn (MigrationFinding $finding): array => $finding->toArray(), + $report->findings, + ), + // Never omitted. A clean result over a file the parser could only half read + // is the one case where an agent would confidently tell the user it is fine. + 'warnings' => $report->warnings(), + 'database_available' => $report->databaseAvailable, + ]; + } +} diff --git a/src/Mcp/Tools/SchemaDrift.php b/src/Mcp/Tools/SchemaDrift.php new file mode 100644 index 0000000..8ef2b7b --- /dev/null +++ b/src/Mcp/Tools/SchemaDrift.php @@ -0,0 +1,58 @@ + 'object', 'properties' => [], 'required' => []]; + } + + public function handle(array $arguments): array + { + $result = $this->checkup->run(RiskLevel::Critical); + + return [ + 'passed' => ! $result->failed(), + 'baseline_recorded' => $result->baselineRecorded, + 'baseline_error' => $result->baselineError, + 'drifted' => $result->drifted(), + 'drift' => $result->drift?->toArray(), + 'pending_migrations' => $result->report->toArray(), + ]; + } +} diff --git a/src/Mcp/Tools/TableContext.php b/src/Mcp/Tools/TableContext.php new file mode 100644 index 0000000..4839742 --- /dev/null +++ b/src/Mcp/Tools/TableContext.php @@ -0,0 +1,116 @@ +nullable()` + * is a nicety or the difference between a deploy and an outage. + */ +final readonly class TableContext implements Tool +{ + public function __construct(private DatabaseContextFactory $contexts) {} + + public function name(): string + { + return 'difflock_table_context'; + } + + public function description(): string + { + return 'Describe a database table as it exists right now: columns with types and nullability, ' + .'indexes, foreign keys, and roughly how many rows it holds. Call this before writing a ' + .'migration that touches the table. Row count is null when the engine will not say, which ' + .'means unknown and never zero. If the table does not exist, exists is false.'; + } + + public function schema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'table' => ['type' => 'string', 'description' => 'The table name.'], + ], + 'required' => ['table'], + ]; + } + + public function handle(array $arguments): array + { + $name = $arguments['table'] ?? null; + + if (! is_string($name) || $name === '') { + return ['error' => 'A table name is required.']; + } + + $database = $this->contexts->make(); + + if (! $database->available) { + return ['error' => 'The database could not be reached, so nothing can be said about '.$name.'.']; + } + + $table = $database->table($name); + + if (! $table instanceof Table) { + return [ + 'table' => $name, + 'exists' => false, + 'known_tables' => $database->schema->tableNames(), + ]; + } + + return [ + 'table' => $name, + 'exists' => true, + 'driver' => $database->driver(), + 'rows' => $database->rows($name), + 'rows_are_estimates' => $database->statistics->approximate(), + 'bytes' => $database->bytes($name), + 'is_large' => $database->thresholds->isLarge($database->rows($name)), + 'columns' => array_values(array_map( + static fn (Column $column): array => [ + 'name' => $column->name, + 'type' => $column->definition, + 'nullable' => $column->nullable, + 'default' => $column->default, + 'auto_increment' => $column->autoIncrement, + ], + $table->columns, + )), + 'indexes' => array_values(array_map( + static fn (Index $index): array => [ + 'name' => $index->name, + 'columns' => $index->columns, + 'unique' => $index->unique, + 'primary' => $index->primary, + 'reads' => $database->indexScans($name, $index->name), + ], + $table->indexes, + )), + 'foreign_keys' => array_values(array_map( + static fn (ForeignKey $key): array => [ + 'name' => $key->name, + 'columns' => $key->columns, + 'references' => $key->foreignTable.'('.implode(', ', $key->foreignColumns).')', + 'on_delete' => $key->onDelete, + ], + $table->foreignKeys, + )), + ]; + } +} diff --git a/src/Version.php b/src/Version.php new file mode 100644 index 0000000..1c04da4 --- /dev/null +++ b/src/Version.php @@ -0,0 +1,17 @@ +|null + */ +function rpc(string $method, array $params = [], mixed $id = 1): ?array +{ + return server()->dispatch(json_encode( + array_filter(['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params]), + JSON_THROW_ON_ERROR, + )); +} + +/** + * @return array + */ +function callTool(string $name, array $arguments = []): array +{ + $response = rpc('tools/call', ['name' => $name, 'arguments' => $arguments]); + + return json_decode($response['result']['content'][0]['text'], true, flags: JSON_THROW_ON_ERROR); +} + +beforeEach(function (): void { + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('legacy_token')->nullable(); + $table->index('legacy_token'); + }); + + config()->set('difflock.migrations.paths', [fixtures()]); +}); + +describe('protocol', function (): void { + it('announces itself on initialize', function (): void { + $result = rpc('initialize')['result']; + + expect($result['protocolVersion'])->toBe(Server::PROTOCOL) + ->and($result['serverInfo']['name'])->toBe('difflock') + ->and($result['capabilities'])->toHaveKey('tools'); + }); + + it('lists its tools with schemas an agent can call', function (): void { + $tools = rpc('tools/list')['result']['tools']; + + expect(array_column($tools, 'name'))->toBe([ + 'difflock_table_context', + 'difflock_lint_migration', + 'difflock_schema_drift', + ]); + + foreach ($tools as $tool) { + expect($tool['description'])->not->toBeEmpty() + ->and($tool['inputSchema']['type'])->toBe('object'); + } + }); + + /** + * A notification carries no id and must receive no reply. Answering one is a + * protocol violation that some clients treat as fatal. + */ + it('stays silent on a notification', function (): void { + expect(server()->dispatch('{"jsonrpc":"2.0","method":"notifications/initialized"}'))->toBeNull(); + }); + + it('reports malformed input as a protocol error rather than crashing', function (): void { + expect(server()->dispatch('not json')['error']['code'])->toBe(-32700) + ->and(server()->dispatch('"a string"')['error']['code'])->toBe(-32600) + ->and(rpc('nonsense/method')['error']['code'])->toBe(-32601); + }); + + it('reports an unknown tool without pretending to answer', function (): void { + expect(rpc('tools/call', ['name' => 'difflock_invented'])['error']['code'])->toBe(-32602); + }); + + /** + * STDOUT carries the protocol and nothing else. A tool that throws must come back + * as a tool result the agent can reason about, not as a broken stream. + */ + it('turns a failing tool into a result, not a dead connection', function (): void { + $result = rpc('tools/call', [ + 'name' => 'difflock_lint_migration', + 'arguments' => ['path' => 12345], + ])['result']; + + expect($result['content'][0]['text'])->toContain('A path is required'); + }); +}); + +describe('difflock_table_context', function (): void { + it('describes a real table', function (): void { + $context = callTool('difflock_table_context', ['table' => 'users']); + + expect($context['exists'])->toBeTrue() + ->and($context['driver'])->toBe('sqlite') + ->and($context['rows'])->toBe(0) + ->and(array_column($context['columns'], 'name'))->toContain('legacy_token') + ->and($context['indexes'])->not->toBeEmpty() + ->and($context['is_large'])->toBeFalse(); + }); + + it('says a table does not exist rather than returning an empty one', function (): void { + $context = callTool('difflock_table_context', ['table' => 'nope']); + + expect($context['exists'])->toBeFalse() + ->and($context['known_tables'])->toContain('users'); + }); +}); + +describe('difflock_lint_migration', function (): void { + it('finds the destructive operation in a single migration file', function (): void { + $result = callTool('difflock_lint_migration', [ + 'path' => fixtures().'/2026_08_10_120000_remove_legacy_token.php', + ]); + + expect($result['risk'])->toBe('critical') + ->and($result['analysed'])->toBe(1) + ->and($result['findings'][0]['rule'])->toBe('drop-column') + ->and($result['findings'][0]['destructive'])->toBeTrue() + ->and($result['findings'][0]['reversible'])->toBeFalse() + ->and($result)->toHaveKey('warnings'); + }); + + it('analyses a whole directory too', function (): void { + expect(callTool('difflock_lint_migration', ['path' => fixtures()])['analysed'])->toBe(3); + }); + + it('says so when there is no migration at the path', function (): void { + expect(callTool('difflock_lint_migration', ['path' => fixtures('nowhere')])) + ->toHaveKey('error'); + }); +}); + +describe('difflock_schema_drift', function (): void { + it('distinguishes no drift from nobody having looked', function (): void { + $before = callTool('difflock_schema_drift'); + + expect($before['baseline_recorded'])->toBeFalse() + ->and($before['drifted'])->toBeFalse() + ->and($before['drift'])->toBeNull(); + + runCommand('difflock:diff', ['--save' => true]); + + expect(callTool('difflock_schema_drift')['baseline_recorded'])->toBeTrue(); + }); + + it('reports real drift', function (): void { + runCommand('difflock:diff', ['--save' => true]); + + Schema::table('users', fn (Blueprint $table) => $table->string('phone')->nullable()); + + $drift = callTool('difflock_schema_drift'); + + expect($drift['drifted'])->toBeTrue() + ->and($drift['drift']['changes'])->toBe(1) + ->and($drift['passed'])->toBeFalse(); + }); +}); + +describe('difflock:explain', function (): void { + it('briefs on one migration with facts and no generated prose', function (): void { + [$exit, $output] = runCommand('difflock:explain', ['migration' => 'remove_legacy_token']); + + expect($exit)->toBe(0) + ->and($output)->toContain('# Migration briefing: 2026_08_10_120000_remove_legacy_token') + ->toContain('Nothing below is generated') + ->toContain('## Tables it touches') + ->toContain('**users**') + ->toContain('CRITICAL — drop-column') + ->toContain('**Destructive:** yes') + ->toContain('**Reversible:** no') + ->toContain('Difflock reports risk, not permission'); + }); + + it('emits the same facts as a document', function (): void { + [$exit, $output] = runCommand('difflock:explain', [ + 'migration' => 'remove_legacy_token', + '--format' => 'json', + ]); + + $document = json_decode(trim($output), true, flags: JSON_THROW_ON_ERROR); + + expect($exit)->toBe(0) + ->and($document['migration'])->toBe('2026_08_10_120000_remove_legacy_token') + ->and($document['tables']['users']['exists'])->toBeTrue() + ->and($document['findings'][0]['rule'])->toBe('drop-column'); + }); + + it('fails rather than guessing when no migration matches', function (): void { + expect(runCommand('difflock:explain', ['migration' => 'no_such_thing'])[0])->toBe(2); + }); +});