From ed806a1cc844c613dbe0340a2e52403dae36af2a Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 02:52:17 +0200 Subject: [PATCH] feat: wp lit-mcp divi-check with diff and reference update Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Cli/DiviCheckCommand.php | 192 ++++++++++++++++++ src/Integrations/Divi/Release/LineDiff.php | 85 ++++++++ src/Plugin.php | 2 + tests/Unit/Cli/DiviCheckCommandTest.php | 174 ++++++++++++++++ .../Divi/Release/LineDiffTest.php | 35 ++++ 5 files changed, 488 insertions(+) create mode 100644 src/Cli/DiviCheckCommand.php create mode 100644 src/Integrations/Divi/Release/LineDiff.php create mode 100644 tests/Unit/Cli/DiviCheckCommandTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/LineDiffTest.php diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php new file mode 100644 index 0000000..43bf24f --- /dev/null +++ b/src/Cli/DiviCheckCommand.php @@ -0,0 +1,192 @@ +out = $out ?? static function (string $line): void { + \WP_CLI::line($line); + }; + } + + /** @param Closure(): CompatibilityCheck $check */ + public static function register(Closure $check): void + { + if (defined('WP_CLI') && WP_CLI) { + \WP_CLI::add_command('lit-mcp divi-check', new self($check)); + } + } + + /** + * Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format + * und Schreibtest auf einer Wegwerfseite. Gibt nichts frei. + * + * ## OPTIONS + * + * [--diff] + * : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz. + * + * [--update-reference] + * : Schreibt Soll-Fingerabdrücke und Referenzquellen neu – nur wenn alle übrigen Schritte grün sind. + * + * ## EXAMPLES + * + * wp --user=admin lit-mcp divi-check --diff + * + * @param list $args + * @param array $assoc + */ + public function __invoke(array $args, array $assoc): void + { + $code = $this->run($assoc); + if ($code !== 0) { + \WP_CLI::halt($code); + } + } + + /** @param array $assoc */ + public function run(array $assoc): int + { + if (! current_user_can('manage_options')) { + ($this->out)('Bitte mit --user= ausführen: der Schreibtest legt eine Seite an.'); + + return 1; + } + + $report = ($this->check)()->run(get_current_user_id()); + ($this->reports ?? new CheckReportStore())->save($report); + ($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion)); + foreach ($report->steps as $step) { + ($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . $step->label()); + foreach ($step->problems as $problem) { + ($this->out)(' ' . $problem); + } + } + + if (isset($assoc['diff'])) { + $this->diff(); + } + if (isset($assoc['update-reference'])) { + return $this->updateReference($report); + } + + return $report->green() ? 0 : 1; + } + + private function diff(): void + { + $themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))(); + $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))(); + $expected = DiviReference::expected($this->expectedFile); + foreach (DiviAdapters::mirrors() as $mirror) { + try { + $print = SourceFingerprint::of( + (string) $themeDir . '/' . $mirror->file, + $mirror->function, + $mirror->class + ); + } catch (RuntimeException $e) { + ($this->out)(sprintf('%s: nicht lesbar (%s)', $mirror->id, $e->getMessage())); + continue; + } + if (($expected[$mirror->id] ?? null) === $print->hash) { + continue; + } + + ($this->out)(sprintf( + '%s: %s, Zeilen %d–%d', + $mirror->id, + $mirror->file, + $print->startLine, + $print->endLine + )); + $reference = $sourceDir === null ? null : DiviReference::readSource($mirror->id, $sourceDir); + if ($reference === null) { + ($this->out)(' keine Referenzquelle (nur in einer Entwicklungskopie mit tests/)'); + continue; + } + foreach (LineDiff::lines(explode("\n", rtrim($reference, "\n")), explode("\n", $print->source)) as $line) { + ($this->out)(' ' . $line); + } + } + } + + private function updateReference(CheckReport $report): int + { + foreach ($report->steps as $step) { + if ($step->key !== CheckStep::MIRRORS && ! $step->green()) { + ($this->out)('Referenz nicht aktualisiert: nicht nur Fingerabdrücke sind rot (siehe oben).'); + + return 1; + } + } + + $themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))(); + try { + $prints = DiviReference::current((string) $themeDir); + } catch (RuntimeException $e) { + ($this->out)( + 'Referenz nicht aktualisiert – eine nachgebaute Divi-Funktion ist nicht lesbar: ' . $e->getMessage() + ); + + return 1; + } + + DiviReference::writeExpected( + array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints), + $this->expectedFile + ); + $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))(); + foreach ($sourceDir === null ? [] : $prints as $id => $print) { + DiviReference::writeSource($id, $print->source, $sourceDir); + } + delete_transient(ReleaseResolver::CACHE); + ReleaseGate::forget(); + ($this->out)(sprintf( + 'Referenz aktualisiert (%d Fingerabdrücke). Smoke-Skripte laufen lassen, dann committen.', + count($prints) + )); + + return 0; + } +} diff --git a/src/Integrations/Divi/Release/LineDiff.php b/src/Integrations/Divi/Release/LineDiff.php new file mode 100644 index 0000000..7845af1 --- /dev/null +++ b/src/Integrations/Divi/Release/LineDiff.php @@ -0,0 +1,85 @@ + $old + * @param list $new + * @return list " kept", "- removed", "+ added"; "…" between changes more than 2 × $context lines apart. + */ + public static function lines(array $old, array $new, int $context = 2): array + { + $operations = self::operations($old, $new); + $keep = []; + foreach ($operations as $i => [$op]) { + if ($op === ' ') { + continue; + } + for ($j = max(0, $i - $context); $j <= min(count($operations) - 1, $i + $context); ++$j) { + $keep[$j] = true; + } + } + + $lines = []; + $last = null; + foreach ($operations as $i => [$op, $line]) { + if (! isset($keep[$i])) { + continue; + } + if ($last !== null && $i > $last + 1) { + $lines[] = '…'; + } + $lines[] = $op . ' ' . $line; + $last = $i; + } + + return $lines; + } + + /** + * @param list $a + * @param list $b + * @return list + */ + private static function operations(array $a, array $b): array + { + $n = count($a); + $m = count($b); + $lcs = array_fill(0, $n + 1, array_fill(0, $m + 1, 0)); + for ($i = $n - 1; $i >= 0; --$i) { + for ($j = $m - 1; $j >= 0; --$j) { + $lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]); + } + } + + $operations = []; + $i = 0; + $j = 0; + while ($i < $n && $j < $m) { + if ($a[$i] === $b[$j]) { + $operations[] = [' ', $a[$i]]; + ++$i; + ++$j; + } elseif ($lcs[$i + 1][$j] >= $lcs[$i][$j + 1]) { + $operations[] = ['-', $a[$i++]]; + } else { + $operations[] = ['+', $b[$j++]]; + } + } + while ($i < $n) { + $operations[] = ['-', $a[$i++]]; + } + while ($j < $m) { + $operations[] = ['+', $b[$j++]]; + } + + return $operations; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 16468cb..be4c530 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -21,6 +21,7 @@ use LIT\MCP\Admin\ToolsTab; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; +use LIT\MCP\Cli\DiviCheckCommand; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; @@ -140,6 +141,7 @@ final class Plugin // Spec §6: the admin check runs as a REST request (same context as MCP calls). $diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer()); (new DiviCheckRoute($diviCheck))->register(); + DiviCheckCommand::register($diviCheck); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register(); diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php new file mode 100644 index 0000000..b3dc4ff --- /dev/null +++ b/tests/Unit/Cli/DiviCheckCommandTest.php @@ -0,0 +1,174 @@ + */ + private array $lines = []; + + private string $themeDir = ''; + private string $sourceDir = ''; + private string $expectedFile = ''; + + protected function setUp(): void + { + parent::setUp(); + $this->lines = []; + $base = sys_get_temp_dir() . '/lit-cli-' . uniqid(); + $this->themeDir = $base . '/Divi'; + $this->sourceDir = $base . '/reference'; + mkdir($this->themeDir . '/includes/builder', 0777, true); + mkdir($this->sourceDir, 0777, true); + $this->expectedFile = $base . '/fingerprints.json'; + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(1); + Functions\when('delete_transient')->justReturn(true); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg(dirname($this->themeDir))); + parent::tearDown(); + } + + /** @param array> $problems Step key => problems. */ + private function command(array $problems = []): DiviCheckCommand + { + $steps = array_map( + static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []), + [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] + ); + $check = Mockery::mock(CompatibilityCheck::class); + $check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps)); + $reports = Mockery::mock(CheckReportStore::class); + $reports->allows('save'); + + return new DiviCheckCommand( + static fn () => $check, + $reports, + function (string $line): void { + $this->lines[] = $line; + }, + fn (): ?string => $this->themeDir, + $this->expectedFile, + fn (): ?string => $this->sourceDir + ); + } + + public function testNeedsAnAdministrator(): void + { + Functions\when('current_user_can')->justReturn(false); + + self::assertSame(1, $this->command()->run([])); + self::assertStringContainsString('--user=', implode("\n", $this->lines)); + } + + public function testPrintsTheStepsAndSucceedsWhenGreen(): void + { + self::assertSame(0, $this->command()->run([])); + self::assertContains('✓ Schreibtest auf einer Wegwerfseite', $this->lines); + } + + public function testFailsWhenAStepIsRed(): void + { + self::assertSame(1, $this->command([CheckStep::CATALOG => ['divi/text: unbekanntes Modul']])->run([])); + self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines); + self::assertContains(' divi/text: unbekanntes Modul', $this->lines); + } + + public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void + { + $code = $this->command([CheckStep::FORMAT => ['kaputt']])->run(['update-reference' => true]); + + self::assertSame(1, $code); + self::assertFileDoesNotExist($this->expectedFile); + } + + public function testDiffShowsWhatChangedInAMirroredFunction(): void + { + file_put_contents( + $this->themeDir . '/includes/builder/shortcode-core.php', + " str_repeat('0', 64)], $this->expectedFile); + DiviReference::writeSource( + 'library-submit-layout', + "function et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 1;\n}", + $this->sourceDir + ); + + $this->command([CheckStep::MIRRORS => ['Bibliothek: Nachbau von et_pb_submit_layout() – Terme']]) + ->run(['diff' => true]); + + $output = implode("\n", $this->lines); + self::assertStringContainsString('library-submit-layout', $output); + self::assertStringContainsString("- \treturn 1;", $output); + self::assertStringContainsString("+ \treturn 2;", $output); + } + + public function testUpdateReferenceWritesNothingWhenAMirrorIsUnreadable(): void + { + file_put_contents( + $this->themeDir . '/includes/builder/shortcode-core.php', + "command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]); + + // The other mirrored Divi files are missing in the temp theme. + self::assertSame(1, $code); + self::assertStringContainsString('nicht lesbar', implode("\n", $this->lines)); + self::assertFileDoesNotExist($this->expectedFile, 'all or nothing'); + } + + public function testUpdateReferenceWritesFingerprintsAndSources(): void + { + $this->writeDivi(); + + $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]); + + self::assertSame(0, $code); + self::assertSame( + ['library-submit-layout', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags'], + array_keys(DiviReference::expected($this->expectedFile)) + ); + self::assertStringStartsWith( + 'function et_pb_submit_layout(', + (string) DiviReference::readSource('library-submit-layout', $this->sourceDir) + ); + } + + /** Minimal stand-ins for the five mirrored Divi functions at their real paths. */ + private function writeDivi(): void + { + $files = [ + 'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}", + 'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}", + 'includes/builder/frontend-builder/theme-builder/theme-builder.php' => + "function et_theme_builder_store_template( \$a, \$b, \$c ) {\n}\n" + . "function et_theme_builder_load_template_setting_options( \$ids ) {\n}", + 'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' => + "class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}", + ]; + foreach ($files as $path => $code) { + $file = $this->themeDir . '/' . $path; + if (! is_dir(dirname($file))) { + mkdir(dirname($file), 0777, true); + } + file_put_contents($file, "