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:
co-authored by
Claude Opus 5.5
parent
b511ef6348
commit
c85131603b
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
))),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user