feat: field validation, term resolution, post guard and revision points

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-25 22:29:15 +02:00
co-authored by Claude Opus 5.5
parent 98a910f056
commit 95aae74466
11 changed files with 748 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* Loads entries and post types for abilities, including the capability checks
* of spec §4.5. Every error is ready to be returned from permission()/execute().
* Input is not schema-validated yet when permission() runs – hence the defensive id().
*/
final class PostGuard
{
public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error
{
$id = self::id($postId);
if ($id === null) {
return Errors::invalidInput(
sprintf('%s fehlt oder ist ungültig.', $field),
[$field => 'positive Ganzzahl erwartet']
);
}
$post = get_post($id);
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
}
return $post;
}
/**
* The brief declares this `: object|WP_Error`, but `object` already covers every
* class type, so PHP refuses `object` inside a union ("Type object cannot be
* part of a union type" – fatal at compile time). WP_Error is itself an object,
* so a plain `object` return type is accurate and still lets callers branch with
* `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above.
*/
public static function postType(mixed $postType, ContentSchema $schema, string $cap): object
{
$name = is_string($postType) ? $postType : '';
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
if (! is_object($object)) {
return Errors::notFound(
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
'Verfügbare Inhaltstypen über lit-content-types abrufen.'
);
}
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
if (! current_user_can($capability)) {
return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $name));
}
return $object;
}
public static function id(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_string($value) && ctype_digit($value) && (int) $value > 0) {
return (int) $value;
}
return null;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
/**
* Restore point before a change (spec §2 principle 7): WordPress stores the
* current state as a revision (only if it differs from the newest one) and the
* newest revision ID is returned as revision_id of the ToolResult.
* Note: revisions cover title, content and excerpt; meta and terms are not revisioned.
*/
final class RevisionPoint
{
public static function capture(int $postId): ?int
{
wp_save_post_revision($postId);
return self::latest($postId);
}
public static function latest(int $postId): ?int
{
$revisions = wp_get_post_revisions($postId, ['posts_per_page' => 1]);
$newest = is_array($revisions) ? reset($revisions) : false;
return is_object($newest) ? (int) $newest->ID : null;
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use DateTimeImmutable;
/**
* Checks field values against the detected schema before anything is written.
*/
final class FieldValidator
{
private const LIST_TYPES = ['relation', 'file', 'choice', 'array'];
/**
* @param array<string, FieldDefinition> $definitions
* @param array<string, mixed> $values
* @return array{values: array<string, mixed>, errors: array<string, string>, warnings: list<string>}
*/
public function validate(array $definitions, array $values, bool $isCreate): array
{
$clean = [];
$errors = [];
$warnings = [];
foreach ($values as $key => $value) {
$key = (string) $key;
$field = $definitions[$key] ?? null;
if ($field === null) {
$errors[$key] = 'Unbekanntes Feld; verfügbare Felder über lit-content-types abrufen.';
continue;
}
if ($field->status === FieldDefinition::READONLY) {
$errors[$key] = 'Feld ist schreibgeschützt.';
continue;
}
$error = $value === null ? null : $this->check($field, $value);
if ($error !== null) {
$errors[$key] = $error;
continue;
}
if ($field->status === FieldDefinition::UNKNOWN) {
$warnings[] = sprintf(
'Feld "%s" hat keinen bekannten Typ; der Wert wurde unverändert gespeichert.',
$key
);
}
$clean[$key] = $value;
}
foreach ($definitions as $key => $field) {
if (! $field->required || isset($errors[$key])) {
continue;
}
$provided = array_key_exists($key, $clean);
if ($isCreate && (! $provided || $this->isEmpty($clean[$key]))) {
$errors[$key] = 'Pflichtfeld fehlt.';
} elseif (! $isCreate && $provided && $this->isEmpty($clean[$key])) {
$errors[$key] = 'Pflichtfeld darf nicht geleert werden.';
}
}
return ['values' => $clean, 'errors' => $errors, 'warnings' => $warnings];
}
private function check(FieldDefinition $field, mixed $value): ?string
{
if ($field->multiple && in_array($field->type, self::LIST_TYPES, true)) {
if (! is_array($value) || ! array_is_list($value)) {
return 'Liste erwartet.';
}
if ($field->type === 'array') {
return null;
}
foreach ($value as $item) {
$error = $this->checkScalar($field, $item);
if ($error !== null) {
return $error;
}
}
return null;
}
return $this->checkScalar($field, $value);
}
private function checkScalar(FieldDefinition $field, mixed $value): ?string
{
switch ($field->type) {
case 'string':
case 'text':
return is_string($value) ? null : 'Text erwartet.';
case 'integer':
return is_int($value) ? null : 'Ganzzahl erwartet.';
case 'number':
return (is_int($value) || is_float($value)) ? null : 'Zahl erwartet.';
case 'boolean':
return is_bool($value) ? null : 'true oder false erwartet.';
case 'date':
return $this->matchesFormat($value, 'Y-m-d') ? null : 'Datum im Format JJJJ-MM-TT erwartet.';
case 'datetime':
return $this->matchesFormat($value, 'Y-m-d H:i:s')
? null
: 'Datum und Uhrzeit im Format JJJJ-MM-TT HH:MM:SS erwartet.';
case 'time':
return $this->matchesFormat($value, 'H:i:s') ? null : 'Uhrzeit im Format HH:MM:SS erwartet.';
case 'choice':
return $this->checkChoice($field, $value);
case 'relation':
case 'file':
return $this->checkRelation($field, $value);
case 'array':
return (is_array($value) && array_is_list($value)) ? null : 'Liste erwartet.';
case 'object':
return is_array($value) ? null : 'Objekt erwartet.';
default:
return null;
}
}
private function checkChoice(FieldDefinition $field, mixed $value): ?string
{
$allowed = array_column($field->choices ?? [], 'value');
return is_string($value) && in_array($value, $allowed, true)
? null
: sprintf('Erlaubte Werte: %s.', implode(', ', $allowed));
}
private function checkRelation(FieldDefinition $field, mixed $value): ?string
{
if (! is_int($value) || $value <= 0) {
return 'ID (positive Ganzzahl) erwartet.';
}
$objectType = $field->related['object_type'] ?? '';
if ($objectType === 'media') {
return get_post_type($value) === 'attachment'
? null
: sprintf('%d ist kein Medium der Mediathek.', $value);
}
if ($objectType === 'post_type') {
$actual = get_post_type($value);
if ($actual === false) {
return sprintf('Eintrag %d existiert nicht.', $value);
}
$expected = $field->related['name'] ?? null;
if ($expected !== null && $actual !== $expected) {
return sprintf('Eintrag %d ist vom Typ %s, erwartet %s.', $value, $actual, $expected);
}
}
return null;
}
private function matchesFormat(mixed $value, string $format): bool
{
if (! is_string($value)) {
return false;
}
$parsed = DateTimeImmutable::createFromFormat('!' . $format, $value);
return $parsed !== false && $parsed->format($format) === $value;
}
private function isEmpty(mixed $value): bool
{
return $value === null || $value === '' || $value === [];
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use RuntimeException;
use WP_Term;
/**
* Resolves term input ({taxonomy: [id | name, …]}) and replaces a post's terms.
* resolve() never writes, so a whole call can be validated first (all or nothing).
*/
final class TermAssigner
{
/**
* @param array<string, mixed> $terms
* @return array{terms: array<string, list<int|string>>, errors: array<string, string>}
*/
public function resolve(string $postType, array $terms): array
{
$allowed = get_object_taxonomies($postType, 'names');
$resolved = [];
$errors = [];
foreach ($terms as $taxonomy => $items) {
$taxonomy = (string) $taxonomy;
if (! in_array($taxonomy, $allowed, true)) {
$errors[$taxonomy] = 'Taxonomie gehört nicht zu diesem Inhaltstyp.';
continue;
}
$object = get_taxonomy($taxonomy);
if (! is_object($object) || ! current_user_can($object->cap->assign_terms)) {
$errors[$taxonomy] = 'Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.';
continue;
}
$result = $this->resolveItems($taxonomy, is_array($items) ? $items : [$items], $object);
if (is_string($result)) {
$errors[$taxonomy] = $result;
continue;
}
$resolved[$taxonomy] = $result;
}
return ['terms' => $resolved, 'errors' => $errors];
}
/**
* @param array<string, list<int|string>> $resolved Output of resolve().
* @throws RuntimeException
*/
public function assign(int $postId, array $resolved): void
{
foreach ($resolved as $taxonomy => $items) {
$ids = [];
foreach ($items as $item) {
if (is_int($item)) {
$ids[] = $item;
continue;
}
$created = wp_insert_term($item, $taxonomy);
if (is_wp_error($created)) {
throw new RuntimeException('Term could not be created: ' . $created->get_error_message());
}
$ids[] = (int) $created['term_id'];
}
$result = wp_set_object_terms($postId, $ids, $taxonomy, false);
if (is_wp_error($result)) {
throw new RuntimeException('Terms could not be assigned: ' . $result->get_error_message());
}
}
}
/**
* @param list<mixed> $items
* @return list<int|string>|string Resolved items or an error message.
*/
private function resolveItems(string $taxonomy, array $items, object $object): array|string
{
$resolved = [];
foreach ($items as $item) {
if (is_int($item)) {
if (! get_term($item, $taxonomy) instanceof WP_Term) {
return sprintf('Begriff %d existiert nicht.', $item);
}
$resolved[] = $item;
continue;
}
if (! is_string($item) || trim($item) === '') {
return 'Begriffe als ID (Zahl) oder Name (Text) angeben.';
}
$existing = term_exists(trim($item), $taxonomy);
if (is_array($existing)) {
$resolved[] = (int) $existing['term_id'];
continue;
}
if (! current_user_can($object->cap->edit_terms)) {
return sprintf('Begriff "%s" existiert nicht; keine Berechtigung, ihn anzulegen.', trim($item));
}
$resolved[] = trim($item);
}
return $resolved;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
if (! class_exists('WP_Post')) {
/**
* Minimal WP_Post: untyped public properties like the real class.
*/
class WP_Post
{
public $ID = 0;
public $post_type = 'post';
public $post_status = 'draft';
public $post_title = '';
public $post_content = '';
public $post_excerpt = '';
public $post_name = '';
public $post_parent = 0;
public $menu_order = 0;
public $post_author = '1';
public $post_date = '2026-09-25 10:00:00';
public $post_date_gmt = '2026-09-25 08:00:00';
public $post_modified = '2026-09-25 10:00:00';
public $post_mime_type = '';
public $comment_status = 'closed';
public $ping_status = 'closed';
public $post_password = '';
/** @param array<string, mixed> $fields */
public function __construct(array $fields = [])
{
foreach ($fields as $key => $value) {
$this->{$key} = $value;
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
if (! class_exists('WP_Term')) {
class WP_Term
{
public $term_id = 0;
public $name = '';
public $slug = '';
public $taxonomy = '';
/** @param array<string, mixed> $fields */
public function __construct(array $fields = [])
{
foreach ($fields as $key => $value) {
$this->{$key} = $value;
}
}
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PostGuardTest extends TestCase
{
private function schema(): ContentSchema
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturnUsing(static fn (string $type): bool => in_array($type, ['page', 'post'], true));
return $schema;
}
public function testRejectsInvalidIds(): void
{
self::assertSame('lit_validation_failed', PostGuard::editable('abc', $this->schema())->get_error_code());
self::assertSame('lit_validation_failed', PostGuard::editable(-3, $this->schema())->get_error_code());
}
public function testMissingOrUnmanagedPostsAreNotFound(): void
{
Functions\when('get_post')->alias(static fn (int $id) => $id === 8 ? new WP_Post(['ID' => 8, 'post_type' => 'et_pb_layout']) : null);
self::assertSame('lit_not_found', PostGuard::editable(5, $this->schema())->get_error_code());
self::assertSame('lit_not_found', PostGuard::editable(8, $this->schema())->get_error_code());
}
public function testRequiresEditRightsAndReturnsThePost(): void
{
$post = new WP_Post(['ID' => 5, 'post_type' => 'page']);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id === 5);
self::assertSame($post, PostGuard::editable('5', $this->schema()));
Functions\when('current_user_can')->justReturn(false);
self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code());
}
public function testPostTypeChecksAvailabilityAndCapability(): void
{
$object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']];
Functions\when('get_post_type_object')->justReturn($object);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_pages');
self::assertSame('lit_not_found', PostGuard::postType('et_pb_layout', $this->schema(), 'create_posts')->get_error_code());
self::assertSame('lit_not_found', PostGuard::postType(null, $this->schema(), 'create_posts')->get_error_code());
self::assertSame($object, PostGuard::postType('page', $this->schema(), 'create_posts'));
Functions\when('current_user_can')->justReturn(false);
self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code());
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class RevisionPointTest extends TestCase
{
public function testCaptureSavesARevisionAndReturnsTheNewest(): void
{
$order = [];
Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) {
$order[] = 'save';
return 811;
});
Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 1])
->andReturnUsing(static function () use (&$order): array {
$order[] = 'latest';
return [811 => new WP_Post(['ID' => 811, 'post_type' => 'revision'])];
});
self::assertSame(811, RevisionPoint::capture(5));
self::assertSame(['save', 'latest'], $order);
}
public function testLatestIsNullWithoutRevisions(): void
{
Functions\when('wp_get_post_revisions')->justReturn([]);
self::assertNull(RevisionPoint::latest(5));
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Tests\Unit\TestCase;
final class FieldValidatorTest extends TestCase
{
/** @return array<string, FieldDefinition> */
private function definitions(): array
{
return [
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
'level' => new FieldDefinition('level', 'Stufe', 'choice', 'pods', 'writable', false, false, [
['value' => 'junior', 'label' => 'Junior'],
['value' => 'senior', 'label' => 'Senior'],
]),
'mentor' => new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable', false, false, null, [
'object_type' => 'post_type',
'name' => 'lit_person',
]),
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true, null, [
'object_type' => 'media',
'name' => null,
]),
'start' => new FieldDefinition('start', 'Beginn', 'date', 'pods', 'writable'),
'secret' => new FieldDefinition('_secret', '_secret', 'string', 'meta', 'readonly'),
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
];
}
public function testValidValuesPassUnchanged(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [12 => 'lit_person', 3 => 'attachment'][$id] ?? false);
$values = ['role' => 'Leitung', 'level' => 'senior', 'mentor' => 12, 'photos' => [3], 'start' => '2026-10-01'];
$result = (new FieldValidator())->validate($this->definitions(), $values, true);
self::assertSame([], $result['errors']);
self::assertSame($values, $result['values']);
}
public function testRequiredFieldMustBeSetOnCreateAndNotClearedOnUpdate(): void
{
$validator = new FieldValidator();
self::assertSame('Pflichtfeld fehlt.', $validator->validate($this->definitions(), [], true)['errors']['role']);
self::assertSame(
'Pflichtfeld darf nicht geleert werden.',
$validator->validate($this->definitions(), ['role' => ''], false)['errors']['role']
);
self::assertSame([], $validator->validate($this->definitions(), ['level' => 'junior'], false)['errors']);
}
public function testUnknownReadonlyAndInvalidValuesAreReported(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [7 => 'page', 3 => 'attachment'][$id] ?? false);
$errors = (new FieldValidator())->validate($this->definitions(), [
'role' => 'x',
'nope' => 1,
'secret' => 'x',
'level' => 'expert',
'mentor' => 7,
'photos' => 3,
'start' => '01.10.2026',
], false)['errors'];
self::assertStringContainsString('lit-content-types', $errors['nope']);
self::assertSame('Feld ist schreibgeschützt.', $errors['secret']);
self::assertSame('Erlaubte Werte: junior, senior.', $errors['level']);
self::assertSame('Eintrag 7 ist vom Typ page, erwartet lit_person.', $errors['mentor']);
self::assertSame('Liste erwartet.', $errors['photos']);
self::assertSame('Datum im Format JJJJ-MM-TT erwartet.', $errors['start']);
self::assertArrayNotHasKey('role', $errors);
}
public function testMissingRelatedPostAndNonMediaAreRejected(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [5 => 'page'][$id] ?? false);
$errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 99, 'photos' => [5]], false)['errors'];
self::assertSame('Eintrag 99 existiert nicht.', $errors['mentor']);
self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']);
}
public function testUnknownTypedFieldsAreAcceptedWithAWarningAndNullClears(): void
{
$result = (new FieldValidator())->validate($this->definitions(), ['city' => ['any' => 'thing'], 'level' => null], false);
self::assertSame([], $result['errors']);
self::assertSame(['city' => ['any' => 'thing'], 'level' => null], $result['values']);
self::assertCount(1, $result['warnings']);
self::assertStringContainsString('city', $result['warnings'][0]);
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Term;
final class TermAssignerTest extends TestCase
{
private function stubTaxonomies(bool $mayEditTerms = true, bool $mayAssign = true): void
{
Functions\when('get_object_taxonomies')->justReturn(['category', 'post_tag']);
Functions\when('get_taxonomy')->justReturn((object) [
'cap' => (object) ['assign_terms' => 'assign_categories', 'edit_terms' => 'manage_categories'],
]);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => match ($cap) {
'assign_categories' => $mayAssign,
'manage_categories' => $mayEditTerms,
default => false,
});
Functions\when('get_term')->alias(static fn (int $id) => $id === 4 ? new WP_Term(['term_id' => 4]) : null);
Functions\when('term_exists')->alias(
static fn (string $name) => $name === 'News' ? ['term_id' => '9', 'term_taxonomy_id' => '9'] : null
);
}
public function testResolvesIdsExistingNamesAndNewNames(): void
{
$this->stubTaxonomies();
$result = (new TermAssigner())->resolve('post', ['category' => [4, 'News', 'Neu']]);
self::assertSame([], $result['errors']);
self::assertSame(['category' => [4, 9, 'Neu']], $result['terms']);
}
public function testReportsForeignTaxonomyMissingIdsAndMissingRights(): void
{
$this->stubTaxonomies(false);
$result = (new TermAssigner())->resolve('post', [
'genre' => [1],
'category' => [77],
'post_tag' => ['Unbekannt'],
]);
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $result['errors']['genre']);
self::assertSame('Begriff 77 existiert nicht.', $result['errors']['category']);
self::assertStringContainsString('keine Berechtigung', $result['errors']['post_tag']);
}
public function testAssignRightsAreRequired(): void
{
$this->stubTaxonomies(true, false);
$result = (new TermAssigner())->resolve('post', ['category' => [4]]);
self::assertSame('Keine Berechtigung, Begriffe dieser Taxonomie zuzuweisen.', $result['errors']['category']);
}
public function testAssignCreatesNewTermsAndReplacesTheAssignment(): void
{
Functions\expect('wp_insert_term')->once()->with('Neu', 'category')->andReturn(['term_id' => 15]);
Functions\expect('wp_set_object_terms')->once()->with(5, [4, 15], 'category', false)->andReturn([4, 15]);
(new TermAssigner())->assign(5, ['category' => [4, 'Neu']]);
}
public function testAssignFailureThrows(): void
{
Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt'));
$this->expectException(RuntimeException::class);
(new TermAssigner())->assign(5, ['category' => [4]]);
}
}
+2
View File
@@ -11,3 +11,5 @@ require_once __DIR__ . '/Stubs/WpError.php';
require_once __DIR__ . '/Stubs/WpUser.php'; require_once __DIR__ . '/Stubs/WpUser.php';
require_once __DIR__ . '/Stubs/WpRestRequest.php'; require_once __DIR__ . '/Stubs/WpRestRequest.php';
require_once __DIR__ . '/Stubs/Wpdb.php'; require_once __DIR__ . '/Stubs/Wpdb.php';
require_once __DIR__ . '/Stubs/WpPost.php';
require_once __DIR__ . '/Stubs/WpTerm.php';