feat(pods): Verhaltensprobe als Prüfschritt
Beim Prüfen schreibt und liest PodsProbe auf einem Wegwerf-Entwurf ein Textfeld genau so, wie LIT MCP es tut, vergleicht die Werte und löscht den Entwurf wieder. Bevorzugt von Pods angelegte Typen; ohne Pod mit Textfeld gilt der Schritt als grün mit Hinweis „nicht geprüft“. Ein roter Schritt sperrt Pods über die Berichtssperre. Liegengebliebene Probe-Einträge räumt der Load-Hook des Reiters auf. Pods-Smoke um Probe und Bereinigung erweitert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
42aa4f8dc9
commit
1a294b7881
Generated
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Compatibility\CheckStep;
|
||||
use LIT\MCP\Compatibility\CompatibilityCheck;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** The check step only Pods has (the behaviour probe, PodsProbe) and its labels. */
|
||||
final class PodsCheckSteps
|
||||
{
|
||||
public const PROBE = 'probe';
|
||||
|
||||
private const LABEL = 'Lese- und Schreibtest an einem Wegwerf-Eintrag';
|
||||
private const LABEL_NO_TARGET = self::LABEL . ' (kein Pod mit Textfeld vorhanden, nicht geprüft)';
|
||||
|
||||
/**
|
||||
* The Profile::$extraSteps of Pods: the probe, skipped while the manifest is red (it would fail anyway). No pod
|
||||
* to probe (null) is a green step that says so, not a skipped one: a skipped step makes the report red and
|
||||
* would lock Pods on a site that simply has no pod yet.
|
||||
*
|
||||
* @param Closure(): (list<string>|null) $probe PodsProbe::run().
|
||||
* @return Closure(bool): list<CheckStep>
|
||||
*/
|
||||
public static function extra(Closure $probe): Closure
|
||||
{
|
||||
return static function (bool $manifestClean) use ($probe): array {
|
||||
if (! $manifestClean) {
|
||||
return [new CheckStep(self::PROBE, [], true, self::LABEL)];
|
||||
}
|
||||
|
||||
$none = false;
|
||||
$problems = CompatibilityCheck::guarded(static function () use ($probe, &$none): array {
|
||||
$result = $probe();
|
||||
$none = $result === null;
|
||||
|
||||
return $result ?? [];
|
||||
});
|
||||
|
||||
return [new CheckStep(self::PROBE, $problems, false, $none ? self::LABEL_NO_TARGET : self::LABEL)];
|
||||
};
|
||||
}
|
||||
|
||||
public static function label(CheckStep $step): string
|
||||
{
|
||||
// Reports saved before labels were stored have none.
|
||||
return $step->label
|
||||
?? ($step->key === self::PROBE ? self::LABEL : null)
|
||||
?? CheckStep::genericLabel($step->key, PodsCompatibility::LABEL)
|
||||
?? $step->key;
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -14,18 +14,20 @@ 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.
|
||||
* are those PodsFieldSource passes; the check adds a behaviour probe (PodsProbe). 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 const LABEL = 'Pods';
|
||||
|
||||
public static function profile(): Profile
|
||||
{
|
||||
$all = PodsFeature::cases();
|
||||
|
||||
return new Profile(
|
||||
id: 'pods',
|
||||
label: 'Pods',
|
||||
label: self::LABEL,
|
||||
tier: Tier::LockOnFailure,
|
||||
capabilities: $all,
|
||||
manifests: [new Manifest(
|
||||
@@ -44,7 +46,11 @@ final class PodsCompatibility
|
||||
active: static fn (): bool => defined('PODS_VERSION'),
|
||||
version: static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
||||
minVersion: '2.8',
|
||||
testedVersions: ['3.3.9.2']
|
||||
testedVersions: ['3.3.9.2'],
|
||||
extraSteps: PodsCheckSteps::extra(
|
||||
static fn (): ?array => (new PodsProbe(new PodsFieldSource(static fn (): bool => true)))->run()
|
||||
),
|
||||
stepLabel: PodsCheckSteps::label(...)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-3
@@ -4,17 +4,45 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Admin\CompatibilityTab;
|
||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||
use LIT\MCP\Integrations\Bootable;
|
||||
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.
|
||||
* brings the compatibility profile and the cleanup of probe entries a dead check left behind. Without abilities it
|
||||
* is no tool group.
|
||||
*/
|
||||
final class PodsIntegration implements Integration, ProvidesCompatibility
|
||||
final class PodsIntegration implements Integration, ProvidesCompatibility, Bootable
|
||||
{
|
||||
/** @var callable(): bool */
|
||||
private $available;
|
||||
|
||||
/** @param callable(): bool|null $available Whether Pods is loaded; null = PODS_VERSION is defined. */
|
||||
public function __construct(?callable $available = null)
|
||||
{
|
||||
$this->available = $available ?? static fn (): bool => defined('PODS_VERSION');
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
add_action(CompatibilityTab::LOAD_ACTION, [$this, 'removeOrphans']);
|
||||
}
|
||||
|
||||
/**
|
||||
* lit_mcp_compat_profile_load('pods'), before any output: deletes probe entries a dead check left behind
|
||||
* (PodsProbe::removeOrphans()). A GET without nonce on purpose: only the plugin's own leftovers, idempotent.
|
||||
*/
|
||||
public function removeOrphans(string $profileId): void
|
||||
{
|
||||
if ($profileId === 'pods' && $this->isAvailable() && current_user_can('manage_options')) {
|
||||
PodsProbe::removeOrphans();
|
||||
}
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return 'pods';
|
||||
@@ -27,7 +55,7 @@ final class PodsIntegration implements Integration, ProvidesCompatibility
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return defined('PODS_VERSION');
|
||||
return ($this->available)();
|
||||
}
|
||||
|
||||
public function abilities(): array
|
||||
|
||||
Generated
+177
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Pods;
|
||||
|
||||
use LIT\MCP\Compatibility\ThrowawayPost;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Log;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Behaviour probe of the Pods check: through PodsFieldSource (always usable here – the probe is what frees a locked
|
||||
* profile again) it loads the schema of an existing pod, writes a plain text field of a throwaway draft entry,
|
||||
* reads it back and compares. The pod and its schema are never touched (no save_pod, so no rewrite flush, no
|
||||
* schema cache change); only the entry is created and always deleted for good (ThrowawayPost). Entries a dead
|
||||
* check left behind carry MARKER; removeOrphans() deletes them. Runs only from the check route
|
||||
* (Compatibility::check).
|
||||
*
|
||||
* Target: a pod of a post type Pods created itself (pod arg 'object' empty) comes first, then a foreign type Pods
|
||||
* only extends (object = the type, e.g. an events plugin), last post and page. Creating and deleting the entry
|
||||
* fires the hooks of other plugins on that type (unavoidable with wp_insert_post/wp_delete_post), and Pods may
|
||||
* check other required fields on a partial save than the plain text field the probe writes.
|
||||
*
|
||||
* shortcut: relations (output ids) are not probed – they need a second pod entry; add when a Pods update breaks them.
|
||||
*/
|
||||
final class PodsProbe
|
||||
{
|
||||
public const MARKER = '_lit_mcp_check_entry';
|
||||
|
||||
public const TITLE = '[LIT MCP Prüfung]';
|
||||
|
||||
public const VALUE = 'litcheck-pods-probe';
|
||||
|
||||
private const ABORTED = 'Pods-Probe abgebrochen (Details im PHP-Error-Log).';
|
||||
|
||||
private const RANK_OWN = 0;
|
||||
private const RANK_EXTENDED = 1;
|
||||
private const RANK_CORE = 2;
|
||||
|
||||
/** @var callable(string): mixed */
|
||||
private $loadPod;
|
||||
|
||||
/** @var callable(): list<string> */
|
||||
private $postTypes;
|
||||
|
||||
/**
|
||||
* @param callable(string): mixed|null $loadPod Pods schema of a post type (raw field types).
|
||||
* @param callable(): list<string>|null $postTypes Post types to look at; the best-ranked target wins, then the
|
||||
* first of them.
|
||||
*/
|
||||
public function __construct(
|
||||
private PodsFieldSource $source,
|
||||
?callable $loadPod = null,
|
||||
?callable $postTypes = null
|
||||
) {
|
||||
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
|
||||
$this->postTypes = $postTypes ?? static fn (): array => array_values(array_unique([
|
||||
...array_values(get_post_types(['_builtin' => false])),
|
||||
'post',
|
||||
'page',
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return list<string>|null German problems, [] = green; null = no pod with a plain text field to probe. */
|
||||
public function run(): ?array
|
||||
{
|
||||
try {
|
||||
$target = $this->target();
|
||||
} catch (Throwable $e) {
|
||||
Log::failure('pods probe', $e);
|
||||
|
||||
return [self::ABORTED];
|
||||
}
|
||||
if ($target === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$postType, $field] = $target;
|
||||
|
||||
return self::throwaway()->run($postType, fn (int $id): array => $this->exercise($id, $postType, $field));
|
||||
}
|
||||
|
||||
/** @return list<int> IDs of probe entries a dead check left behind, deleted now. */
|
||||
public static function removeOrphans(): array
|
||||
{
|
||||
return self::throwaway()->removeOrphans(array_values(get_post_types()));
|
||||
}
|
||||
|
||||
private static function throwaway(): ThrowawayPost
|
||||
{
|
||||
return new ThrowawayPost(
|
||||
self::MARKER,
|
||||
self::TITLE,
|
||||
self::ABORTED,
|
||||
'Der Probe-Eintrag konnte nicht angelegt werden (Details im PHP-Error-Log).',
|
||||
'Der Probe-Eintrag %d konnte nicht gelöscht werden; bitte endgültig löschen (Titel „'
|
||||
. self::TITLE . ' …“).',
|
||||
'pods probe'
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: FieldDefinition}|null Post type and field of the best-ranked text pod. */
|
||||
private function target(): ?array
|
||||
{
|
||||
$best = null;
|
||||
$bestRank = PHP_INT_MAX;
|
||||
foreach (($this->postTypes)() as $postType) {
|
||||
$pod = ($this->loadPod)($postType);
|
||||
$field = is_object($pod) ? $this->textField($postType, $pod) : null;
|
||||
if ($field === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rank = self::rank($postType, $pod);
|
||||
if ($rank === self::RANK_OWN) {
|
||||
return [$postType, $field];
|
||||
}
|
||||
if ($rank < $bestRank) {
|
||||
[$best, $bestRank] = [[$postType, $field], $rank];
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
private static function rank(string $postType, object $pod): int
|
||||
{
|
||||
if ((string) $pod->get_arg('object', '') === '') {
|
||||
return self::RANK_OWN;
|
||||
}
|
||||
|
||||
return in_array($postType, ['post', 'page'], true) ? self::RANK_CORE : self::RANK_EXTENDED;
|
||||
}
|
||||
|
||||
private function textField(string $postType, object $pod): ?FieldDefinition
|
||||
{
|
||||
$definitions = [];
|
||||
foreach ($this->source->fieldsFor($postType) as $definition) {
|
||||
$definitions[$definition->key] = $definition;
|
||||
}
|
||||
foreach ($pod->get_fields() as $field) {
|
||||
// Raw type 'text' only: email, slug, website … validate or rewrite the value.
|
||||
$definition = $definitions[(string) $field->get_name()] ?? null;
|
||||
if (
|
||||
$definition !== null && (string) $field->get_type() === 'text'
|
||||
&& $definition->status === FieldDefinition::WRITABLE
|
||||
&& ! $definition->multiple
|
||||
) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function exercise(int $id, string $postType, FieldDefinition $field): array
|
||||
{
|
||||
$this->source->write($id, $postType, [$field->key => self::VALUE], [$field->key => $field]);
|
||||
$read = $this->source->read($id, $field);
|
||||
if ($read === self::VALUE) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The field name and the value stay in the log: the report reaches MCP clients.
|
||||
Log::line(sprintf(
|
||||
'pods probe: field %s read back %s instead of the written value.',
|
||||
$field->key,
|
||||
is_scalar($read) ? '"' . $read . '"' : gettype($read)
|
||||
));
|
||||
|
||||
return ['Gelesener Wert weicht vom geschriebenen ab.'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user