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'); $composed = $composition->composer()->compose(array_values($sections), null, 'divi');
if ($composed['errors'] !== []) { if ($composed['errors'] !== []) {
return $composed['invalid_paths'] !== [] return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
? Errors::invalidAttrPath($composed['errors'])
: Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']);
} }
$document = new Document(); $document = new Document();
@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages; namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use WP_Error; 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 * nodes inside another node that is deleted anyway do not count twice. The revision in the
* result allows undoing it (lit/revision-restore). * 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 public function name(): string
{ {
return 'lit/divi-delete'; 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 public function annotations(): array
{ {
return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; 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 */ /** @var array<string, Block> $nodes */
$nodes = []; $nodes = [];
foreach (is_array($input['ids'] ?? null) ? $input['ids'] : [] as $id) { foreach (is_array($input['ids'] ?? null) ? $input['ids'] : [] as $id) {
$node = is_string($id) ? $page->index->find($id) : null; $node = is_string($id) ? $page->index->find($id) : null;
if ($node === null) { if ($node === null) {
return DiviPageLoader::unknownNode((string) $id, $page->post); return DiviErrors::unknownNode((string) $id, $page->post);
} }
$nodes[$id] = $node; $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))); $change = new DiviChange(sprintf('%d Divi-Knoten gelöscht', count($nodes)));
foreach ($nodes as $id => $node) { foreach ($nodes as $id => $node) {
$change->removed((string) $id, $node); $change->removed((string) $id, $node);
$editor->remove($page->index, $node); $editor->remove($page->index, $node);
} }
return $this->writer->save($this->name(), $page, $change, $input); return $change;
} }
/** @param array<string, Block> $nodes */ /** @param array<string, Block> $nodes */
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages; namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use WP_Error; 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, * (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. * 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 public function name(): string
{ {
return 'lit/divi-duplicate-node'; 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 public function annotations(): array
{ {
return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
@@ -63,31 +52,20 @@ final class DiviDuplicateNodeAbility implements Ability
return false; 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'] : ''; $id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id); $node = $page->index->find($id);
if ($node === null) { 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()) { if ($copy->isDivi() && ! $copy->isOpaque()) {
// The raw stored desktop value (never NodeSummary::label()'s display-shortened // 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. // 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. // 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'); $raw = AttrMap::desktop($node->attrs(), 'module.meta.adminLabel');
$label = is_string($raw) ? $raw : ''; $label = is_string($raw) ? $raw : '';
$copy->setAttrs(AttrMap::set( $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 (new DiviChange(sprintf('Knoten %s dupliziert', $id)))->changed($copy, 'duplicated');
return $this->writer->save($this->name(), $page, $change, $input);
} }
} }
@@ -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; namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use WP_Error; use WP_Error;
/** /**
@@ -16,12 +14,8 @@ use WP_Error;
* (client values pass its choke point) and enter the tree only through TreeEditor::insert(), * (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). * 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 public function name(): string
{ {
return 'lit/divi-insert'; 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 public function annotations(): array
{ {
return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; 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(); $editor = $composition->editor();
$position = $editor->position($page->index, $input); $position = $editor->position($page->index, $input);
if (is_string($position)) { if (is_string($position)) {
// The nodes are checked against their parent – without a valid position there is none. // 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']); $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); $composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context);
if ($composed['errors'] !== []) { if ($composed['errors'] !== []) {
return $composed['invalid_paths'] !== [] return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.');
? Errors::invalidAttrPath($composed['errors'])
: Errors::invalidInput('Knoten sind ungültig; nichts wurde gespeichert.', $composed['errors']);
} }
$violation = $editor->insert($position, $composed['blocks']); $violation = $editor->insert($position, $composed['blocks']);
@@ -125,6 +94,6 @@ final class DiviInsertAbility implements Ability
$change->changed($block, 'inserted'); $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; namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use WP_Error; 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 * 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. * 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 public function name(): string
{ {
return 'lit/divi-move'; 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 public function annotations(): array
{ {
return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; 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'] : ''; $id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id); $node = $page->index->find($id);
if ($node === null) { 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); $position = $editor->position($page->index, $input);
if (is_string($position)) { 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); $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 (new DiviChange(sprintf('Knoten %s verschoben', $id)))->changed($node, 'moved');
return $this->writer->save($this->name(), $page, $change, $input);
} }
} }
@@ -118,7 +118,7 @@ final class DiviNodeGetAbility implements Ability
$block = $page->index->find($id); $block = $page->index->find($id);
if ($block === null) { if ($block === null) {
return DiviPageLoader::unknownNode($id, $page->post); return DiviErrors::unknownNode($id, $page->post);
} }
$names = $this->design->read()->presetNames(); $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"']); return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']);
} }
if ($rootId !== null && $page->index->find($rootId) === null) { 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()); $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\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Log; use LIT\MCP\Log;
use WP_Error; use WP_Error;
use WP_Post;
/** /**
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the * 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); 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; namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths; use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
use WP_Error; 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 * appear once per call (ruling S14); set and attrs of one change must not reach the same path
* (ruling S38, checked by Composer::update()). * (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 public function name(): string
{ {
return 'lit/divi-update'; 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 public function annotations(): array
{ {
return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; 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']) : []; $changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : [];
if ($changes === []) { if ($changes === []) {
return Errors::invalidInput('Keine Änderungen angegeben; nichts wurde gespeichert.', [ 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 = []; $errors = [];
$invalidPaths = false; /** @var list<string> $invalidPaths */
$invalidPaths = [];
/** @var array<string, int> $seen id => position of its first change */ /** @var array<string, int> $seen id => position of its first change */
$seen = []; $seen = [];
/** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */ /** @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); $result = $composer->update($block, $change, $field);
$errors += $result['errors']; $errors += $result['errors'];
$invalidPaths = $invalidPaths || $result['invalid_paths'] !== []; array_push($invalidPaths, ...$result['invalid_paths']);
$updates[] = [$block, $result['attrs'], $field]; $updates[] = [$block, $result['attrs'], $field];
} }
@@ -159,9 +132,10 @@ final class DiviUpdateAbility implements Ability
} }
if ($errors !== []) { if ($errors !== []) {
return $invalidPaths return DiviErrors::composed(
? Errors::invalidAttrPath($errors) ['errors' => $errors, 'invalid_paths' => $invalidPaths],
: Errors::invalidInput('Änderungen sind ungültig; nichts wurde gespeichert.', $errors); 'Änderungen sind ungültig; nichts wurde gespeichert.'
);
} }
$updates = array_values(array_filter( $updates = array_values(array_filter(
@@ -175,6 +149,6 @@ final class DiviUpdateAbility implements Ability
$change->changed($block, 'updated'); $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);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ final class ToolNamingTest extends TestCase
$class = 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], substr((string) $file, strlen($root . '/src/'))); $class = 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], substr((string) $file, strlen($root . '/src/')));
$reflection = new ReflectionClass($class); $reflection = new ReflectionClass($class);
if ($reflection->isInterface() || ! $reflection->implementsInterface(Ability::class)) { if (! $reflection->isInstantiable() || ! $reflection->implementsInterface(Ability::class)) {
continue; continue;
} }