refactor: ToolAvailability ersetzt die sechs Referenz-Closures in Plugin (Frage vor der Verdrahtung wirft statt still false); DiviIntegration bekommt den Tools-Tab-Zustand als Objekt; Plugin::integrations() mit Verdrahtungstest für Reihenfolge, einmaliges Booten und spätes Binden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 21:56:36 +02:00
co-authored by Claude Opus 5.5
parent 6c59934caa
commit e6e0f914ba
6 changed files with 376 additions and 109 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use LIT\MCP\Integrations\IntegrationRegistry;
use LogicException;
defined('ABSPATH') || exit;
/**
* The Tools-tab state for integrations that gate their own tools on another group or tool (rulings P9, E9, audit
* M2/L5). Integrations get this object before the registry and the policy exist – both need the integrations –
* and ask it only at call time; Plugin binds it right after building them. A question before bind() is a wiring
* error and throws, instead of quietly answering false.
*/
final class ToolAvailability
{
private ?IntegrationRegistry $registry = null;
private ?ExposurePolicy $policy = null;
/** Called once by Plugin after the registry and the policy are built. */
public function bind(IntegrationRegistry $registry, ExposurePolicy $policy): void
{
if ($this->policy !== null) {
throw new LogicException('ToolAvailability is already bound.');
}
$this->registry = $registry;
$this->policy = $policy;
}
/** Whether $group is switched on at all (ExposurePolicy::groupEnabled()). */
public function groupEnabled(string $group): bool
{
return $this->policy()->groupEnabled($group);
}
/** Whether abilities of $group may write (ExposurePolicy::groupWritable()). */
public function groupWritable(string $group): bool
{
return $this->policy()->groupWritable($group);
}
/** Whether the ability named $ability is registered and not disabled (ExposurePolicy::allows()). */
public function allows(string $ability): bool
{
$policy = $this->policy();
return $policy->allows($ability, $this->registry()->abilities());
}
private function policy(): ExposurePolicy
{
return $this->policy ?? throw new LogicException('ToolAvailability asked before Plugin bound it.');
}
private function registry(): IntegrationRegistry
{
return $this->registry ?? throw new LogicException('ToolAvailability asked before Plugin bound it.');
}
}
+49 -34
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use Closure;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\ChangeLog\ChangeLog;
@@ -83,40 +84,42 @@ final class DiviIntegration implements Integration, Bootable
private ThemeBuilderAccess $themeBuilderAccess;
private DesignSystemWriter $designWriter;
/** @var (Closure(): bool)|null Whether lit/divi-insert is enabled (ruling P9, E9, audit M2); null = always. */
private ?Closure $diviWritable;
/** @var (Closure(): bool)|null Whether lit/library-insert is enabled (audit L5); null = always. */
private ?Closure $libraryInsertable;
/**
* @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
* (group "Divi-Bibliothek" writable); null = always.
* @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group
* "Divi-Bibliothek" not disabled, ruling P9); null = always.
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy
* and lit-library-insert follow it (ruling P9, E9, audit M2); null = always.
* @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled –
* lit/divi-node-copy follows it for library entries as source (audit L5); null = always.
* @param (Closure(): bool)|null $themeBuilderWritable Tools-tab gate for writes to Theme Builder layouts in
* the Divi tools (group "Divi Theme Builder" writable, E9); null = always.
* @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi
* tools at all (group "Divi Theme Builder" not disabled); null = always.
* @param ToolAvailability|null $tools The Tools-tab state, asked at call time: the groups "Divi-Bibliothek" and
* "Divi Theme Builder" gate their entries in the Divi tools (ruling P9, E9), lit/divi-insert gates
* lit/divi-node-copy, lit-library-insert and content-create's divi input (audit M2), lit/library-insert
* gates lit/divi-node-copy from a library entry (audit L5). null = no Tools tab: everything allowed.
*/
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
?Closure $libraryWritable = null,
?Closure $libraryVisible = null,
private ?Closure $diviWritable = null,
private ?Closure $libraryInsertable = null,
?Closure $themeBuilderWritable = null,
?Closure $themeBuilderVisible = null
private ?ToolAvailability $tools = null
) {
$this->modules = new DiviModules();
$this->design = new DesignSystemReader();
$this->designWriter = new DesignSystemWriter();
$this->diviWritable = $this->gate(static fn (ToolAvailability $t): bool => $t->allows('lit/divi-insert'));
$this->libraryInsertable = $this->gate(
static fn (ToolAvailability $t): bool => $t->allows('lit/library-insert')
);
$roles = new DiviRoles();
$this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible);
$this->access = new LibraryAccess(
new DiviLibraryStore(),
$roles,
$this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(LibraryAccess::GROUP)),
$this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(LibraryAccess::GROUP))
);
$this->themeBuilderAccess = new ThemeBuilderAccess(
new DiviThemeBuilder(),
$roles,
$themeBuilderWritable,
$themeBuilderVisible
$this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(ThemeBuilderAccess::GROUP)),
$this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(ThemeBuilderAccess::GROUP))
);
}
@@ -181,20 +184,12 @@ final class DiviIntegration implements Integration, Bootable
}
/**
* Divi short form for lit/content-create; available() reports whether Divi 5 is loaded,
* writable() asks $writable (the Tools-tab gate, security M2).
*
* @param (Closure(): bool)|null $writable
* Divi short form for lit/content-create; available() reports whether Divi 5 is loaded, writable() follows
* lit/divi-insert's Tools-tab state (security M2).
*/
public function contentComposer(?Closure $writable = null): ContentComposer
public function contentComposer(): ContentComposer
{
return new DiviContentComposer(
$this->schema,
$this->composition(),
new DiviSaver(),
$this->design,
$writable
);
return $this->shortForm($this->diviWritable);
}
/**
@@ -211,7 +206,7 @@ final class DiviIntegration implements Integration, Bootable
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
// Tools-tab gate: the check tests Divi, not this site's tool switches.
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->contentComposer());
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null));
(new DiviCheckRoute($check))->register();
DiviCheckCommand::register($check);
@@ -318,6 +313,26 @@ final class DiviIntegration implements Integration, Bootable
);
}
/**
* A gate on the Tools-tab state for the classes that take a (Closure(): bool)|null: asked at call time, null
* without $tools (always allowed).
*
* @param Closure(ToolAvailability): bool $ask
* @return (Closure(): bool)|null
*/
private function gate(Closure $ask): ?Closure
{
$tools = $this->tools;
return $tools === null ? null : static fn (): bool => $ask($tools);
}
/** @param (Closure(): bool)|null $writable */
private function shortForm(?Closure $writable): DiviContentComposer
{
return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable);
}
/**
* The one wiring of the composition for every tool that composes (spec D7): presets and Divi's option groups come
* from the design system writer, so a stack or group preset in any write tool resolves against the live book.
+35 -58
View File
@@ -6,6 +6,7 @@ namespace LIT\MCP;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\AccessTab;
use LIT\MCP\Admin\AdminNotices;
use LIT\MCP\Admin\ConnectionRepository;
@@ -23,9 +24,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
@@ -74,66 +73,14 @@ final class Plugin
(new ChangeLogCleanup($changeLog, $limits))->register();
$schema = self::contentSchema();
$registry = null;
$policy = null;
// Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries.
$libraryVisible = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP);
};
// Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
// when registry and policy exist), so the Divi group's switches stop every Divi layout write.
$diviWritable = static function () use (&$registry, &$policy): bool {
return $registry instanceof IntegrationRegistry
&& $policy instanceof ExposurePolicy
&& $policy->allows('lit/divi-insert', $registry->abilities());
};
// The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9).
$libraryWritable = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
};
// Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state.
$libraryInsertable = static function () use (&$registry, &$policy): bool {
return $registry instanceof IntegrationRegistry
&& $policy instanceof ExposurePolicy
&& $policy->allows('lit/library-insert', $registry->abilities());
};
// Etappe 3b: the group "Divi Theme Builder" gates TB layouts in the Divi tools like the library group (E9).
$themeBuilderVisible = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupEnabled(ThemeBuilderAccess::GROUP);
};
$themeBuilderWritable = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupWritable(ThemeBuilderAccess::GROUP);
};
$divi = new DiviIntegration(
$schema,
$changeLog,
$libraryWritable,
libraryVisible: $libraryVisible,
diviWritable: $diviWritable,
libraryInsertable: $libraryInsertable,
themeBuilderWritable: $themeBuilderWritable,
themeBuilderVisible: $themeBuilderVisible
);
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
new WordPressIntegration(
$schema,
$changeLog,
$divi->contentComposer($diviWritable),
$divi->libraryIdsVisible(...),
$divi->builderDetector()
),
new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()),
$divi,
$divi->library(),
$divi->themeBuilder()
);
$tools = new ToolAvailability();
$registry = self::integrations($schema, $changeLog, $limits, $tools);
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
$tools->bind($registry, $policy);
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
// page reads its tabs.
$registry->boot();
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
(new AbilityRegistrar(
$registry,
$policy,
@@ -172,6 +119,36 @@ final class Plugin
\WPMedia\MCP\OAuth\Bootstrap::instance();
}
/**
* The integrations in registry order – the order of the Tools tab, the guide and the server instructions. The
* Divi integration and its groups share $tools, which Plugin binds once registry and policy exist.
*
* @internal Public for PluginTest only.
*/
public static function integrations(
ContentSchema $schema,
ChangeLog $changeLog,
Limits $limits,
ToolAvailability $tools
): IntegrationRegistry {
$divi = new DiviIntegration($schema, $changeLog, $tools);
return new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
new WordPressIntegration(
$schema,
$changeLog,
$divi->contentComposer(),
$divi->libraryIdsVisible(...),
$divi->builderDetector()
),
new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()),
$divi,
$divi->library(),
$divi->themeBuilder()
);
}
public static function settingsPage(): ?SettingsPage
{
return self::$settingsPage;