feat(compat): Profile, Gates und Pods über den Kompatibilitätskern

Neu: Tier, Profile, Compatibility, CapabilityLocked, CapabilityGate, CompatibilityComponent, ProvidesCompatibility, DiviProfile sowie PodsFeature, PodsCompatibility und PodsIntegration. Das CapabilityGate sperrt gesperrte und unbekannte Fähigkeits-IDs (übernimmt die Sperre unbekannter IDs vom DiviFeatureGate) und delegiert nur Divi ausdrücklich. Pods-Schema, -Lesen, -Schreiben und -Feldrechte laufen über usable(PodsFeature::…); Schreiben verlangt FieldRead und FieldWrite, damit ein Rollback keine Werte verliert. lit-site-status meldet Pods über die CompatibilityComponent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 12:26:54 +01:00
co-authored by Claude Opus 5.5
parent b511ef6348
commit c85131603b
38 changed files with 2259 additions and 135 deletions
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The lock of all non-Divi capabilities: a call is refused before it runs while one of the capabilities it declares
* (DeclaresRequirements) is locked, and also while a declared ID is unknown to every profile. IDs of
* profiles named in $delegated are left to the guard that checks them itself. A refused write is logged with result
* "refused"; a refused read changes nothing and is not.
*/
final class CapabilityGate implements AbilityGuard
{
/** @param list<string> $delegated IDs of the profiles another guard checks (skipped here), e.g. 'divi'. */
public function __construct(
private Compatibility $compat,
private ?ChangeLog $log = null,
private array $delegated = []
) {
}
public function refusal(Ability $ability, array $input): ?WP_Error
{
$capabilities = [];
$unknown = [];
foreach ($ability instanceof DeclaresRequirements ? $ability->requirements($input) : [] as $id) {
$capability = $this->compat->capability($id);
if ($capability === null) {
$unknown[] = $id;
} elseif (! in_array($this->compat->owner($capability)?->id, $this->delegated, true)) {
$capabilities[] = $capability;
}
}
if ($unknown !== []) {
return $this->refuse(
$ability,
CapabilityLocked::unknown($unknown),
implode(', ', $unknown),
'unbekannte Fähigkeit ' . implode(', ', $unknown)
);
}
$lock = $this->compat->lockOf($capabilities);
if ($lock === null) {
return null;
}
$labels = implode(', ', array_map(static fn (FeatureStatus $s): string => $s->feature->label(), $lock->locked));
return $this->refuse(
$ability,
$lock,
implode(', ', array_unique(array_map(
fn (FeatureStatus $s): string => $this->compat->owner($s->feature)?->id ?? '',
$lock->locked
))),
sprintf('%s – %s gesperrt', implode(', ', $lock->plugins), $labels)
);
}
private function refuse(Ability $ability, CapabilityLocked $lock, string $objectId, string $reason): WP_Error
{
if (($ability->annotations()['readonly'] ?? false) !== true) {
$this->log?->record(
$ability->name(),
'compat',
$objectId,
'Abgelehnt: ' . $reason . '.',
result: 'refused'
);
}
return $lock->toError();
}
/** A refusal is the whole answer: there is no result to annotate. */
public function annotate(Ability $ability, mixed $result): mixed
{
return $result;
}
public function outputSchema(Ability $ability, ?array $schema): ?array
{
return $schema;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolRefusal;
use LogicException;
use RuntimeException;
use WP_Error;
defined('ABSPATH') || exit;
/**
* A call refused because a capability it needs is locked, or because no profile knows a capability it declares;
* AbilityRegistrar returns toError().
*/
final class CapabilityLocked extends RuntimeException implements ToolRefusal
{
/**
* @param list<FeatureStatus> $locked The locked capabilities the refused call needs.
* @param list<string> $plugins Labels of the plugins they belong to.
* @param list<string> $unknown Declared IDs no profile knows.
*/
private function __construct(
public readonly array $locked,
public readonly array $plugins,
public readonly array $unknown
) {
parent::__construct(sprintf(
'%s locked – %s refused',
implode(', ', $plugins) ?: 'unknown capability',
implode(', ', $unknown ?: array_map(static fn (FeatureStatus $s): string => $s->feature->id(), $locked))
));
}
/**
* @param list<FeatureStatus> $locked
* @param list<Profile> $profiles The profiles the locked capabilities belong to.
* @throws LogicException When $locked is empty – a lock always names what it locks.
*/
public static function of(array $locked, array $profiles): self
{
if ($locked === []) {
throw new LogicException('a capability lock needs the locked capabilities');
}
return new self($locked, array_map(static fn (Profile $profile): string => $profile->label, $profiles), []);
}
/**
* @param list<string> $ids
* @throws LogicException When $ids is empty.
*/
public static function unknown(array $ids): self
{
if ($ids === []) {
throw new LogicException('an unknown-capability lock needs the IDs');
}
return new self([], [], $ids);
}
public function toError(): WP_Error
{
if ($this->unknown !== []) {
return Errors::make(
'compat_incompatible',
sprintf(
'Das Werkzeug verlangt unbekannte Fähigkeiten (%s). Das ist gesperrt. Es wurde nichts geändert.',
implode(', ', $this->unknown)
),
'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.',
423,
['unknown' => $this->unknown]
);
}
$named = implode(', ', array_map(
static fn (FeatureStatus $status): string => '„' . $status->feature->label() . '“',
$this->locked
));
$plugins = implode(', ', $this->plugins);
return Errors::make(
'compat_incompatible',
sprintf(
'%s ist auf dieser Website nicht mit LIT MCP kompatibel: %s ist gesperrt. Es wurde nichts geändert.',
$plugins,
$named
),
sprintf(
'Gesperrt: %s. Andere Werkzeuge funktionieren weiter. '
. 'Den Nutzer informieren, dass %s geprüft werden muss.',
$named,
$plugins
),
423,
[
'features' => array_map(static fn (FeatureStatus $status): array => $status->summary(), $this->locked),
'problems' => array_values(array_unique(array_merge(
...array_map(static fn (FeatureStatus $status): array => $status->problems, $this->locked)
))),
]
);
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LogicException;
use Throwable;
defined('ABSPATH') || exit;
/**
* The compatibility state of all third-party plugins, evaluated per Profile and memoised per request. Everything
* that cannot be judged (a throwing closure or check, an unknown version under a minimum, an unowned capability)
* counts as locked – fail closed.
*/
final class Compatibility
{
/** @var array<string, Profile> keyed by profile ID */
private array $byId = [];
/** @var array<string, array{0: Capability, 1: Profile}> keyed by capability ID */
private array $byCapability = [];
/** @var array<string, CompatibilityMatrix> */
private array $matrices = [];
/** @var array<string, ManifestCheck> */
private array $checks = [];
/** @var Closure(Profile): ManifestCheck */
private Closure $check;
/**
* @param list<Profile> $profiles
* @param (Closure(Profile): ManifestCheck)|null $check Builds the check of a profile; default: its manifests
* without signature checks.
* @throws LogicException For a profile ID or a capability ID that occurs twice.
*/
public function __construct(private array $profiles, ?Closure $check = null)
{
$this->check = $check ?? static fn (Profile $profile): ManifestCheck => new ManifestCheck(
$profile->manifests,
signatures: false
);
foreach ($profiles as $profile) {
if (isset($this->byId[$profile->id])) {
throw new LogicException('profile ID twice: ' . $profile->id);
}
$this->byId[$profile->id] = $profile;
foreach ($profile->capabilities as $capability) {
if (isset($this->byCapability[$capability->id()])) {
throw new LogicException('capability ID twice: ' . $capability->id());
}
$this->byCapability[$capability->id()] = [$capability, $profile];
}
}
}
/** @return list<Profile> */
public function profiles(): array
{
return $this->profiles;
}
public function profile(string $id): ?Profile
{
return $this->byId[$id] ?? null;
}
public function capability(string $id): ?Capability
{
return ($this->byCapability[$id] ?? [null])[0];
}
public function owner(Capability $capability): ?Profile
{
return ($this->byCapability[$capability->id()] ?? [1 => null])[1];
}
public function active(Profile $profile): bool
{
return $profile->active === null || self::attempt($profile->active) === true;
}
public function version(Profile $profile): ?string
{
$version = $profile->version === null ? null : self::attempt($profile->version);
return is_string($version) ? $version : null;
}
public function matrix(Profile $profile): CompatibilityMatrix
{
return $this->matrices[$profile->id] ??= $this->build($profile);
}
/** @return list<string> Labels of what is wrong with the plugin; for ReportOnly the problems of the check. */
public function problems(Profile $profile): array
{
if ($profile->tier !== Tier::ReportOnly) {
return $this->matrix($profile)->problems();
}
try {
return array_values(array_map(
static fn (Problem $problem): string => $problem->label,
$this->checkOf($profile)->problems()
));
} catch (Throwable) {
return [self::failed($profile)];
}
}
public function usable(Capability $capability): bool
{
$owner = $this->owner($capability);
return $owner !== null && $this->matrix($owner)->of($capability)->usable();
}
/**
* The lock of those of $capabilities that are not usable, null when all are. Unowned ones are left out.
*
* @param list<Capability> $capabilities
*/
public function lockOf(array $capabilities): ?CapabilityLocked
{
$locked = [];
$owners = [];
foreach ($capabilities as $capability) {
$owner = $this->owner($capability);
if ($owner === null) {
continue;
}
$status = $this->matrix($owner)->of($capability);
if (! $status->usable()) {
$locked[$capability->id()] = $status;
$owners[$owner->id] = $owner;
}
}
return $locked === [] ? null : CapabilityLocked::of(array_values($locked), array_values($owners));
}
private function build(Profile $profile): CompatibilityMatrix
{
$capabilities = $profile->capabilities;
try {
if ($profile->tier === Tier::ReleasePerVersion) {
return ($profile->matrix)();
}
if ($profile->tier === Tier::ReportOnly) {
return CompatibilityMatrix::allReleased($capabilities);
}
if (! $this->active($profile)) {
return CompatibilityMatrix::allIncompatible(
$capabilities,
sprintf('%s ist nicht installiert oder nicht aktiv.', $profile->label)
);
}
$tooOld = $this->versionProblem($profile);
if ($tooOld !== null) {
return CompatibilityMatrix::allIncompatible($capabilities, $tooOld);
}
return CompatibilityMatrix::fromProblems(
$capabilities,
$this->checkOf($profile)->problems(),
null,
automatic: true
);
} catch (Throwable) {
return CompatibilityMatrix::allIncompatible($capabilities, self::failed($profile));
}
}
private function versionProblem(Profile $profile): ?string
{
if ($profile->minVersion === null) {
return null;
}
$version = $this->version($profile);
if ($version === null) {
return sprintf(
'Die Version von %s ist unbekannt (Mindestversion %s).',
$profile->label,
$profile->minVersion
);
}
return version_compare($version, $profile->minVersion, '<')
? sprintf('%s %s ist älter als die Mindestversion %s.', $profile->label, $version, $profile->minVersion)
: null;
}
private function checkOf(Profile $profile): ManifestCheck
{
return $this->checks[$profile->id] ??= ($this->check)($profile);
}
private static function failed(Profile $profile): string
{
return sprintf('Die Kompatibilitätsprüfung von %s ist fehlgeschlagen.', $profile->label);
}
/** The closure's result, null when it throws. */
private static function attempt(Closure $closure): mixed
{
try {
return $closure();
} catch (Throwable) {
return null;
}
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LogicException;
defined('ABSPATH') || exit;
/**
* One third-party plugin in lit-site-status: not installed, active, or active but broken (problems filled). No
* per-capability rows – the problem labels already name what is wrong. A ReleasePerVersion profile (Divi) brings its
* own component with the release status. The Compatibility comes per closure because it exists only after the
* integrations that provide the profiles.
*/
final class CompatibilityComponent implements EnvironmentComponent
{
/**
* @param Closure(): Compatibility $compatibility
* @throws LogicException For a ReleasePerVersion profile.
*/
public function __construct(private Profile $profile, private Closure $compatibility)
{
if ($profile->tier === Tier::ReleasePerVersion) {
throw new LogicException(
sprintf('profile %s has a release status and needs its own component', $profile->id)
);
}
}
/** @return list<CompatibilityComponent> One per profile except the ReleasePerVersion ones (own components). */
public static function forProfiles(Compatibility $compatibility): array
{
$profiles = array_filter(
$compatibility->profiles(),
static fn (Profile $profile): bool => $profile->tier !== Tier::ReleasePerVersion
);
return array_values(array_map(
static fn (Profile $profile): self => new self($profile, static fn (): Compatibility => $compatibility),
$profiles
));
}
/** @return array{id: string, active: bool, version: ?string, problems: list<string>, release: null} */
public function describe(): array
{
$compatibility = ($this->compatibility)();
if (! $compatibility->active($this->profile)) {
return [
'id' => $this->profile->id,
'active' => false,
'version' => null,
'problems' => [],
'release' => null,
];
}
return [
'id' => $this->profile->id,
'active' => true,
'version' => $compatibility->version($this->profile),
'problems' => $compatibility->problems($this->profile),
'release' => null,
];
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LogicException;
defined('ABSPATH') || exit;
/**
* Everything the compatibility layer needs to know about one third-party plugin: its capabilities, what its code
* depends on, how to tell whether it is active and which version it has. Pure data; Compatibility evaluates it.
*/
final class Profile
{
/**
* @param list<Capability> $capabilities
* @param list<Manifest> $manifests
* @param (Closure(): bool)|null $active Plugin installed and active; null = always.
* @param (Closure(): ?string)|null $version The installed version, null result = unknown.
* @param string|null $minVersion Lowest supported version (version_compare).
* @param (Closure(): CompatibilityMatrix)|null $matrix Required for ReleasePerVersion, unused otherwise.
* @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice.
*/
public function __construct(
public readonly string $id,
public readonly string $label,
public readonly Tier $tier,
public readonly array $capabilities,
public readonly array $manifests = [],
public readonly ?Closure $active = null,
public readonly ?Closure $version = null,
public readonly ?string $minVersion = null,
public readonly ?Closure $matrix = null
) {
if ($tier === Tier::ReleasePerVersion && $matrix === null) {
throw new LogicException(sprintf('profile %s: a ReleasePerVersion profile needs a matrix closure', $id));
}
$ids = array_map(static fn (Capability $capability): string => $capability->id(), $capabilities);
$duplicates = array_keys(array_filter(array_count_values($ids), static fn (int $n): bool => $n > 1));
if ($duplicates !== []) {
throw new LogicException(sprintf('profile %s: capability IDs twice: %s', $id, implode(', ', $duplicates)));
}
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/** An integration that describes the third-party plugin it depends on for the compatibility layer. */
interface ProvidesCompatibility
{
/** @return list<Profile> */
public function compatibilities(): array;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/** How strictly the compatibility layer treats a third-party plugin. */
enum Tier: string
{
/** Problems are only reported; nothing is ever locked. */
case ReportOnly = 'report';
/** A hard problem (or an inactive or too old plugin) locks the affected capabilities; otherwise they are free. */
case LockOnFailure = 'lock';
/** Every plugin version needs a release; the profile brings its own matrix (Divi). */
case ReleasePerVersion = 'release';
}
+6 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use Closure;
use LIT\MCP\Access;
use LIT\MCP\Guides\GuideRepository;
use LIT\MCP\Integrations\Integration;
@@ -12,8 +13,11 @@ defined('ABSPATH') || exit;
final class CoreIntegration implements Integration
{
/** @param list<EnvironmentComponent> $components What lit-site-status reports besides Pods and Events Calendar. */
public function __construct(private string $guidesDirectory, private array $components = [])
/**
* @param (Closure(): list<EnvironmentComponent>)|null $components What lit-site-status reports besides the Events
* Calendar; asked when the status is built.
*/
public function __construct(private string $guidesDirectory, private ?Closure $components = null)
{
}
+10 -12
View File
@@ -4,16 +4,21 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use Closure;
defined('ABSPATH') || exit;
/**
* Detects the themes/plugins LIT MCP integrates with: the components it is given (Divi, wired in Plugin) first, then
* Pods and The Events Calendar.
* Detects the themes/plugins LIT MCP integrates with: the components it is given (Divi, Pods; wired in Plugin) first,
* then The Events Calendar.
*/
class EnvironmentProbe
{
/** @param list<EnvironmentComponent> $components Components of integrations, reported in this order. */
public function __construct(private array $components = [])
/**
* @param (Closure(): list<EnvironmentComponent>)|null $components Components of integrations, reported in this
* order; asked per call (the registry that knows them exists later).
*/
public function __construct(private ?Closure $components = null)
{
}
@@ -26,18 +31,11 @@ class EnvironmentProbe
$tecActive = class_exists('Tribe__Events__Main');
$described = array_map(
static fn (EnvironmentComponent $component): array => $component->describe(),
$this->components
$this->components === null ? [] : ($this->components)()
);
return [
...$described,
[
'id' => 'pods',
'active' => defined('PODS_VERSION'),
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'problems' => [],
'release' => null,
],
[
'id' => 'the-events-calendar',
'active' => $tecActive,
+8 -1
View File
@@ -9,6 +9,7 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand;
@@ -75,6 +76,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
@@ -99,7 +101,7 @@ defined('ABSPATH') || exit;
* DiviWriter; each binds the composition to its entry via DiviWriter::composition().
* contentComposer() gives lit/content-create the Divi short form (spec §6.2). boot() registers Divi's own hooks.
*/
final class DiviIntegration implements Integration, Bootable
final class DiviIntegration implements Integration, Bootable, ProvidesCompatibility
{
private DiviModules $modules;
private DesignSystemReader $design;
@@ -186,6 +188,11 @@ final class DiviIntegration implements Integration, Bootable
return DiviModules::isAvailable();
}
public function compatibilities(): array
{
return [DiviProfile::profile()];
}
/**
* Divi's own post types (library, Theme Builder, canvases, speculation), which the content tools leave alone:
* ContentSchema's excluded post types. Static because the schema is built before this integration (Plugin).
@@ -6,7 +6,6 @@ namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\ReleaseState;
use WP_Error;
@@ -19,8 +18,8 @@ defined('ABSPATH') || exit;
* answers divi_incompatible / divi_untested with the affected capability instead of failing inside Divi. A refused
* write is logged with result "refused" (as before); a refused read changes nothing and is not. A tool that declares
* nothing (RequiresDiviFeatures, or DeclaresRequirements for lit/content-create without divi) is left alone; the
* adapters still check each write themselves. A declared ID that is no Divi capability (FeatureSets::unknownIds())
* locks the call as divi_incompatible with details.unknown – the lock fails closed.
* adapters still check each write themselves. IDs that are no Divi capability are ignored here; CapabilityGate
* checks them (and locks the unknown ones).
*/
final class DiviFeatureGate implements AbilityGuard
{
@@ -30,11 +29,6 @@ final class DiviFeatureGate implements AbilityGuard
public function refusal(Ability $ability, array $input): ?WP_Error
{
$unknown = FeatureSets::unknownIds($ability, $input);
if ($unknown !== []) {
return $this->refuseUnknown($ability, $unknown);
}
$lock = ReleaseGate::lockOf(FeatureSets::declaredBy($ability, $input));
if ($lock === null) {
return null;
@@ -53,34 +47,6 @@ final class DiviFeatureGate implements AbilityGuard
return $lock->toError();
}
/** @param non-empty-list<string> $unknown Declared IDs that are no Divi capability. */
private function refuseUnknown(Ability $ability, array $unknown): WP_Error
{
$status = ReleaseGate::status();
$ids = implode(', ', $unknown);
if (! self::readsOnly($ability)) {
$this->log?->record(
$ability->name(),
'divi',
$status->version,
sprintf('Abgelehnt: %s – unbekannte Fähigkeit %s.', $status->versionLabel(), $ids),
result: 'refused'
);
}
return Errors::make(
'divi_incompatible',
sprintf(
'%s: Das Werkzeug verlangt unbekannte Fähigkeiten (%s). Das ist gesperrt. Es wurde nichts geändert.',
$status->versionLabel(),
$ids
),
'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.',
423,
['unknown' => $unknown]
);
}
/** A refusal is the whole answer: there is no result to annotate (the lock of reading tools is gone, E2). */
public function annotate(Ability $ability, mixed $result): mixed
{
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
defined('ABSPATH') || exit;
/** Divi as a compatibility profile: every version needs a release, the matrix comes from the release gate. */
final class DiviProfile
{
public static function profile(): Profile
{
return new Profile(
'divi',
'Divi',
Tier::ReleasePerVersion,
DiviFeature::cases(),
array_values(DiviAdapters::manifests()),
version: DiviEnvironment::version(...),
matrix: static fn () => ReleaseGate::status()->matrix
);
}
}
+2 -21
View File
@@ -17,8 +17,8 @@ final class FeatureSets
{
/**
* The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool
* names through DeclaresRequirements (lit/content-create with divi). Anything else declares none. Every declared
* ID is a Divi ID; one that is no DiviFeature is skipped here but locks the call (unknownIds(), fail closed).
* names through DeclaresRequirements (lit/content-create with divi). Anything else declares none; an ID that is no
* DiviFeature is ignored (CapabilityGate checks the IDs of other integrations and locks unknown ones).
*
* @param array<string, mixed> $input
* @return list<DiviFeature>
@@ -44,25 +44,6 @@ final class FeatureSets
return self::merge($features);
}
/**
* The IDs a core tool declares for $input that are no DiviFeature. Such a call cannot be judged, so
* DiviFeatureGate refuses it instead of letting it run unlocked.
*
* @param array<string, mixed> $input
* @return list<string>
*/
public static function unknownIds(Ability $ability, array $input): array
{
if ($ability instanceof RequiresDiviFeatures || ! $ability instanceof DeclaresRequirements) {
return [];
}
return array_values(array_filter(
$ability->requirements($input),
static fn (string $id): bool => DiviFeature::tryFrom($id) === null
));
}
/**
* @param list<DiviFeature> ...$lists
* @return list<DiviFeature> Every capability once, in the order of first appearance.
+33 -1
View File
@@ -5,6 +5,10 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LogicException;
defined('ABSPATH') || exit;
@@ -17,6 +21,7 @@ final class IntegrationRegistry
private ?array $loaded = null;
private bool $booted = false;
private ?Compatibility $compatibility = null;
public function __construct(Integration ...$integrations)
{
@@ -63,7 +68,8 @@ final class IntegrationRegistry
}
/**
* Abilities of the available integrations, grouped by integration id (registry order).
* Abilities of the available integrations, grouped by integration id (registry order). An integration without
* abilities (Pods) is no group.
*
* @return array<string, list<Ability>>
*/
@@ -71,6 +77,9 @@ final class IntegrationRegistry
{
$groups = [];
foreach ($this->loaded() as [$integration, $own]) {
if ($own === []) {
continue;
}
$groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own);
}
@@ -101,6 +110,29 @@ final class IntegrationRegistry
return $labels;
}
/** @return list<Profile> Profiles of every registered integration that provides some, available or not. */
public function compatibilities(): array
{
$profiles = [];
foreach ($this->integrations as $integration) {
if ($integration instanceof ProvidesCompatibility) {
array_push($profiles, ...$integration->compatibilities());
}
}
return $profiles;
}
/**
* The compatibility state of all profiles, one shared instance (its matrices are memoised per request).
*
* @throws LogicException When two profiles or capabilities share an ID (a programming error, PluginTest).
*/
public function compatibility(): Compatibility
{
return $this->compatibility ??= new Compatibility($this->compatibilities());
}
/** @return list<string> Ids of all known integrations, available or not. */
public function ids(): array
{
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\ConstantRequirement;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
defined('ABSPATH') || exit;
/**
* Pods as a compatibility profile: the calls PodsFieldSource makes, checked against Pods 3.3.9.2. Argument counts
* are those PodsFieldSource passes. A hard problem (missing function or constant, Pods below 2.8) locks the
* affected capabilities; Pods has no per-version release.
*/
final class PodsCompatibility
{
public static function profile(): Profile
{
$all = PodsFeature::cases();
return new Profile(
'pods',
'Pods',
Tier::LockOnFailure,
$all,
[new Manifest(
'Pods',
[
new Call('pods_api', 0, [PodsFeature::SchemaRead]),
new Call('PodsAPI::load_pod', 1, [PodsFeature::SchemaRead]),
new Call('pods', 2, [PodsFeature::FieldRead, PodsFeature::FieldWrite]),
new Call('Pods::field', 1, [PodsFeature::FieldRead]),
new Call('Pods::save', 4, [PodsFeature::FieldWrite]),
new Call('pods_has_permissions', 1, [PodsFeature::FieldPermissions]),
new Call('pods_permission', 1, [PodsFeature::FieldPermissions]),
],
[new ConstantRequirement('PODS_VERSION', $all)]
)],
static fn (): bool => defined('PODS_VERSION'),
static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'2.8'
);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Compatibility\Capability;
defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
/** What the plugin can do with Pods, as far as a Pods update may break it separately. */
enum PodsFeature: string implements Capability
{
case SchemaRead = 'pods-schema-read';
case FieldRead = 'pods-field-read';
case FieldWrite = 'pods-field-write';
case FieldPermissions = 'pods-field-permissions';
public function id(): string
{
return $this->value;
}
public function label(): string
{
return match ($this) {
self::SchemaRead => 'Pods-Schema lesen',
self::FieldRead => 'Pods-Felder lesen',
self::FieldWrite => 'Pods-Felder schreiben',
self::FieldPermissions => 'Pods-Feldrechte prüfen',
};
}
public function isWrite(): bool
{
return $this === self::FieldWrite;
}
}
+36 -9
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use Closure;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use RuntimeException;
@@ -21,6 +22,10 @@ defined('ABSPATH') || exit;
* They are mapped like Pods' own view does: hidden → HIDDEN; read_only → READONLY; restricted
* and not permitted for the current user → READONLY with read_only_restricted, else HIDDEN.
* write() refuses every field that is not writable before anything reaches Pods.
*
* Every part is gated by a compatibility capability ($usable, from Compatibility::usable): the schema (SchemaRead,
* else no fields), reading (FieldRead, else null), writing (FieldWrite and FieldRead, else write() throws and the
* fields are READONLY) and the permission check (FieldPermissions, else no field counts as permitted).
*/
final class PodsFieldSource implements FieldSource
{
@@ -45,8 +50,13 @@ final class PodsFieldSource implements FieldSource
/** @var callable(object): ?bool Pods field => null (unrestricted) or whether the current user may use it. */
private $permitted;
public function __construct(?callable $loadPod = null, ?callable $podFor = null, ?callable $permitted = null)
{
/** @param Closure(PodsFeature): bool $usable Whether a Pods capability may be used (a locked one is never called). */
public function __construct(
private Closure $usable,
?callable $loadPod = null,
?callable $podFor = null,
?callable $permitted = null
) {
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
$this->podFor = $podFor ?? static fn (string $name, int $id): mixed => pods($name, $id);
$this->permitted = $permitted ?? static function (object $field): ?bool {
@@ -59,11 +69,6 @@ final class PodsFieldSource implements FieldSource
};
}
public static function isAvailable(): bool
{
return function_exists('pods_api') && function_exists('pods');
}
public function id(): string
{
return 'pods';
@@ -71,6 +76,10 @@ final class PodsFieldSource implements FieldSource
public function fieldsFor(string $postType): array
{
if (! ($this->usable)(PodsFeature::SchemaRead)) {
return [];
}
$pod = ($this->loadPod)($postType);
if (! is_object($pod) || $pod->get_type() !== 'post_type') {
return [];
@@ -91,6 +100,10 @@ final class PodsFieldSource implements FieldSource
public function read(int $postId, FieldDefinition $field): mixed
{
if (! ($this->usable)(PodsFeature::FieldRead)) {
return null;
}
$pod = ($this->podFor)((string) get_post_type($postId), $postId);
if (! is_object($pod)) {
return null;
@@ -110,6 +123,10 @@ final class PodsFieldSource implements FieldSource
public function write(int $postId, string $postType, array $values, array $definitions): void
{
if (! $this->canWrite()) {
throw new RuntimeException('Pods fields cannot be written: Pods is locked by the compatibility check.');
}
$data = [];
foreach ($values as $key => $value) {
$status = isset($definitions[$key]) ? $definitions[$key]->status : null;
@@ -180,11 +197,21 @@ final class PodsFieldSource implements FieldSource
return FieldDefinition::HIDDEN;
}
if (($this->permitted)($field) === false) {
// Fail closed: without the permission check no field counts as permitted.
if (! ($this->usable)(PodsFeature::FieldPermissions) || ($this->permitted)($field) === false) {
return self::flag($field, 'read_only_restricted') ? FieldDefinition::READONLY : FieldDefinition::HIDDEN;
}
return self::flag($field, 'read_only') ? FieldDefinition::READONLY : $open;
$status = self::flag($field, 'read_only') ? FieldDefinition::READONLY : $open;
// Pods writing is locked (it needs FieldWrite and FieldRead) → nothing is writable.
return $status !== FieldDefinition::HIDDEN && ! $this->canWrite() ? FieldDefinition::READONLY : $status;
}
/** A write snapshots the value first (read), so FieldRead is as necessary as FieldWrite. */
private function canWrite(): bool
{
return ($this->usable)(PodsFeature::FieldWrite) && ($this->usable)(PodsFeature::FieldRead);
}
/** A Pods boolean option (stored as 1/'1'/true, like pods_v_bool()). */
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit;
/**
* Pods has no tools of its own (the content tools read its fields through PodsFieldSource); this integration only
* brings the compatibility profile. Without abilities it is no tool group.
*/
final class PodsIntegration implements Integration, ProvidesCompatibility
{
public function id(): string
{
return 'pods';
}
public function label(): string
{
return 'Pods';
}
public function isAvailable(): bool
{
return defined('PODS_VERSION');
}
public function abilities(): array
{
return [];
}
public function compatibilities(): array
{
return [PodsCompatibility::profile()];
}
}
+67 -14
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP;
use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
@@ -21,14 +22,21 @@ use LIT\MCP\Admin\ToolsTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Compatibility\CapabilityGate;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityComponent;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration;
use LIT\MCP\Integrations\Pods\PodsCompatibility;
use LIT\MCP\Integrations\Pods\PodsFeature;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\Pods\PodsIntegration;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
@@ -87,11 +95,7 @@ final class Plugin
// page reads its tabs.
$registry->boot();
(new AbilityRegistrar(
$registry,
$policy,
[new DiviFeatureGate($changeLog)]
))->register();
(new AbilityRegistrar($registry, $policy, self::guards($registry, $changeLog)))->register();
(new ServerRegistrar(
$registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy),
@@ -125,6 +129,25 @@ final class Plugin
\WPMedia\MCP\OAuth\Bootstrap::instance();
}
/**
* The guards of every tool call. DiviFeatureGate judges the Divi IDs; CapabilityGate the IDs of all other profiles
* and locks the unknown ones – without it a tool with an unknown ID would run unlocked.
*
* @return list<AbilityGuard>
* @internal Public for PluginTest only.
*/
public static function guards(IntegrationRegistry $registry, ChangeLog $changeLog): array
{
return [
new DiviFeatureGate($changeLog),
new CapabilityGate(
$registry->compatibility(),
$changeLog,
['divi'] // checked by DiviFeatureGate
),
];
}
/**
* 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.
@@ -145,8 +168,20 @@ final class Plugin
};
$divi = new DiviIntegration($schema, $changeLog, $tools, $allAbilities);
// lit-site-status: Divi first (its own component), then every other profile in registry order.
$components = static function () use (&$registry, $divi): array {
if (! $registry instanceof IntegrationRegistry) {
return [$divi->environmentComponent()];
}
return [
$divi->environmentComponent(),
...CompatibilityComponent::forProfiles($registry->compatibility()),
];
};
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
new CoreIntegration(self::pluginDir() . '/docs/guides', $components),
new WordPressIntegration(
$schema,
$changeLog,
@@ -157,7 +192,8 @@ final class Plugin
new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()),
$divi,
$divi->library(),
$divi->themeBuilder()
$divi->themeBuilder(),
new PodsIntegration()
);
return $registry;
@@ -192,13 +228,12 @@ final class Plugin
private static function contentSchema(): ContentSchema
{
// Pods active but unusable: its meta keys (sampled, or registered via Pods' register_meta integration) must not
// be written past Pods.
$podsUnusable = defined('PODS_VERSION') && ! PodsFieldSource::isAvailable();
$sources = [new RegisteredMetaSource(readOnly: $podsUnusable), new SampledMetaSource(readOnly: $podsUnusable)];
if (PodsFieldSource::isAvailable()) {
array_unshift($sources, new PodsFieldSource());
}
// The sources exist before the registry, so Pods gets its own Compatibility here – a deliberate, cheap
// duplicate of the registry's (a few function_exists checks, memoised per request).
$sources = self::schemaSources(
new Compatibility([PodsCompatibility::profile()]),
defined('PODS_VERSION')
);
// Divi's post types and builder check come in statically: the schema exists before the Divi integration.
return new ContentSchema(
@@ -207,4 +242,22 @@ final class Plugin
DiviIntegration::internalPostTypes()
);
}
/**
* The field sources, highest priority first. Pods active but its schema locked: PodsFieldSource yields no fields
* and the meta sources report the keys read-only, so they cannot be written past Pods.
*
* @return list<FieldSource>
* @internal Public for PluginTest only.
*/
public static function schemaSources(Compatibility $pods, bool $podsActive): array
{
$readOnly = $podsActive && ! $pods->usable(PodsFeature::SchemaRead);
$sources = [new RegisteredMetaSource(readOnly: $readOnly), new SampledMetaSource(readOnly: $readOnly)];
if ($podsActive) {
array_unshift($sources, new PodsFieldSource($pods->usable(...)));
}
return $sources;
}
}