refactor: DiviWriteAbility base and shared DiviErrors (H4)

The five Divi write tools extend DiviWriteAbility (final permission and
execute, change() per tool, shared output schema). DiviErrors holds the
unknown-node, position and compose-error mapping; DiviContentComposer uses
the same mapping (ledger T9). ToolNamingTest skips abstract classes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 18:22:51 +02:00
co-authored by Claude Opus 5.5
parent 9da236b0e3
commit ecfece719d
12 changed files with 141 additions and 187 deletions
@@ -98,9 +98,7 @@ final class DiviContentComposer implements ContentComposer
$composed = $composition->composer()->compose(array_values($sections), null, 'divi');
if ($composed['errors'] !== []) {
return $composed['invalid_paths'] !== []
? Errors::invalidAttrPath($composed['errors'])
: Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']);
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
}
$document = new Document();
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use WP_Error;
@@ -17,12 +15,8 @@ use WP_Error;
* nodes inside another node that is deleted anyway do not count twice. The revision in the
* result allows undoing it (lit/revision-restore).
*/
final class DiviDeleteAbility implements Ability
final class DiviDeleteAbility extends DiviWriteAbility
{
public function __construct(private DiviWriter $writer)
{
}
public function name(): string
{
return 'lit/divi-delete';
@@ -59,41 +53,19 @@ final class DiviDeleteAbility implements Ability
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
/** @var array<string, Block> $nodes */
$nodes = [];
foreach (is_array($input['ids'] ?? null) ? $input['ids'] : [] as $id) {
$node = is_string($id) ? $page->index->find($id) : null;
if ($node === null) {
return DiviPageLoader::unknownNode((string) $id, $page->post);
return DiviErrors::unknownNode((string) $id, $page->post);
}
$nodes[$id] = $node;
}
@@ -115,14 +87,14 @@ final class DiviDeleteAbility implements Ability
);
}
$editor = $this->writer->composition($page)->editor();
$editor = $composition->editor();
$change = new DiviChange(sprintf('%d Divi-Knoten gelöscht', count($nodes)));
foreach ($nodes as $id => $node) {
$change->removed((string) $id, $node);
$editor->remove($page->index, $node);
}
return $this->writer->save($this->name(), $page, $change, $input);
return $change;
}
/** @param array<string, Block> $nodes */
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use WP_Error;
@@ -14,12 +12,8 @@ use WP_Error;
* (spec §5.7). The copy's admin label gets " (Kopie)"; read-only nodes (global modules,
* foreign blocks) are copied unchanged, as a whole, byte-identical reference.
*/
final class DiviDuplicateNodeAbility implements Ability
final class DiviDuplicateNodeAbility extends DiviWriteAbility
{
public function __construct(private DiviWriter $writer)
{
}
public function name(): string
{
return 'lit/divi-duplicate-node';
@@ -48,11 +42,6 @@ final class DiviDuplicateNodeAbility implements Ability
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
@@ -63,31 +52,20 @@ final class DiviDuplicateNodeAbility implements Ability
return false;
}
public function permission(array $input): bool|WP_Error
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviPageLoader::unknownNode($id, $page->post);
return DiviErrors::unknownNode($id, $page->post);
}
$copy = $this->writer->composition($page)->editor()->duplicate($page->index, $node);
$copy = $composition->editor()->duplicate($page->index, $node);
if ($copy->isDivi() && ! $copy->isOpaque()) {
// The raw stored desktop value (never NodeSummary::label()'s display-shortened
// text): a long or multi-line label must be copied verbatim, only " (Kopie)" added.
// Setting only the ".desktop.value" leaf keeps every other breakpoint untouched.
// No client input: the stored label plus a constant suffix, so no choke point (S39).
$raw = AttrMap::desktop($node->attrs(), 'module.meta.adminLabel');
$label = is_string($raw) ? $raw : '';
$copy->setAttrs(AttrMap::set(
@@ -97,8 +75,6 @@ final class DiviDuplicateNodeAbility implements Ability
));
}
$change = (new DiviChange(sprintf('Knoten %s dupliziert', $id)))->changed($copy, 'duplicated');
return $this->writer->save($this->name(), $page, $change, $input);
return (new DiviChange(sprintf('Knoten %s dupliziert', $id)))->changed($copy, 'duplicated');
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use WP_Error;
use WP_Post;
/** The client errors the Divi tools share. */
final class DiviErrors
{
/** A path ID that names no node of $post. */
public static function unknownNode(string $id, WP_Post $post): WP_Error
{
return Errors::notFound(
sprintf('Knoten "%s" gibt es in Eintrag %d nicht.', $id, $post->ID),
DiviPageLoader::OUTLINE_HINT
);
}
/** A parent / before / after / index the tree has no place for (TreeEditor::position()). */
public static function position(string $problem): WP_Error
{
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $problem]);
}
/**
* The errors of Composer::compose()/update(): invalid_attr_path when an attribute path is
* unknown (the more useful hint), else invalid_input with $message.
*
* @param array{errors: array<string, string>, invalid_paths: list<string>} $result
*/
public static function composed(array $result, string $message): WP_Error
{
return $result['invalid_paths'] !== []
? Errors::invalidAttrPath($result['errors'])
: Errors::invalidInput($message, $result['errors']);
}
}
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use WP_Error;
/**
@@ -16,12 +14,8 @@ use WP_Error;
* (client values pass its choke point) and enter the tree only through TreeEditor::insert(),
* which also wraps the first section of an empty page in divi/placeholder (ruling S9).
*/
final class DiviInsertAbility implements Ability
final class DiviInsertAbility extends DiviWriteAbility
{
public function __construct(private DiviWriter $writer)
{
}
public function name(): string
{
return 'lit/divi-insert';
@@ -60,41 +54,18 @@ final class DiviInsertAbility implements Ability
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$composition = $this->writer->composition($page);
$editor = $composition->editor();
$position = $editor->position($page->index, $input);
if (is_string($position)) {
// The nodes are checked against their parent – without a valid position there is none.
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]);
return DiviErrors::position($position);
}
$context = DiviWriter::sectionLabel($page->index, $position['parent']);
@@ -107,9 +78,7 @@ final class DiviInsertAbility implements Ability
$composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context);
if ($composed['errors'] !== []) {
return $composed['invalid_paths'] !== []
? Errors::invalidAttrPath($composed['errors'])
: Errors::invalidInput('Knoten sind ungültig; nichts wurde gespeichert.', $composed['errors']);
return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.');
}
$violation = $editor->insert($position, $composed['blocks']);
@@ -125,6 +94,6 @@ final class DiviInsertAbility implements Ability
$change->changed($block, 'inserted');
}
return $this->writer->save($this->name(), $page, $change, $input);
return $change;
}
}
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use WP_Error;
/**
@@ -14,12 +12,8 @@ use WP_Error;
* another row or section, as long as the structure rules allow it (spec §5.7). Read-only
* nodes (global modules, foreign blocks) move as a whole, unchanged.
*/
final class DiviMoveAbility implements Ability
final class DiviMoveAbility extends DiviWriteAbility
{
public function __construct(private DiviWriter $writer)
{
}
public function name(): string
{
return 'lit/divi-move';
@@ -50,45 +44,23 @@ final class DiviMoveAbility implements Ability
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviPageLoader::unknownNode($id, $page->post);
return DiviErrors::unknownNode($id, $page->post);
}
$editor = $this->writer->composition($page)->editor();
$editor = $composition->editor();
$position = $editor->position($page->index, $input);
if (is_string($position)) {
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]);
return DiviErrors::position($position);
}
$problem = $editor->move($page->index, $node, $position);
@@ -99,8 +71,6 @@ final class DiviMoveAbility implements Ability
);
}
$change = (new DiviChange(sprintf('Knoten %s verschoben', $id)))->changed($node, 'moved');
return $this->writer->save($this->name(), $page, $change, $input);
return (new DiviChange(sprintf('Knoten %s verschoben', $id)))->changed($node, 'moved');
}
}
@@ -118,7 +118,7 @@ final class DiviNodeGetAbility implements Ability
$block = $page->index->find($id);
if ($block === null) {
return DiviPageLoader::unknownNode($id, $page->post);
return DiviErrors::unknownNode($id, $page->post);
}
$names = $this->design->read()->presetNames();
@@ -143,7 +143,7 @@ final class DiviOutlineAbility implements Ability
return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']);
}
if ($rootId !== null && $page->index->find($rootId) === null) {
return DiviPageLoader::unknownNode($rootId, $page->post);
return DiviErrors::unknownNode($rootId, $page->post);
}
$outline = new Outline($this->design->read()->presetNames());
@@ -12,7 +12,6 @@ use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Log;
use WP_Error;
use WP_Post;
/**
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
@@ -91,12 +90,4 @@ class DiviPageLoader
return PostGuard::statusStable($post);
}
public static function unknownNode(string $id, WP_Post $post): WP_Error
{
return Errors::notFound(
sprintf('Knoten "%s" gibt es in Eintrag %d nicht.', $id, $post->ID),
self::OUTLINE_HINT
);
}
}
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
use WP_Error;
@@ -19,12 +17,8 @@ use WP_Error;
* appear once per call (ruling S14); set and attrs of one change must not reach the same path
* (ruling S38, checked by Composer::update()).
*/
final class DiviUpdateAbility implements Ability
final class DiviUpdateAbility extends DiviWriteAbility
{
public function __construct(private DiviWriter $writer)
{
}
public function name(): string
{
return 'lit/divi-update';
@@ -77,35 +71,13 @@ final class DiviUpdateAbility implements Ability
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : [];
if ($changes === []) {
return Errors::invalidInput('Keine Änderungen angegeben; nichts wurde gespeichert.', [
@@ -113,9 +85,10 @@ final class DiviUpdateAbility implements Ability
]);
}
$composer = $this->writer->composition($page)->composer();
$composer = $composition->composer();
$errors = [];
$invalidPaths = false;
/** @var list<string> $invalidPaths */
$invalidPaths = [];
/** @var array<string, int> $seen id => position of its first change */
$seen = [];
/** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
@@ -150,7 +123,7 @@ final class DiviUpdateAbility implements Ability
$result = $composer->update($block, $change, $field);
$errors += $result['errors'];
$invalidPaths = $invalidPaths || $result['invalid_paths'] !== [];
array_push($invalidPaths, ...$result['invalid_paths']);
$updates[] = [$block, $result['attrs'], $field];
}
@@ -159,9 +132,10 @@ final class DiviUpdateAbility implements Ability
}
if ($errors !== []) {
return $invalidPaths
? Errors::invalidAttrPath($errors)
: Errors::invalidInput('Änderungen sind ungültig; nichts wurde gespeichert.', $errors);
return DiviErrors::composed(
['errors' => $errors, 'invalid_paths' => $invalidPaths],
'Änderungen sind ungültig; nichts wurde gespeichert.'
);
}
$updates = array_values(array_filter(
@@ -175,6 +149,6 @@ final class DiviUpdateAbility implements Ability
$change->changed($block, 'updated');
}
return $this->writer->save($this->name(), $page, $change, $input);
return $change;
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolResult;
use WP_Error;
/**
* The frame of the Divi write tools (spec §5.6): permission and execute both load the page
* through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state
* from permission), the tool changes the tree in change() – only when its whole input
* validated, all or nothing (ruling S39) – and DiviWriter::save() stores it and builds the
* result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
*/
abstract class DiviWriteAbility implements Ability
{
public function __construct(protected DiviWriter $writer)
{
}
/**
* Changes the tree of $page, or returns why nothing is written. Client values enter the
* tree only through $composition (Composer / TreeEditor).
*
* @param array<string, mixed> $input
*/
abstract protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error;
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function isDirect(): bool
{
return true;
}
final public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
final public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$change = $this->change($page, $input, $this->writer->composition($page));
if ($change instanceof WP_Error) {
return $change;
}
return $this->writer->save($this->name(), $page, $change, $input);
}
}