feat: Änderungs-Pipeline des Designsystems (Probelauf, design_rev, Snapshot, Rollback) und Bereich presets

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:39:58 +02:00
co-authored by Claude Sonnet 5.5
parent bfd3840eb1
commit bb4d9b3baf
6 changed files with 698 additions and 0 deletions
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* One area of the design system for DesignChange (spec §2, §4): colors, variables, presets or theme options – its
* raw inventory as Divi stores it (also the snapshot), its design_rev, how it is written through Divi and how a
* read-back is judged. write() throws on failure; it is also how a snapshot is written back ($mayRemove = true).
*/
interface DesignArea
{
/** One of SnapshotStore::AREAS. */
public function name(): string;
/** German name for messages, e.g. "Presets". */
public function label(): string;
/** @return array<array-key, mixed> */
public function raw(): array;
/** @param array<array-key, mixed> $raw */
public function rev(array $raw): string;
/**
* @param array<array-key, mixed> $raw
* @return array<array-key, mixed> The inventory as read back after writing.
*/
public function write(array $raw, bool $mayRemove): array;
/**
* Whether Divi stored what was written – sanitizing may change values, never which entries exist.
*
* @param array<array-key, mixed> $expected
* @param array<array-key, mixed> $actual
*/
public function verify(array $expected, array $actual): bool;
}
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The change pipeline of every design system write (spec §4, G2, G6): plan on the live inventory → without
* confirm a dry run (diff, affected posts, design_rev; nothing saved) → with confirm the design_rev must still match
* → no live post may block (P8/P8a) → release lock → snapshot of the area → write through Divi → read back and
* verify → change log. A failed write or read-back writes the snapshot back (Rollback); when that fails too the
* error is partial_write with the snapshot to restore by hand. The rights are the tool's (spec §10).
*/
final class DesignChange
{
public const OBJECT_TYPE = 'divi_design';
private const LISTED_IDS = 10;
public function __construct(private SnapshotStore $snapshots, private ChangeLog $log)
{
}
/** @return array<string, array<string, mixed>> Input properties every design write tool takes. */
public static function inputProperties(): array
{
return [
'confirm' => [
'type' => 'boolean',
'description' => 'Erst nach dem Probelauf und der Rückfrage beim Nutzer.',
],
'design_rev' => [
'type' => 'string',
'description' => 'design_rev aus dem Probelauf (Pflicht mit confirm).',
],
];
}
/** @return array<string, mixed> */
public static function outputSchema(): array
{
return ToolResult::schema([
'diff' => ['type' => 'array', 'items' => ['type' => 'object']],
'design_rev' => ['type' => 'string'],
], ['diff', 'design_rev']);
}
/**
* @param array<string, mixed> $input The tool input (confirm, design_rev).
* @param callable(array<array-key, mixed>): (DesignPlan|WP_Error) $plan
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
* @throws DiviLocked
*/
public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error
{
$current = $area->raw();
$rev = $area->rev($current);
$planned = $plan($current);
if ($planned instanceof WP_Error) {
return $planned;
}
if ($planned->isEmpty()) {
return ToolResult::ok('Keine Änderung – ' . $planned->summary)
->with('diff', [])
->with('design_rev', $rev)
->warnings($planned->warnings)
->toArray();
}
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
$planned->summary . ' Nichts wurde gespeichert (Probelauf).',
['diff' => $planned->diff, 'design_rev' => $rev, 'warnings' => $planned->warnings]
+ ($planned->usages?->listing() ?? ['affected' => [], 'affected_count' => 0])
+ ($planned->usages !== null && $planned->usages->blocked !== []
? ['blocked_ids' => array_slice($planned->usages->blocked, 0, self::LISTED_IDS)] : [])
);
}
if (($input['design_rev'] ?? null) !== $rev) {
return Errors::revMismatch(
sprintf('Die %s wurden seit dem Probelauf geändert; nichts wurde gespeichert.', $area->label()),
'Erneut ohne confirm aufrufen (Probelauf), dem Nutzer die Änderung zeigen und mit der neuen '
. 'design_rev bestätigen.'
);
}
$blocked = $this->blocked($planned);
if ($blocked !== null) {
return $blocked;
}
ReleaseGate::assertWritable();
try {
$snapshotId = $this->snapshots->save($area->name(), $current, $ability);
} catch (SnapshotTooLarge $e) {
return Errors::make(
'snapshot_too_large',
sprintf(
'Die %s sind zu groß für eine Sicherung (%d Bytes, höchstens %d); nichts wurde gespeichert.',
$area->label(),
$e->size,
SnapshotStore::MAX_BYTES
),
'Ohne Sicherung schreibt LIT MCP nicht. Die Änderung im Divi-Builder vornehmen oder unbenutzte '
. 'Einträge dort entfernen.',
413
);
}
$saved = $this->write($ability, $area, $planned, $current, $snapshotId);
$logId = $this->log->record(
$ability,
self::OBJECT_TYPE,
$area->name(),
$planned->summary,
snapshotId: $snapshotId
);
$this->snapshots->attachLog($area->name(), $snapshotId, $logId);
return ToolResult::ok($planned->summary)
->snapshotId($snapshotId)
->logId($logId)
->warnings($planned->warnings)
->with('diff', $planned->diff)
->with('design_rev', $area->rev($saved))
->toArray();
}
/** Spec §4 / rulings P8, P8a: a confirmed change is refused while the caller may not edit a live post it changes. */
private function blocked(DesignPlan $plan): ?WP_Error
{
$usages = $plan->usages;
if ($usages === null || ! $usages->blocks()) {
return null;
}
return Errors::make(
'forbidden',
$usages->tooMany
? 'Die Änderung betrifft zu viele veröffentlichte Seiten, um sie einzeln zu prüfen; nichts wurde '
. 'gespeichert.'
: 'Die Änderung wirkt auf veröffentlichte Seiten, die dieses Konto nicht bearbeiten darf; nichts wurde '
. 'gespeichert.',
'Einen Administrator die Änderung vornehmen lassen oder sie im Divi-Builder ausführen.',
403,
['blocked_ids' => array_slice($usages->blocked, 0, self::LISTED_IDS)]
+ ($usages->blockedHidden > 0 ? ['blocked_hidden_count' => $usages->blockedHidden] : [])
);
}
/**
* @param array<array-key, mixed> $current
* @return array<array-key, mixed>
* @throws WriteFailed
*/
private function write(
string $ability,
DesignArea $area,
DesignPlan $plan,
array $current,
string $snapshotId
): array {
try {
$saved = $area->write($plan->raw, $plan->mayRemove);
if (! $area->verify($plan->raw, $saved)) {
throw new RuntimeException(sprintf('%s read back differs from what was written', $area->name()));
}
return $saved;
} catch (Throwable $e) {
if (DiviLocked::in($e) !== null) {
throw $e;
}
$this->rollBack($ability, $area, $current, $snapshotId, $e);
}
}
/**
* @param array<array-key, mixed> $current
* @throws WriteFailed Always.
*/
private function rollBack(
string $ability,
DesignArea $area,
array $current,
string $snapshotId,
Throwable $cause
): never {
$reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label());
$failures = Rollback::attempt(static fn () => $area->write($current, true));
if ($failures === []) {
throw WriteFailed::rolledBack(
$reason,
$cause,
sprintf('design %s write failed: %s', $area->name(), $cause->getMessage()),
details: ['snapshot_id' => $snapshotId]
);
}
$this->log->recordPartial(
$ability,
self::OBJECT_TYPE,
$area->name(),
sprintf('%s nicht zurückgenommen (%s); Snapshot %s', $area->label(), $failures[0], $snapshotId)
);
throw WriteFailed::partial(
$reason,
$cause,
sprintf('design %s rollback failed: %s', $area->name(), $failures[0]),
state: sprintf(
'Die %s sind möglicherweise teilweise geändert (Stand vorher: Snapshot %s).',
$area->label(),
$snapshotId
),
hint: sprintf(
'Den Aufruf nicht wiederholen. Mit lit/divi-design-restore und snapshot_id %s (Probelauf, dann '
. 'confirm: true) den vorherigen Stand wiederherstellen und dem Nutzer Bescheid geben.',
$snapshotId
),
details: ['snapshot_id' => $snapshotId]
);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
defined('ABSPATH') || exit;
/**
* What a design change would do (spec §4 step 3–4): the new raw inventory, the diff, a one-sentence German summary,
* the posts it affects (null = none to report), warnings and whether entries are removed (delete/restore).
*/
final class DesignPlan
{
/**
* @param array<array-key, mixed> $raw
* @param list<array<string, mixed>> $diff
* @param list<string> $warnings
*/
public function __construct(
public readonly array $raw,
public readonly array $diff,
public readonly string $summary,
public readonly ?UsageReport $usages = null,
public readonly array $warnings = [],
public readonly bool $mayRemove = false
) {
}
public function isEmpty(): bool
{
return $this->diff === [];
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* The area "presets" (spec §6): the inventory in Divi's stored format (PresetBook), written as REST format through
* DesignSystemWriter::savePresets(). Divi's prepare_data() may sanitize values; verify() only insists on the same
* presets and defaults (PresetDiff without "changed").
*/
final class PresetArea implements DesignArea
{
public function __construct(private DesignSystemWriter $writer)
{
}
public function name(): string
{
return 'presets';
}
public function label(): string
{
return 'Presets';
}
public function raw(): array
{
return PresetBook::fromDivi($this->writer->presets())->data();
}
public function rev(array $raw): string
{
return PresetBook::fromDivi($raw)->rev();
}
public function write(array $raw, bool $mayRemove): array
{
return PresetBook::fromDivi($this->writer->savePresets(PresetBook::fromDivi($raw)->toRest(), $mayRemove))
->data();
}
public function verify(array $expected, array $actual): bool
{
$structural = array_filter(
PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)),
static fn (array $row): bool => $row['action'] !== 'changed'
);
return $structural === [];
}
}
@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DesignArea;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\SnapshotTooLarge;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DesignChangeTest extends TestCase
{
/** A DesignArea over an array; $failWrite/$failRestore make write()/the rollback throw. */
private static function area(array &$stored, int $failWrites = 0, bool $verify = true): DesignArea
{
return new class ($stored, $failWrites, $verify) implements DesignArea {
public array $writes = [];
public function __construct(private array &$stored, private int $failWrites, private bool $verify)
{
}
public function name(): string
{
return 'presets';
}
public function label(): string
{
return 'Presets';
}
public function raw(): array
{
return $this->stored;
}
public function rev(array $raw): string
{
return substr(md5((string) json_encode($raw)), 0, 12);
}
public function write(array $raw, bool $mayRemove): array
{
$this->writes[] = [$raw, $mayRemove];
if ($this->failWrites > 0) {
--$this->failWrites;
throw new RuntimeException('divi refused');
}
$this->stored = $raw;
return $raw;
}
public function verify(array $expected, array $actual): bool
{
return $this->verify;
}
};
}
private static function plan(array $raw, ?UsageReport $usages = null): callable
{
return static fn (array $current): DesignPlan => new DesignPlan(
$raw,
[['kind' => 'module', 'scope' => 'divi/button', 'id' => 'a', 'name' => 'CTA', 'action' => 'added']],
'Preset „CTA“ wird angelegt.',
$usages
);
}
private static function usages(array $blocked = []): UsageReport
{
return new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
0, 1, $blocked, 0, false);
}
public function testDryRunListsDiffAffectedAndRevAndSavesNothing(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->shouldNotReceive('save');
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
->run('lit/divi-presets-set', $area, [], self::plan(['v' => 2], self::usages()));
$details = self::assertToolError($result, 'confirm_required');
self::assertStringContainsString('Preset „CTA“ wird angelegt. Nichts wurde gespeichert (Probelauf).', $result->get_error_message());
self::assertSame('added', $details['diff'][0]['action']);
self::assertSame($area->rev(['v' => 1]), $details['design_rev']);
self::assertSame(3, $details['affected'][0]['post_id']);
self::assertSame(1, $details['affected_count']);
self::assertSame([], $area->writes);
}
public function testNothingToDoIsNoDryRun(): void
{
$stored = ['v' => 1];
$empty = static fn (array $current): DesignPlan => new DesignPlan($current, [], 'Nichts.');
$result = (new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))
->run('lit/divi-presets-set', self::area($stored), [], $empty);
self::assertTrue($result['ok']);
self::assertSame([], $result['diff']);
self::assertStringStartsWith('Keine Änderung', $result['summary']);
}
public function testStaleDesignRevWritesNothing(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->shouldNotReceive('save');
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
->run('lit/divi-presets-set', $area, ['confirm' => true, 'design_rev' => 'veraltet'], self::plan(['v' => 2]));
self::assertToolError($result, 'rev_mismatch');
self::assertStringContainsString('Presets wurden seit dem Probelauf geändert', $result->get_error_message());
self::assertSame([], $area->writes);
}
public function testBlockedUsagesRefuseTheConfirm(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->shouldNotReceive('save');
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2], self::usages([3]))
);
$details = self::assertToolError($result, 'forbidden');
self::assertSame([3], $details['blocked_ids']);
}
public function testConfirmTakesASnapshotWritesAndLogs(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->with('presets', ['v' => 1], 'lit/divi-presets-set')->andReturn('presets-1');
$snapshots->expects('attachLog')->with('presets', 'presets-1', 9);
$log = new FakeChangeLog(9);
$result = (new DesignChange($snapshots, $log))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev(['v' => 1])],
self::plan(['v' => 2])
);
self::assertTrue($result['ok']);
self::assertSame('presets-1', $result['snapshot_id']);
self::assertSame(9, $result['log_id']);
self::assertSame($area->rev(['v' => 2]), $result['design_rev']);
self::assertSame(['v' => 2], $stored);
self::assertSame('presets', $log->records[0]['object_id']);
self::assertSame('divi_design', $log->records[0]['object_type']);
self::assertSame('presets-1', $log->records[0]['snapshot_id']);
}
public function testLockedDiviWritesNoSnapshot(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0'));
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->shouldNotReceive('save');
$this->expectException(DiviLocked::class);
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
}
public function testTooLargeSnapshotRefusesTheChange(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andThrow(new SnapshotTooLarge(3000000));
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
self::assertToolError($result, 'snapshot_too_large');
self::assertSame([], $area->writes);
}
public function testFailedWriteRestoresTheSnapshot(): void
{
$stored = ['v' => 1];
$area = self::area($stored, 1);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-1');
try {
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
self::fail('expected WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame('presets-1', $e->details['snapshot_id']);
}
self::assertSame([[['v' => 2], false], [['v' => 1], true]], $area->writes);
self::assertSame(['v' => 1], $stored);
}
public function testReadBackMismatchRestoresTheSnapshot(): void
{
$stored = ['v' => 1];
$area = self::area($stored, 0, false);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-1');
$this->expectException(WriteFailed::class);
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
}
public function testFailedRollbackIsPartial(): void
{
$stored = ['v' => 1];
$area = self::area($stored, 2);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-1');
$log = FakeChangeLog::partialsOnly();
try {
(new DesignChange($snapshots, $log))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
self::fail('expected WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertStringContainsString('presets-1', (string) $e->hint);
}
self::assertCount(1, $log->partials);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\PresetArea;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class PresetAreaTest extends TestCase
{
public function testWritesTheRestFormatAndReadsBack(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$stored = PresetBookTest::stored();
$writer->allows('presets')->andReturn($stored);
$writer->expects('savePresets')
->with(PresetBook::fromDivi($stored)->toRest(), true)
->andReturn($stored);
$area = new PresetArea($writer);
self::assertSame(['presets', 'Presets'], [$area->name(), $area->label()]);
self::assertSame(PresetBook::fromDivi($stored)->data(), $area->raw());
self::assertSame(PresetBook::fromDivi($stored)->rev(), $area->rev($stored));
self::assertSame(PresetBook::fromDivi($stored)->data(), $area->write($stored, true));
}
public function testVerifyIgnoresSanitizedValuesButNotMissingPresets(): void
{
$area = new PresetArea(Mockery::mock(DesignSystemWriter::class));
$expected = PresetBookTest::stored();
$sanitized = $expected;
$sanitized['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['x' => 'bereinigt']];
$missing = $expected;
unset($missing['module']['divi/button']['items']['bbbbbbbbbb']);
$otherDefault = $expected;
$otherDefault['module']['divi/text']['default'] = '';
self::assertTrue($area->verify($expected, $sanitized));
self::assertFalse($area->verify($expected, $missing));
self::assertFalse($area->verify($expected, $otherDefault));
}
}