fix: find-similar marks replaceable matches and names excluded types (review M-3, M-7, M-8)

Every match carries replaceable: true only when it is identical and in a
page or post, the scope lit/library-make-global replaces; the description
and the guide say so. An excluded post type is answered like any
unavailable one, by its name (PostGuard::unavailableType()), and the
excluded list takes the Theme Builder types from DiviThemeBuilder::postTypes().

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-27 23:09:13 +02:00
co-authored by Claude Opus 5.5
parent 5290a9d81d
commit e7bbe1e9d7
4 changed files with 80 additions and 28 deletions
@@ -18,6 +18,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
@@ -48,15 +49,6 @@ final class LibraryFindSimilarAbility implements Ability
/** Post contents read with one primed cache (bounded memory). */
public const CONTENT_CHUNK = 50;
/** Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them. */
private const NOT_SCANNED = [
DiviLibraryStore::POST_TYPE,
'et_theme_builder',
'et_template',
'et_header_layout',
'et_body_layout',
'et_footer_layout',
];
private const LIVING = ['publish', 'draft', 'pending', 'private', 'future'];
@@ -84,8 +76,8 @@ final class LibraryFindSimilarAbility implements Ability
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
. 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit '
. 'anderen Inhalten. Nur Einträge, die dieses Konto bearbeiten darf; höchstens 200 Einträge je Aufruf '
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen ersetzt lit/library-make-global '
. '(library_id, targets).';
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen in Seiten und Beiträgen (replaceable: true) '
. 'ersetzt lit/library-make-global (library_id, targets).';
}
public function inputSchema(): array
@@ -192,7 +184,7 @@ final class LibraryFindSimilarAbility implements Ability
}
++$scanned;
$post = current_user_can('edit_post', $id) ? get_post($id) : null;
if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::NOT_SCANNED, true)) {
if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::notScanned(), true)) {
$this->collect($post, $reference, $matches, $nodes);
}
}
@@ -302,19 +294,31 @@ final class LibraryFindSimilarAbility implements Ability
return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id];
}
/**
* Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them
* (review M-3: the Theme Builder types from DiviThemeBuilder::postTypes()).
*
* @return list<string>
*/
private static function notScanned(): array
{
return array_merge([DiviLibraryStore::POST_TYPE], DiviThemeBuilder::postTypes());
}
/** @return list<string>|WP_Error */
private function postTypes(mixed $postType): array|WP_Error
{
if ($postType !== null) {
$type = in_array($postType, self::NOT_SCANNED, true)
? PostGuard::postType('', $this->schema, 'edit_posts')
// Review M-7: an excluded type answers like any unavailable one, by its name.
$type = in_array($postType, self::notScanned(), true)
? PostGuard::unavailableType((string) $postType)
: PostGuard::postType($postType, $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : [(string) $postType];
}
$types = array_values(array_filter(
array_diff($this->schema->postTypeNames(), self::NOT_SCANNED),
array_diff($this->schema->postTypeNames(), self::notScanned()),
fn (string $type): bool => $this->schema->diviEnabled($type)
));
@@ -355,6 +359,7 @@ final class LibraryFindSimilarAbility implements Ability
continue;
}
$identical = Fingerprint::content($block) === $reference['info']['content'];
$matches[] = [
'post_id' => (int) $post->ID,
'title' => (string) $post->post_title,
@@ -363,7 +368,10 @@ final class LibraryFindSimilarAbility implements Ability
'id' => $entry['id'],
'label' => NodeSummary::label($block),
'tree_rev' => Rev::of($content),
'match' => Fingerprint::content($block) === $reference['info']['content'] ? 'identical' : 'structure',
'match' => $identical ? 'identical' : 'structure',
// Review M-8: lit/library-make-global replaces identical occurrences in pages and posts only.
'replaceable' => $identical
&& in_array((string) $post->post_type, LibraryMakeGlobalAbility::POST_TYPES, true),
];
if (count($matches) >= self::MAX_MATCHES) {
return;
+10 -4
View File
@@ -136,15 +136,21 @@ final class PostGuard
* so a plain `object` return type is accurate and still lets callers branch with
* `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above.
*/
/** The answer for a post type that is not available to the tools – the same for unknown and excluded ones. */
public static function unavailableType(string $name): WP_Error
{
return Errors::notFound(
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.'
);
}
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 (direkt: lit-content-types) abrufen.'
);
return self::unavailableType($name);
}
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;