Files
WP-LIT-MCP/docs/superpowers/plans/2026-10-19-etappe-4e-schleifen-guides-audit.md
T

280 KiB
Raw Blame History

Etappe 4e – Schleifen-Inhalte, Guides, Security-Audit, Version – Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Der KI-Assistent kann loop_*-Inhalte (z. B. Datum, Kategorien, Autor) in Modulen setzen, die in einem Divi-Loop-Element liegen oder selbst eines sind – mit Kontextprüfung aus dem Blockbaum, passenden Fehlermeldungen und der Kurzform {{loop:<option>}}; lit/divi-dynamic-sources zeigt die passenden Loop-Optionen je Knoten. Außerdem: Guides für alle Etappe-4-Werkzeuge, ein Security-Audit der ganzen Etappe mit Fix-Welle und der Versionssprung auf 0.7.0.

Architecture: Der Schleifen-Kontext wird im Blockbaum bestimmt (LoopContext: nächster Vorfahre-oder-selbst mit module.advanced.loop.desktop.value.enable === "on", Typ aus queryType, Standard post_types) und als reines Wertobjekt LoopScope an VariableRef/DynamicContentRules gegeben; dort erlaubt er nur loop_*-Optionen der passenden Art (Post/Term/Benutzer/Menü). Die Einspeisung liegt in den Abilities (Insert, Update, Move), nicht in den unteren Schichten: DiviComposition::inLoop() liefert eine Komposition mit Kontext. DynamicContentMap behält die loop_*-Optionen (heute verworfen) gruppiert nach Art; Divi-Aufrufe ändern sich nicht (DynamicContentOptions::get_options() liefert sie schon). Guides, Audit und Version sind Dokumentations-/Prozess-Tasks mit klaren Checklisten.

Tech Stack: PHP ≥ 8.1, WordPress 7.x, Divi 5.15.1 (nur über vorhandene Adapter), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV + bash/jq-Smokes.

Spec: docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md (§9 Schleifen-Inhalte, §12 Audit, §14 Schritt 8–9, §1.3 Nr. 5–6, A3, A10, A11), Entscheidungen docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md (G11, G12), Recherchen 4e: docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md (Divi-Interna; Task 10 legt sie ab, bis dahin …/scratchpad/research-4e-loop-divi.md, …/research-4e-loop-plugin.md, …/research-4e-guides-audit-version.md – die Task-Autoren haben sie gelesen), Pläne 4c/4d als Vorbild; der Code ist maßgeblich, wo er von den Plänen abweicht.

Voraussetzung: Branch etappe-4 (= main), Stand 7678d22 (4a–4d fertig), 3368 Unit-Tests grün (composer test), composer lint sauber.

Teil von Etappe 4: 4a–4d (fertig) → 4e (dieser Plan) = Abschluss von Etappe 4.

Global Constraints

  • Dateien nur im Plugin-Verzeichnis /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP anlegen oder ändern. Integrationsskripte stellen im EXIT-Trap alles wieder her, was sie ändern (Seiten, Optionen).
  • PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9; Divi 5.15.1. Namespace LIT\MCP\ → src/, PSR-4, eine Klasse pro Datei, jede Datei mit defined('ABSPATH') || exit;, declare(strict_types=1);, höchstens 120 Zeichen je Zeile. Nach jedem Task composer test und composer lint sauber (composer test = php -d memory_limit=512M vendor/bin/phpunit).
  • Divi-Grenze (Divi-Absicherung §3): Divi-Aufrufe nur in Adaptern aus DiviAdapters::CLASSES. 4e braucht keinen neuen Divi-Aufruf: DynamicContentMap ruft DynamicContentOptions::get_options() schon und behält jetzt nur mehr von dessen Ergebnis. Ergibt sich doch ein neuer Divi-Aufruf, Task anhalten und melden (NEEDS_CONTEXT). LoopScope, LoopContext und die Kontext-Teile der Regeln sind reines PHP (keine WordPress-/Divi-Aufrufe).
  • Sicherheitsgrenze Typabgleich: Ein loop_*-Wert ist nur erlaubt, wenn der Schleifen-Kontext des Knotens (Art) zur Art der Option passt. Das ist keine Komfortmeldung: In Divi liest loop_post_meta_key_* in einer Term-/Benutzer-/Menü-Schleife Post-Meta des Objekts mit derselben ID (und Benutzer-/Term-Meta in einer Post-Schleife umgekehrt) – ein Fehlabgleich würde fremde Daten ausgeben (Recherche 4e, Befund 1). Ohne Schleifen-Kontext ist jede loop_*-Option abgelehnt.
  • Rechte/Sperren unverändert: Alle Dynamic-Content-Einstellungen laufen weiter durch ValueCleaner/VariableRef/DynamicContentRules mit den Meta-Schlüssel-Regeln S29/S30 (auch für loop_*_meta_key_) und unfiltered_html-Regeln; die Release-Gate-Fähigkeiten der Tools ändern sich nicht (dynamic-sources ist schon überall deklariert, wo Inhalte entstehen).
  • Sprache: Meldungen, Hinweise, Guides, Notizen auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“. Tool-Namen: direkte Tools lit-<name>, indirekte lit/<name> (ToolNamingTest).
  • Tests: Abilities über LIT\MCP\Tests\Unit\TestCase (Brain Monkey + Mockery, assertToolError); vorbelegte Stubs (wp_json_encode, is_wp_error, absint, wp_slash, wp_unslash, sanitize_text_field) nie mit Functions\expect() prüfen. Reine Klassen erben von PHPUnit\Framework\TestCase. TDD: Test zuerst, RED-Lauf, Code, GREEN; ein Test muss den Codepfad erreichen, den sein Name behauptet (keine vakuösen Assertions – Lehre aus 4c/4d). Echte Loop-Optionsnamen aus Divi 5.15.1 verwenden (die bisherigen Testdaten loop_post_meta_key u. ä. sind erfunden und werden in Task 2 korrigiert). Integration: cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh (läuft nur mit deaktivierten Plugins postlos und asynclistry; vorher Zustand mit ddev exec wp plugin list notieren und danach wiederherstellen).
  • Kein clean-code-review-Skill je Task (die globale CLAUDE.md-Regel zieht sonst die Antwort des Implementierers an sich); der Gesamtreview folgt am Ende (Task 10).
  • Commits: git commit -m "<Nachricht>" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>" mit dem Namen des ausführenden Modells; nur die Dateien des Tasks stagen.

Entscheidungen für 4e (Rulings, verbindlich)

  • X1 Kontextregel (Divi 5.15.1): Der Schleifen-Kontext eines Knotens ist der des nächsten Vorfahren-oder-selbst, dessen attrs.module.advanced.loop.desktop.value.enable genau "on" ist (Tablet/Phone/Hover-Varianten und true zählen nicht – das liest auch Divi so). queryType fehlt → post_types. Verschachtelte Schleifen: die innerste gilt für alles darunter, egal welcher Art. Loop-Einstellungen, die nur über ein Preset kommen (LoopPresetAttrsMap), erkennt die Baum-Prüfung nicht – das ist eine dokumentierte Grenze (fail closed: ohne erkannte Schleife werden loop_* abgelehnt).
  • X2 Arten: post (post_types, current_page), term (post_taxonomies), user (user_roles), menu (menus), repeater (repeater_<acf-name>, nur falls die Recherche zeigt, dass es eigene Optionen gibt – sonst fällt repeater_* unter post oder wird abgelehnt; der Task-Autor entscheidet anhand der Recherchetabelle und dokumentiert es). Die Zuordnung Option → Art stammt aus der group der Divi-Option („Loop Terms“ → term, „Loop Users“ → user, alle übrigen loop_* → post, Menü nach Tabelle); die drei Meta-Schlüssel-Optionen (loop_post_meta_key_manual_custom_field, loop_user_…, loop_term_…) ordnet der Name zu, weil ihre Gruppennamen übersetzt sind. Gespeichert wird der Array-Schlüssel der Option (loop_term_name), nicht die interne id.
  • X3 Einspeisung in den Abilities: VariableRef und DynamicContentRules bekommen einen optionalen ?LoopScope (Standard LoopScope::none() → verhält sich wie heute, kein bestehender Aufruf bricht). DiviComposition::inLoop(LoopScope) liefert eine Komposition mit Kontext (Vorbild forPost()). Insert: Kontext der Zielposition (Eltern-Knoten, alle neuen Knoten eines Aufrufs teilen ihn, solange sie nicht selbst eine Schleife einschalten – ein neuer Knoten mit eigener loop-Einstellung im Aufruf schaltet den Kontext für seine Nachfahren um: der Task-Autor löst das über die neuen Attribute des Knotens, wenn nötig fail closed = Schleife zählt erst ab dem gespeicherten Zustand und der Fehler nennt das). Update: Kontext des bestehenden Knotens (Vorfahren aus dem Baum, plus eigene neue loop-Attribute desselben Aufrufs); ein Composer je Kontext (Ruling S31 „ein VariableRef je Komposition“ wird entsprechend umformuliert). Move: Kontext am neuen Ziel; ein Move, der vorhandene loop_*-Inhalte aus einer Schleife herausführt oder in eine Schleife anderer Art, erzeugt eine Warnung (Divi lässt das zu), keine Ablehnung (A11). lit-content-create und Theme-Builder-Anlage haben nie einen Schleifen-Kontext.
  • X4 Kurzform: {{loop:<option>}} (Option ohne Präfix, z. B. {{loop:post_date}} → loop_post_date) mit Einstellungen wie {{post:…}} (A10); die Tabelle LOOP in Placeholders ordnet den Kurznamen den Optionen zu; welche Optionen before/after tragen, kommt aus der DynamicContentMap.
  • X5 Fehlermeldungen (Spec §9): ohne Schleife: „Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten Element (Abschnitt, Zeile, Spalte, Gruppe).“; falsche Art: „… passt nicht zur Schleife dieses Elements (Typ: %s).“ (Typ deutsch: Beiträge, Terme, Benutzer, Menü). Die irreführende alte Meldung „nur innerhalb von Divi-Schleifen“ entfällt; Ruling S36 ist abgelöst.
  • X6 lit/divi-dynamic-sources: optional post_id + node (Pfad-ID): zusätzlich die zum Schleifen-Kontext dieses Knotens passenden loop_*-Optionen (Feldtypen wie die übrigen Optionen); lesen mit Leserecht auf die Seite (Muster lit-divi-node-get, DiviPageLoader); ohne Schleife im Kontext: Hinweis „kein Schleifen-Kontext“ statt Liste. Der Konstruktor bekommt den DiviPageLoader (Task 9 verdrahtet; die Tool-Fähigkeiten ändern sich nicht).
  • X7 Grenzen, dokumentiert statt gelöst: (a) module.advanced.loop ist als Attributpfad schreibbar – Schleifen ein-/ausschalten über attrs war schon vorher möglich und bleibt (A3 verbietet nur ein Anlegen/Konfigurieren-Werkzeug); (b) globale Module werden nicht betreten – loop_* in Bibliothekseinträgen hat keinen Schleifen-Kontext und wird abgelehnt; (c) StoredNodeGuard/Kopieren: kopierte Schleifen-Container mit loop_*-Kindern bleiben für Benutzer ohne unfiltered_html abgelehnt (fail closed), solange die Prüfung nur einen Kontext je Unterbaum kennt; (d) Presets, die eine Schleife einschalten, erkennt die Baumprüfung nicht (X1); (e) ACF-Wiederholungsfelder verlangen die Einstellung acf_type, die die Einstellungsregeln heute ablehnen – bleibt außen vor, wenn die Task-Autoren keinen sicheren Weg finden. Alle Punkte kommen in die Audit-Notiz (Task 8) und den Guide (Task 7).
  • X8 Guides (G11): keine Prompts, keine neue Guide-Datei ohne Not; Ablauf „Corporate Design übernehmen“ in workflows.md; divi-design-system.md bekommt Farben, Variablen, Theme-Optionen, Schriften, Restore je Bereich, Rechte; overview.md die vollständige Liste der indirekten Design-Tools und die neuen Fehlercodes (design_rev, rev_mismatch, conflict, in_use, font_*); divi-placeholders.md/divi-shortform.md {{loop:…}} und die Loop-Regeln. Die Inhalte folgen der Checkliste der Recherche (A) und dem echten Code der Tools.
  • X9 Audit (Task 8): Ein unabhängiger Audit (Opus, Skill anthropic-skills:security-audit wenn verfügbar, sonst eigenes Vorgehen) über den Umfang von Spec §12 (Preset-attrs/XSS, Font-Upload, Massen-Schreibrechte, Schleifen-Optionen/Meta-Schlüssel/Typabgleich, Snapshot-Restore) im Format der Audits 3a/3b (docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md); Befunde mit Schwere; kritische/hohe Befunde werden in einer Fix-Welle behoben, mittlere nach Urteil, der Rest steht als Restrisiko in der Notiz. Das DNS-Rebinding-Fenster zwischen allows() und cURL (W13) gehört in den Audit.
  • X10 Version: 0.6.1 → 0.7.0 (wp-lit-mcp.php:6, src/Plugin.php:48, readme.txt:7 plus Changelog-Abschnitt in readme.txt); kein Git-Tag, kein Update-Server, kein CHANGELOG; DiviReleases::TESTED bleibt (5.2.1, 5.14.0, 5.15.1). Spec/§9 nennt 5.14.0 – Task 10 berichtigt auf 5.15.1.

Schnittstellenvertrag (Namen und Signaturen, die spätere Tasks voraussetzen)

Klassen unter src/Integrations/Divi/Compose/ (Namespace LIT\MCP\Integrations\Divi\Compose), Tests unter tests/Unit/Integrations/Divi/Compose/, sofern nicht anders genannt. Task-Autoren dürfen Details verfeinern, aber nur mit Eintrag unter ## ABWEICHUNGEN am Ende ihres Abschnitts.

// LoopScope.php (Task 1) – reines PHP, unveränderlich
public const POST = 'post'; public const TERM = 'term'; public const USER = 'user'; public const MENU = 'menu';
public static function none(): self;
public static function ofQueryType(string $queryType): self;     // post_types|current_page → post, post_taxonomies → term, user_roles → user, menus → menu; unbekannt (repeater_… nach X2) → laut Task-Entscheidung
public function active(): bool;
public function kind(): ?string;                                  // null ohne Schleife
public function queryType(): ?string;
public function label(): string;                                  // deutsch für Meldungen: 'Beiträge' | 'Terme' | 'Benutzer' | 'Menü'

// LoopContext.php (Task 1) – reines PHP
/** @param array<string, mixed> $attrs Attribute eines Blocks */
public static function enabled(array $attrs): bool;               // desktop.value.enable === 'on' (X1)
public static function ownScope(array $attrs): ?LoopScope;        // Schleife an genau diesem Block, sonst null
/** @param list<array<string, mixed>> $attrChain Attribute von der Wurzel bis zum Knoten (inklusive) */
public static function fromChain(array $attrChain): LoopScope;    // innerste gewinnt
public static function of(NodeIndex $index, string $nodeId): LoopScope;     // Vorfahren-oder-selbst über die Pfad-ID (Namespace BlockTree\NodeIndex)

// DynamicContentMap (Pages/, Task 2) – loop_*-Optionen bleiben erhalten
/** @return array<string, array<string, mixed>> Option => Feldspezifikation, nur loop_*-Optionen der Art $kind */
public function loopFields(string $kind): array;                  // Name final nach Task 2; Task 3–5 nutzen genau diese Methode

// DynamicContentRules / VariableRef (Task 2) – optionaler Kontext
// VariableRef::__construct(DesignSystem $design, ?array $dynamicContent = null, ?LoopScope $loop = null)
// DynamicContentRules::__construct(DesignSystem $design, ?array $map, ?LoopScope $loop = null)

// DiviComposition (Pages/, Task 4)
public function inLoop(LoopScope $scope): self;                   // Klon mit Kontext, Vorbild forPost()

// Placeholders (Task 3): {{loop:<kurzname>}} → loop_<name>; Tabelle LOOP wie POST
// DiviDynamicSourcesAbility (Task 5): __construct(…, ?DiviPageLoader $loader = null); Eingabe post_id + node optional

Dateistruktur

Neu: Compose/LoopScope.php, Compose/LoopContext.php, tests/Integration/divi-loop-smoke.sh, ggf. Fixture unter tests/Fixtures/divi/ (echte Loop-Seite), Audit-Notiz docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md, Recherche-Notiz docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md.

Geändert: Compose/{DynamicContentRules,VariableRef,Placeholders}.php, Pages/{DynamicContentMap,DiviComposition,DiviInsertAbility,DiviUpdateAbility,DiviMoveAbility,DiviDynamicSourcesAbility,DiviWriter}.php (je nach Task), DiviIntegration.php (Loader für dynamic-sources), Guides (docs/guides/*.md), wp-lit-mcp.php, src/Plugin.php, readme.txt, Spec/Notizen, Tests der genannten Klassen (inkl. DynamicContentRulesTest, VariableRefTest, DiviFixtures).

Task-Übersicht

# Task Ergebnis
1 LoopScope, LoopContext reiner Schleifen-Kontext aus dem Baum
2 DynamicContentMap behält loop_*; DynamicContentRules/VariableRef mit Kontext; Meldungen; Fixtures korrigieren loop_* im passenden Kontext erlaubt, sonst Fehler
3 {{loop:…}} in Placeholders/Kurzform Kurzform
4 Kontext in Insert, Update, Move; Move-Warnung Werkzeuge nutzen den Kontext
5 lit/divi-dynamic-sources mit post_id + node Optionen je Knoten
6 Smoke gegen echtes Divi (Loop-Seite, Speichern, Rendern) divi-loop-smoke.sh
7 Guides Etappe-4-Werkzeuge und Loops dokumentiert
8 Security-Audit Etappe 4 + Fix-Welle Audit-Notiz, Fixes
9 Version 0.7.0, Spec-/Notiz-Nachträge, Verdrahtung Release-Stand
10 Gesamtverifikation, Gesamtreview, Memory Abschluss Etappe 4

Task 1: LoopScope und LoopContext – reiner Schleifen-Kontext aus dem Baum (X1, X2)

Zwei reine Klassen ohne WordPress und Divi: LoopScope ist der unveränderliche Wert „in welcher Divi-Schleife steht ein Knoten“ (Art + queryType), LoopContext liest ihn aus Block-Attributen, einer Attribut-Kette oder dem echten NodeIndex. Beide Teilblöcke haben einen eigenen Commit; 1b baut auf 1a auf. Nichts anderes ändert sich, kein bestehender Test wird angefasst.

Entscheidung repeater_* (X2, dokumentiert): Es gibt keine Art repeater. Divi hat zwar eigene ACF-Optionen (loop_acf_<rep>|||<feld>, Gruppe Loop ACF <rep>), aber ihr Renderer liest die Einstellung acf_type (Recherche §2), die die Einstellungsregeln ablehnen (X7e), und die Art hinge am Feldnamen. LoopScope::ofQueryType() behandelt darum jeden anderen Wert als die fünf des Visual Builders (post_types, current_page, post_taxonomies, user_roles, menus) – also repeater_<n>, die Aliase terms/users (Divis Inhalts-Renderer kennt sie nicht, LoopUtils.php:3130-3148), "" und Nicht-Strings (sanitize_key() → "") – als Schleife ohne unterstützte Art: queryType() nennt den Wert, kind() ist null, active() ist false; jede loop_*-Option wird dort abgelehnt (Meldung „passt nicht … (Typ: repeater_team)“, Task 2). Eine solche innere Schleife verdeckt trotzdem die äußere (Divi löst die innere zuerst auf) – fromChain()/of() halten an ihr an (fail closed). loop_acf_* hat nach kindOfOption() keine Art und passt nie.

Art einer Option nach Name (Ergänzung, siehe ## ABWEICHUNGEN A1): LoopScope::kindOfOption(string) (loop_term_ → term, loop_user_ → user, loop_menu_ → menu, loop_acf_ → null, jedes andere loop_* → post, andere Namen → null) und fits(string) (aktiv und gleiche Art). Das ist die Sicherheitsprüfung von Task 2: Divi nimmt den Meta-Typ von loop_<typ>_meta_key_* aus dem Namen (DynamicContentOptionLoopPostMetaKey::get_meta_type_from_name, :334-345) und liest dann die Meta des Schleifen-Objekts mit derselben ID (DynamicContentLoopOptions.php:145-158) – darum prüft die Regel den Namen, nicht nur die Gruppe.

Files:

  • Create: src/Integrations/Divi/Compose/LoopScope.php, src/Integrations/Divi/Compose/LoopContext.php
  • Create: tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php, tests/Unit/Integrations/Divi/Compose/LoopContextTest.php

Interfaces:

  • Consumes: LIT\MCP\Integrations\Divi\BlockTree\NodeIndex (find(string): ?Block, parentBlock(Block): ?Block – null an der Wurzel und am divi/placeholder), Block::attrs(), Block::isOpaque() (globale Module und fremde Blöcke; NodeIndex steigt nicht in sie ab), AttrMap::desktop(array, string) (liest <pfad>.desktop.value, stdClass auf dem Weg wird gelesen, ein leeres JSON-Objekt am Ende ergibt null), Parser::parse(string): Document (nur im Test).
  • Produces (Schnittstellenvertrag, Namespace LIT\MCP\Integrations\Divi\Compose):
    • LoopScope::POST|TERM|USER|MENU, none(), ofQueryType(string), active(): bool, kind(): ?string, queryType(): ?string, label(): string (Beiträge/Terme/Benutzer/Menü; bei nicht unterstützter Schleife der rohe queryType, ohne Schleife ""), neu fits(string $option): bool, static kindOfOption(string $option): ?string.
    • LoopContext::enabled(array): bool (genau "on" unter module.advanced.loop.desktop.value.enable), ownScope(array): ?LoopScope (queryType fehlt oder null → post_types), fromChain(list<array>): LoopScope (innerste gewinnt), of(NodeIndex, string $nodeId): LoopScope (Vorfahren-oder-selbst; opake Blöcke werden nicht gelesen; unbekannte ID → none()).
    • Für Task 4/5: „Schleife vorhanden?“ = active(); zum Vergleich zweier Kontexte (Move-Warnung) kind() vergleichen; eine nicht unterstützte Schleife hat queryType() !== null und kind() === null.
  • Ausgangslage vor dem Task: composer test = 3368 Tests grün (Stand 6203dbb).

Task 1a: LoopScope

  • Step 1a.1: Failing Test LoopScopeTest schreiben

Datei tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;

use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use PHPUnit\Framework\TestCase;

/** Rulings X1/X2: the loop kind of a node, from Divi's queryType (LoopUtils.php:730-739, VB "Zj"). */
final class LoopScopeTest extends TestCase
{
    /** @return array<string, array{string, string, string}> */
    public static function queryTypes(): array
    {
        return [
            'post types' => ['post_types', LoopScope::POST, 'Beiträge'],
            'current page (archive main query)' => ['current_page', LoopScope::POST, 'Beiträge'],
            'taxonomies' => ['post_taxonomies', LoopScope::TERM, 'Terme'],
            'user roles' => ['user_roles', LoopScope::USER, 'Benutzer'],
            'menus' => ['menus', LoopScope::MENU, 'Menü'],
        ];
    }

    /** @dataProvider queryTypes */
    public function testTheFiveQueryTypesOfTheBuilderHaveAKind(string $queryType, string $kind, string $label): void
    {
        $scope = LoopScope::ofQueryType($queryType);

        self::assertTrue($scope->active());
        self::assertSame($kind, $scope->kind());
        self::assertSame($queryType, $scope->queryType());
        self::assertSame($label, $scope->label());
    }

    public function testNoneIsNoLoop(): void
    {
        $scope = LoopScope::none();

        self::assertFalse($scope->active());
        self::assertNull($scope->kind());
        self::assertNull($scope->queryType());
        self::assertSame('', $scope->label());
    }

    /**
     * Ruling X2: repeater_<acf field> rows need the acf_type setting the rules refuse (X7e); Divi's
     * renderer knows no "terms"/"users" aliases for the content (LoopUtils.php:3130-3148), and sanitize_key()
     * turns "" or an array into "" – a loop the tools do not support: no kind, every loop_* refused.
     */
    public function testOtherQueryTypesAreALoopWithoutKind(): void
    {
        foreach (['repeater_team', 'terms', 'users', '', 'Post_Types', 'post_types '] as $queryType) {
            $scope = LoopScope::ofQueryType($queryType);

            self::assertFalse($scope->active(), $queryType);
            self::assertNull($scope->kind(), $queryType);
            self::assertSame($queryType, $scope->queryType(), $queryType);
            self::assertSame($queryType, $scope->label(), $queryType);
        }
    }

    /** @return array<string, array{string, string|null}> */
    public static function optionKinds(): array
    {
        return [
            'post title' => ['loop_post_title', LoopScope::POST],
            'post terms' => ['loop_post_terms', LoopScope::POST],
            'post meta' => ['loop_post_meta_key_manual_custom_field', LoopScope::POST],
            'product (post loop of products)' => ['loop_product_price_current', LoopScope::POST],
            'term name (key, not the id "loop_name")' => ['loop_term_name', LoopScope::TERM],
            'term meta' => ['loop_term_meta_key_manual_custom_field', LoopScope::TERM],
            'user avatar' => ['loop_user_avatar', LoopScope::USER],
            'user meta' => ['loop_user_meta_key_manual_custom_field', LoopScope::USER],
            'menu link' => ['loop_menu_link', LoopScope::MENU],
            'acf repeater row' => ['loop_acf_team|||name', null],
            'no loop option' => ['post_title', null],
            'prefix without underscore' => ['loopy_title', null],
        ];
    }

    /**
     * The kind an option NAME belongs to – Divi takes the meta type of loop_<type>_meta_key_* from the
     * name too (DynamicContentOptionLoopPostMetaKey::get_meta_type_from_name, :334-345).
     *
     * @dataProvider optionKinds
     */
    public function testTheOptionNameGivesItsKind(string $option, ?string $kind): void
    {
        self::assertSame($kind, LoopScope::kindOfOption($option));
    }

    public function testFitsOnlyAnOptionOfTheSameKind(): void
    {
        $terms = LoopScope::ofQueryType('post_taxonomies');

        self::assertTrue($terms->fits('loop_term_name'));
        self::assertTrue($terms->fits('loop_term_meta_key_manual_custom_field'));
        self::assertFalse($terms->fits('loop_post_meta_key_manual_custom_field'));
        self::assertFalse($terms->fits('loop_acf_team|||name'));
        self::assertFalse($terms->fits('post_title'));
        self::assertFalse(LoopScope::none()->fits('loop_post_title'));
        self::assertFalse(LoopScope::ofQueryType('repeater_team')->fits('loop_post_title'));
        self::assertTrue(LoopScope::ofQueryType('current_page')->fits('loop_post_title'));
    }
}
  • Step 1a.2: Test laufen lassen – RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter LoopScopeTest Expected: FAIL – ERRORS! Tests: 5, Assertions: 0, Errors: 5. mit Error: Class "LIT\MCP\Integrations\Divi\Compose\LoopScope" not found (die Datenprovider lösen die Klassenkonstanten noch vor den Tests auf).

  • Step 1a.3: LoopScope implementieren

Datei src/Integrations/Divi/Compose/LoopScope.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Compose;

defined('ABSPATH') || exit;

/**
 * The Divi loop a node sits in (rulings X1/X2) – an immutable value, pure PHP. Its kind comes from Divi's
 * queryType: post_types and current_page → post, post_taxonomies → term, user_roles → user, menus → menu
 * (the five values the Visual Builder offers; LoopUtils.php:730-739, QueryTypeController.php:89-96).
 *
 * Any other queryType – repeater_<ACF field>, the aliases "terms"/"users", "" – is a loop the tools do
 * not support: queryType() names it, but it has no kind and is not active(), so every loop_* option is
 * refused inside it (fail closed). ACF repeater rows would need the setting acf_type, which the content
 * rules refuse (ruling X7e); Divi's content renderer knows no aliases (LoopUtils.php:3130-3148).
 *
 * kindOfOption() is the kind an option NAME belongs to: Divi takes the meta type of
 * loop_<post|user|term>_meta_key_* from the name and reads the meta of the loop object's ID, whatever the
 * object is (DynamicContentLoopOptions.php:145-158) – a post meta option in a term loop reads the post
 * with the term's ID. fits() is that security check (Global Constraints: "Sicherheitsgrenze Typabgleich").
 */
final class LoopScope
{
    public const POST = 'post';
    public const TERM = 'term';
    public const USER = 'user';
    public const MENU = 'menu';

    /** Divi queryType => kind. */
    private const KINDS = [
        'post_types' => self::POST,
        'current_page' => self::POST,
        'post_taxonomies' => self::TERM,
        'user_roles' => self::USER,
        'menus' => self::MENU,
    ];

    private const LABELS = [
        self::POST => 'Beiträge',
        self::TERM => 'Terme',
        self::USER => 'Benutzer',
        self::MENU => 'Menü',
    ];

    /** Name prefix => kind of a loop option; every other loop_* name is a post option (loop_post_*, loop_product_*). */
    private const OPTION_PREFIXES = [
        'loop_term_' => self::TERM,
        'loop_user_' => self::USER,
        'loop_menu_' => self::MENU,
        'loop_acf_' => null,
    ];

    private function __construct(private ?string $queryType)
    {
    }

    public static function none(): self
    {
        return new self(null);
    }

    public static function ofQueryType(string $queryType): self
    {
        return new self($queryType);
    }

    /** Whether there is a loop whose loop_* options the tools allow. */
    public function active(): bool
    {
        return $this->kind() !== null;
    }

    /** POST, TERM, USER or MENU; null without a loop or in an unsupported one. */
    public function kind(): ?string
    {
        return $this->queryType === null ? null : (self::KINDS[$this->queryType] ?? null);
    }

    /** Divi's queryType of the loop; null without a loop. */
    public function queryType(): ?string
    {
        return $this->queryType;
    }

    /** German name of the kind for messages; the raw queryType of an unsupported loop; "" without a loop. */
    public function label(): string
    {
        $kind = $this->kind();

        return $kind === null ? (string) $this->queryType : self::LABELS[$kind];
    }

    /** Whether the loop_* option $option belongs to this loop's kind. */
    public function fits(string $option): bool
    {
        return $this->active() && self::kindOfOption($option) === $this->kind();
    }

    /** Kind of a loop_* option by its name; null for other names and ACF repeater options. */
    public static function kindOfOption(string $option): ?string
    {
        if (! str_starts_with($option, 'loop_')) {
            return null;
        }

        foreach (self::OPTION_PREFIXES as $prefix => $kind) {
            if (str_starts_with($option, $prefix)) {
                return $kind;
            }
        }

        return self::POST;
    }
}
  • Step 1a.4: GREEN und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit --filter LoopScopeTest && composer lint Expected: OK (20 tests, 68 assertions); phpcs sauber.

  • Step 1a.5: Commit
git add src/Integrations/Divi/Compose/LoopScope.php tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php
git commit -m "feat: LoopScope – Schleifen-Art aus queryType und Optionsname (X1/X2)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 1b: LoopContext

Die Baum-Fälle laufen über eine echte geparste Seite (Parser + NodeIndex::build), nicht über Mocks: Pfad-IDs wie s0.r0.c0.m1.m1.m0 entstehen so, wie die Werkzeuge sie sehen. Die Seite enthält eine Beitrags-Schleife (Spalte) mit einer Term-Schleife (Gruppe) darin, darin eine ausgeschaltete Schleife (Desktop off, Tablet on), eine Spalte ohne Schleife mit einer globalen Modul-Instanz, die enable: "on" trägt (opak → nicht gelesen), und einen Abschnitt, der selbst eine Benutzer-Schleife ist. Der Fall „globale Instanz“ prüft wirklich den isOpaque()-Zweig: ohne ihn liefert of() dort post (im Arbeitsstand nachgeprüft: Mutation → 1 Failure); ebenso fällt == 'on' statt === 'on' durch (true == 'on').

  • Step 1b.1: Failing Test LoopContextTest schreiben

Datei tests/Unit/Integrations/Divi/Compose/LoopContextTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;

use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Compose\LoopContext;
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use PHPUnit\Framework\TestCase;

/** Ruling X1: the nearest ancestor-or-self with desktop.value.enable === "on" decides (LoopUtils.php:4720). */
final class LoopContextTest extends TestCase
{
    /**
     * Block attributes with module.advanced.loop.
     *
     * @param mixed $loop The value of module.advanced.loop
     * @return array<string, mixed>
     */
    private static function attrs(mixed $loop): array
    {
        return ['module' => ['advanced' => ['loop' => $loop]], 'builderVersion' => '5.15.1'];
    }

    /**
     * @param array<string, mixed> $value desktop.value of the loop
     * @return array<string, mixed>
     */
    private static function loop(array $value): array
    {
        return self::attrs(['desktop' => ['value' => $value]]);
    }

    public function testOnlyTheStringOnAtDesktopValueEnablesALoop(): void
    {
        self::assertTrue(LoopContext::enabled(self::loop(['enable' => 'on'])));

        $off = [
            'off' => self::loop(['enable' => 'off']),
            'true' => self::loop(['enable' => true]),
            'int 1' => self::loop(['enable' => 1]),
            '"1"' => self::loop(['enable' => '1']),
            '"true"' => self::loop(['enable' => 'true']),
            '"ON"' => self::loop(['enable' => 'ON']),
            'missing enable' => self::loop(['queryType' => 'post_types']),
            'tablet only' => self::attrs(['tablet' => ['value' => ['enable' => 'on']]]),
            'phone only' => self::attrs(['phone' => ['value' => ['enable' => 'on']]]),
            'hover only' => self::attrs(['desktop' => ['hover' => ['enable' => 'on']]]),
            'sticky only' => self::attrs(['desktop' => ['sticky' => ['enable' => 'on']]]),
            'no loop' => ['builderVersion' => '5.15.1'],
            'loop is a string' => self::attrs('on'),
            'desktop is a string' => self::attrs(['desktop' => 'on']),
            'value is a string' => self::attrs(['desktop' => ['value' => 'on']]),
            'value is a list' => self::attrs(['desktop' => ['value' => ['on']]]),
            'empty JSON object' => self::attrs(new \stdClass()),
            'advanced is a string' => ['module' => ['advanced' => 'loop']],
        ];
        foreach ($off as $case => $attrs) {
            self::assertFalse(LoopContext::enabled($attrs), $case);
            self::assertNull(LoopContext::ownScope($attrs), $case);
        }
    }

    public function testOwnScopeReadsTheQueryTypeWithPostTypesAsDefault(): void
    {
        self::assertSame('post_types', LoopContext::ownScope(self::loop(['enable' => 'on']))?->queryType());
        self::assertSame(LoopScope::POST, LoopContext::ownScope(self::loop(['enable' => 'on']))?->kind());
        self::assertSame(
            'post_types',
            LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => null]))?->queryType()
        );
        self::assertSame(
            LoopScope::TERM,
            LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']))?->kind()
        );
        self::assertSame(
            LoopScope::USER,
            LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'user_roles']))?->kind()
        );
        self::assertSame(
            LoopScope::MENU,
            LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'menus']))?->kind()
        );
    }

    public function testAnUnknownOrMalformedQueryTypeIsALoopWithoutKind(): void
    {
        $repeater = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'repeater_team']));
        self::assertNotNull($repeater);
        self::assertFalse($repeater->active());
        self::assertSame('repeater_team', $repeater->queryType());

        // sanitize_key() turns a non-string into "" – no post_types default (LoopUtils.php:730-732 isset()).
        $array = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => ['post_types']]));
        self::assertNotNull($array);
        self::assertFalse($array->active());
        self::assertSame('', $array->queryType());
    }

    public function testTheInnermostEnabledLoopOfTheChainWins(): void
    {
        $posts = self::loop(['enable' => 'on', 'queryType' => 'post_types']);
        $terms = self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']);
        $plain = ['builderVersion' => '5.15.1'];
        $disabled = self::loop(['enable' => 'off', 'queryType' => 'user_roles']);

        self::assertSame(LoopScope::TERM, LoopContext::fromChain([$plain, $posts, $terms, $plain])->kind());
        self::assertSame(LoopScope::POST, LoopContext::fromChain([$terms, $posts])->kind(), 'post loop inside terms');
        self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts, $disabled, $plain])->kind());
        self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts])->kind(), 'the node itself');
        self::assertNull(LoopContext::fromChain([$plain, $disabled])->queryType());
        self::assertNull(LoopContext::fromChain([])->queryType());
        // An unsupported inner loop still hides the outer one: Divi resolves the inner loop first.
        $repeater = self::loop(['enable' => 'on', 'queryType' => 'repeater_team']);
        $inner = LoopContext::fromChain([$posts, $repeater, $plain]);
        self::assertFalse($inner->active());
        self::assertSame('repeater_team', $inner->queryType());
    }

    private static function page(): NodeIndex
    {
        $loop = static fn (string $queryType): string => '{"module":{"advanced":{"loop":{"desktop":{"value":'
            . '{"enable":"on","loopId":"loop-lit4etest1","queryType":"' . $queryType . '"}}}}},'
            . '"builderVersion":"5.15.1"}';
        $off = '{"module":{"advanced":{"loop":{"desktop":{"value":{"enable":"off","queryType":"menus"}},'
            . '"tablet":{"value":{"enable":"on"}}}}},"builderVersion":"5.15.1"}';
        $text = '<!-- wp:divi/text {"builderVersion":"5.15.1"} /-->';

        return NodeIndex::build((new Parser())->parse(
            '<!-- wp:divi/placeholder -->'
            . '<!-- wp:divi/section {"builderVersion":"5.15.1"} --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
            // s0.r0.c0: a post loop, a term loop group inside it, a disabled loop group inside that.
            . '<!-- wp:divi/column ' . $loop('post_types') . ' -->' . $text
            . '<!-- wp:divi/group ' . $loop('post_taxonomies') . ' -->' . $text
            . '<!-- wp:divi/group ' . $off . ' -->' . $text . '<!-- /wp:divi/group -->'
            . '<!-- /wp:divi/group -->'
            . '<!-- /wp:divi/column -->'
            // s0.r0.c1: no loop; a global module instance claiming a loop (opaque: not read).
            . '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text
            . '<!-- wp:divi/text {"globalModule":"812","module":{"advanced":{"loop":{"desktop":{"value":'
            . '{"enable":"on"}}}}},"builderVersion":"5.15.1"} /-->'
            . '<!-- /wp:divi/column -->'
            . '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
            // s1: the section itself is a user loop.
            . '<!-- wp:divi/section ' . $loop('user_roles') . ' --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
            . '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text . '<!-- /wp:divi/column -->'
            . '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
            . '<!-- /wp:divi/placeholder -->'
        ));
    }

    public function testTheTreeWalksAncestorsOrSelfByPathId(): void
    {
        $index = self::page();

        // The ids the cases below rely on.
        self::assertSame('divi/group', $index->find('s0.r0.c0.m1.m1')?->name());
        self::assertTrue($index->find('s0.r0.c1.g0')?->isOpaque());

        $kinds = [
            's0' => null,
            's0.r0' => null,
            's0.r0.c0' => LoopScope::POST,
            's0.r0.c0.m0' => LoopScope::POST,
            's0.r0.c0.m1' => LoopScope::TERM,
            's0.r0.c0.m1.m0' => LoopScope::TERM,
            's0.r0.c0.m1.m1' => LoopScope::TERM,
            's0.r0.c0.m1.m1.m0' => LoopScope::TERM,
            's0.r0.c1' => null,
            's0.r0.c1.m0' => null,
            's0.r0.c1.g0' => null,
            's1' => LoopScope::USER,
            's1.r0.c0.m0' => LoopScope::USER,
            'nope' => null,
            's9.r0' => null,
        ];
        foreach ($kinds as $id => $kind) {
            self::assertSame($kind, LoopContext::of($index, $id)->kind(), $id);
        }
        self::assertSame('post_taxonomies', LoopContext::of($index, 's0.r0.c0.m1.m1.m0')->queryType());
        self::assertNull(LoopContext::of($index, 's0.r0.c1.g0')->queryType());
    }
}
  • Step 1b.2: Test laufen lassen – RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter LoopContextTest Expected: FAIL – ERRORS! Tests: 5, Assertions: 2, Errors: 5., je Error: Class "LIT\MCP\Integrations\Divi\Compose\LoopContext" not found.

  • Step 1b.3: LoopContext implementieren

Datei src/Integrations/Divi/Compose/LoopContext.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Compose;

use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;

defined('ABSPATH') || exit;

/**
 * The loop context of a node from the block tree (ruling X1), pure PHP. A block runs a loop exactly when
 * attrs.module.advanced.loop.desktop.value.enable is the string "on" – Divi's render gate
 * (LoopUtils::_is_block_loop_enabled, LoopUtils.php:4720-4724) and the Visual Builder read only that;
 * tablet, phone, hover/sticky values and true/1 do not count. A missing queryType is post_types
 * (LoopUtils.php:730-732). The nearest enabled ancestor-or-self wins whatever its kind: Divi resolves
 * nested loops innermost first (LoopUtils.php:4576-4605).
 *
 * Limits (rulings X1, X7): loop settings that come only from a preset (LoopPresetAttrsMap) are not seen,
 * and opaque blocks (global modules, foreign blocks) are not read – both leave the node without a loop,
 * so loop_* options are refused there (fail closed).
 */
final class LoopContext
{
    private const PATH = 'module.advanced.loop';

    /** @param array<string, mixed> $attrs Attributes of a block */
    public static function enabled(array $attrs): bool
    {
        return (self::settings($attrs)['enable'] ?? null) === 'on';
    }

    /**
     * The loop of exactly this block; null when it runs none.
     *
     * @param array<string, mixed> $attrs Attributes of a block
     */
    public static function ownScope(array $attrs): ?LoopScope
    {
        if (! self::enabled($attrs)) {
            return null;
        }

        $queryType = self::settings($attrs)['queryType'] ?? 'post_types';

        // Divi's sanitize_key() makes a non-string "" – a loop without a kind the tools support.
        return LoopScope::ofQueryType(is_string($queryType) ? $queryType : '');
    }

    /**
     * @param list<array<string, mixed>> $attrChain Attributes from the root down to the node (inclusive)
     */
    public static function fromChain(array $attrChain): LoopScope
    {
        foreach (array_reverse($attrChain) as $attrs) {
            $scope = self::ownScope($attrs);
            if ($scope !== null) {
                return $scope;
            }
        }

        return LoopScope::none();
    }

    /** The loop of the node $nodeId (path ID) or its nearest ancestor; none() for an unknown ID. */
    public static function of(NodeIndex $index, string $nodeId): LoopScope
    {
        for ($node = $index->find($nodeId); $node !== null; $node = $index->parentBlock($node)) {
            $scope = $node->isOpaque() ? null : self::ownScope($node->attrs());
            if ($scope !== null) {
                return $scope;
            }
        }

        return LoopScope::none();
    }

    /**
     * desktop.value of module.advanced.loop; [] when it is no object.
     *
     * @param array<string, mixed> $attrs
     * @return array<array-key, mixed>
     */
    private static function settings(array $attrs): array
    {
        $value = AttrMap::desktop($attrs, self::PATH);

        return is_array($value) ? $value : [];
    }
}
  • Step 1b.4: GREEN und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'LoopContextTest|LoopScopeTest' && composer lint Expected: OK (25 tests, 144 assertions); phpcs sauber.

  • Step 1b.5: Commit
git add src/Integrations/Divi/Compose/LoopContext.php tests/Unit/Integrations/Divi/Compose/LoopContextTest.php
git commit -m "feat: LoopContext – Schleifen-Kontext aus dem Blockbaum, innerste gewinnt (X1)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
  • Step 1b.6: Task-Abschluss

Run: composer test && composer lint Expected: OK (3393 tests, …) (3368 + 20 + 5); phpcs sauber.


Task 2: DynamicContentMap behält loop_*; DynamicContentRules/VariableRef mit Kontext; Meldungen; Testdaten (X2, X5)

Die Map hält die loop_*-Optionen jetzt bereit (loopFields()), die Regeln lassen eine loop_*-Option nur zu, wenn der optionale LoopScope aktiv ist und die Option zu seiner Art passt; ohne Kontext (Standard) wird jede loop_*-Option mit der neuen Meldung abgelehnt. Kein Aufrufer im Code ändert sich (die neuen Parameter sind optional und hinten); die Einspeisung des Kontexts macht Task 4. Kein neuer Divi-Aufruf: loopFields() nutzt dieselbe raw()-Abfrage wie forPost() (Manifest Call(REGISTRY . '::get_options', 2) unverändert; die Gruppennamen 'Loop Terms' usw. sind keine et_…-Literale, DiviBoundaryTest bleibt grün – im Arbeitsstand im Gesamtlauf nachgeprüft).

Teilblöcke: 2a Map + echte Loop-Optionen in DiviFixtures (die Map-Tests brauchen sie, darum hier statt in 2c), 2b Regeln/VariableRef + Korrektur der erfundenen Namen in VariableRefTest (die Meta-Schlüssel-Tests brauchen ab 2b einen Beitrags-Kontext, sonst würden sie an der Schleifen-Prüfung statt an S29/S30 scheitern – vakuös), 2c Docblocks und ein Pin-Test der Map in VariableRef. Jeder Teilblock hat einen Commit.

Verhalten „ohne Kontext“: byte-gleich für alles außer loop_*. Für loop_* ändert sich (a) die Meldung (X5 statt S36) und (b) ein loop_*-Name, den eine eingespeiste Map nennt, wird nicht mehr kontextfrei akzeptiert (bisher VariableRefTest::testInjectedDynamicContentMap; produktiv kam das nie vor, weil fromOptions() loop_* verwirft und das weiter tut). Das ist die Sicherheitsgrenze aus den Global Constraints („Ohne Schleifen-Kontext ist jede loop_*-Option abgelehnt“), siehe ## ABWEICHUNGEN A3.

Reihenfolge in fieldsFor(): custom_meta_* (unverändert) → loop_* → loopFieldsFor() (vor dem Map-Treffer, damit ein Map-Eintrag die Kontextprüfung nie umgeht) → Map → Design-Variablen → unbekannt. loopFieldsFor(): kein Kontext (queryType() === null) → Meldung 1 (X5); ! fits($name) → Meldung 2 mit label(); Name nicht in der Map → VariableRef::unknownName(); sonst die Map-Einstellungen, die dann wie alle anderen laufen (enable_html immer abgelehnt in VariableRef::contentSettings(), S29/S30 für select_loop_meta_key/loop_meta_key über das Suffix meta_key, S28, S37). Mit der Standard-Map (null, Registry nicht verfügbar) gibt es keine loop_*-Einträge → „Unbekannter Name“.

Für Task 4 (Vertrag): Eine Komposition in einer Schleife baut ihre Map als (array) $map->forPost($postId) + $map->loopFields((string) $scope->kind(), $postId) und übergibt $scope; forPost() enthält nie loop_*, die Summe ist also überschneidungsfrei (gepinnt in 2c). forPost() = null (Registry fehlt) → loopFields() = [] → nur die Standard-Map ohne loop_*.

Files:

  • Modify: src/Integrations/Divi/Pages/DynamicContentMap.php, src/Integrations/Divi/Compose/DynamicContentRules.php, src/Integrations/Divi/Compose/VariableRef.php, src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php (nur Docblock)
  • Create: tests/Unit/Integrations/Divi/Compose/DynamicContentRulesTest.php
  • Modify: tests/Support/DiviFixtures.php, tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php, tests/Unit/Integrations/Divi/Compose/VariableRefTest.php

Interfaces:

  • Consumes: LoopScope (none(), ofQueryType(), queryType(), kind(), fits(), label(), kindOfOption()) aus Task 1; DynamicContentMap::raw()/type() (vorhanden, privat).
  • Produces:
    • DynamicContentMap::loopFields(string $kind, int $postId = 0): array<string, array<string, string>> – Option => [Einstellung => Feldtyp], dieselbe Form wie forPost() (before/after richtext, loop_position text, date_format/thumbnail_size/select_loop_meta_key select, enable_html bleibt gelistet und wird von VariableRef abgelehnt). $postId ist eine rückwärtskompatible Ergänzung des Vertrags (A2); loopFields($kind) fragt die Registry mit Post 0 ab. Leer bei unbekannter Art und ohne Registry.
    • DynamicContentRules::__construct(DesignSystem $design, ?array $map = null, ?LoopScope $loop = null), VariableRef::__construct(DesignSystem $design, ?array $dynamicContent = null, ?LoopScope $loop = null) – wie im Vertrag.
    • Meldungen (X5, wörtlich): Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten Element (Abschnitt, Zeile, Spalte, Gruppe). und Schleifen-Inhalt "%s" passt nicht zur Schleife dieses Elements (Typ: %s).
  • Ausgangslage: 3393 Tests grün (nach Task 1).

Task 2a: DynamicContentMap::loopFields() und echte Loop-Optionen in DiviFixtures

Art je Option (X2): die drei Meta-Optionen nach Name (str_contains($name, '_meta_key_') nach kindOfOption(); ihre Gruppen Loop %s Custom Fields sind übersetzt), loop_acf_* keine Art, sonst die Gruppe (Loop Terms → term, Loop Users → user, Loop Menus → menu, jede andere – Loop, Loop Product – → post). Gespeichert und gerendert wird der Array-Schlüssel (get_options() setzt id = Schlüssel, DynamicContentOptions.php:114-116); die Fixture übernimmt das (id = Schlüssel). Die bisherigen Fixture-Einträge waren falsch: loop_post_meta_key_manual_custom_field stand in Gruppe Loop mit nur loop_meta_key; echt sind Gruppe Loop Post Custom Fields und die Felder before/after/loop_position/date_format/custom_date_format/select_loop_meta_key/loop_meta_key/enable_html (DynamicContentOptionLoopPostMetaKey.php:108-229; enable_html nur für unfiltered_html). fromOptions() verwirft loop_* weiter – testTheAllowMapIsUnchangedByTheRawLookupRefactor (JSON-Snapshot) und testLoopOptionsNeverEnterTheMap bleiben unverändert grün; options() (dynamic-sources) listet weiter keine loop_* (Task 5 ergänzt sie je Knoten).

  • Step 2a.1: Fixture korrigieren und Failing Tests schreiben

tests/Support/DiviFixtures.php (Hunks gegen 6203dbb):

diff --git a/tests/Support/DiviFixtures.php b/tests/Support/DiviFixtures.php
--- a/tests/Support/DiviFixtures.php
+++ b/tests/Support/DiviFixtures.php
@@ -102,6 +102,14 @@ final class DiviFixtures
         $option = static fn (string $id, string $group, array $fields): array => [
             'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
         ];
+        $select = static fn (): array => ['label' => 'x', 'type' => 'select', 'default' => ''];
+        $loop = ['before' => $text(), 'after' => $text(), 'loop_position' => $text()];
+        $date = ['date_format' => $select(), 'custom_date_format' => $text()];
+        $meta = $loop + $date + [
+            'select_loop_meta_key' => $select(),
+            'loop_meta_key' => $text(),
+            'enable_html' => ['label' => 'x', 'type' => 'yes_no_button', 'default' => 'off'],
+        ];
 
         return [
             'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]),
@@ -131,12 +139,32 @@ final class DiviFixtures
                 'meta_key' => $text(),
                 'enable_html' => ['type' => 'yes_no_button'],
             ]),
-            'loop_post_title' => $option('loop_post_title', 'Loop', [
-                'before' => $text(), 'after' => $text(), 'loop_position' => $text(),
-            ]),
-            'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [
-                'loop_meta_key' => $text(),
+            // Loop options as Divi 5.15.1's get_options() returns them (research 4e §2): id = key = stored name.
+            'loop_post_title' => $option('loop_post_title', 'Loop', $loop),
+            'loop_post_date' => $option('loop_post_date', 'Loop', $loop + $date),
+            'loop_post_featured_image' => $option('loop_post_featured_image', 'Loop', [
+                'loop_position' => $text(), 'thumbnail_size' => $select(),
             ]),
+            'loop_post_meta_key_manual_custom_field' => $option(
+                'loop_post_meta_key_manual_custom_field',
+                'Loop Post Custom Fields',
+                $meta
+            ),
+            'loop_product_title' => $option('loop_product_title', 'Loop Product', $loop),
+            'loop_term_name' => $option('loop_term_name', 'Loop Terms', $loop),
+            'loop_term_meta_key_manual_custom_field' => $option(
+                'loop_term_meta_key_manual_custom_field',
+                'Loop Term Custom Fields',
+                $meta
+            ),
+            'loop_user_name' => $option('loop_user_name', 'Loop Users', $loop),
+            'loop_user_meta_key_manual_custom_field' => $option(
+                'loop_user_meta_key_manual_custom_field',
+                'Loop User Custom Fields',
+                $meta
+            ),
+            'loop_menu_text' => $option('loop_menu_text', 'Loop Menus', $loop),
+            'loop_acf_team|||name' => $option('loop_acf_team|||name', 'Loop ACF team', $loop + ['acf_type' => $text()]),
             // Odd entries a third-party filter could add.
             'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]),
             'no_fields' => ['id' => 'no_fields', 'type' => 'text'],

tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php – Import und vier Tests vor assertRejected():

diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
--- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
+++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
 
 use InvalidArgumentException;
 use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Compose\Placeholders;
 use LIT\MCP\Integrations\Divi\Compose\VariableRef;
 use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
@@ -210,6 +211,88 @@ final class DynamicContentMapTest extends TestCase
         self::assertSame($before, json_encode($after));
     }
 
+    /** Ruling X2: loop_* options stay, grouped by kind – by group, the three meta options by name. */
+    public function testLoopFieldsGroupTheLoopOptionsByKind(): void
+    {
+        $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
+
+        self::assertSame(
+            [
+                'loop_post_title', 'loop_post_date', 'loop_post_featured_image',
+                'loop_post_meta_key_manual_custom_field', 'loop_product_title',
+            ],
+            array_keys($map->loopFields(LoopScope::POST))
+        );
+        self::assertSame(
+            ['loop_term_name', 'loop_term_meta_key_manual_custom_field'],
+            array_keys($map->loopFields(LoopScope::TERM))
+        );
+        self::assertSame(
+            ['loop_user_name', 'loop_user_meta_key_manual_custom_field'],
+            array_keys($map->loopFields(LoopScope::USER))
+        );
+        self::assertSame(['loop_menu_text'], array_keys($map->loopFields(LoopScope::MENU)));
+        // ACF repeater rows have no kind (X2/X7e); an unknown kind has no options.
+        self::assertSame([], $map->loopFields('repeater_team'));
+        self::assertSame([], $map->loopFields(''));
+    }
+
+    public function testLoopFieldsHaveTheShapeOfTheAllowMap(): void
+    {
+        $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
+
+        $post = $map->loopFields(LoopScope::POST);
+
+        self::assertSame(
+            [
+                'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
+                'date_format' => 'select', 'custom_date_format' => 'text',
+            ],
+            $post['loop_post_date']
+        );
+        self::assertSame(['loop_position' => 'text', 'thumbnail_size' => 'select'], $post['loop_post_featured_image']);
+        self::assertSame(
+            ['select', 'text', 'yes_no_button'],
+            [
+                $post['loop_post_meta_key_manual_custom_field']['select_loop_meta_key'],
+                $post['loop_post_meta_key_manual_custom_field']['loop_meta_key'],
+                $post['loop_post_meta_key_manual_custom_field']['enable_html'],
+            ]
+        );
+    }
+
+    /** The custom field groups are translated ("Loop %s Custom Fields", esc_html__) – their name decides. */
+    public function testTheMetaOptionsFollowTheirNameNotTheirGroup(): void
+    {
+        $registry = DiviFixtures::dynamicContentRegistry();
+        $registry['loop_user_meta_key_manual_custom_field']['group'] = 'Schleife: Benutzer-Felder';
+        $registry['loop_term_meta_key_manual_custom_field']['group'] = 'Loop';
+        $registry['loop_post_meta_key_manual_custom_field']['group'] = 'Loop Users';
+        $map = new DynamicContentMap(static fn (int $id): array => $registry);
+
+        self::assertArrayHasKey('loop_user_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
+        self::assertArrayHasKey('loop_term_meta_key_manual_custom_field', $map->loopFields(LoopScope::TERM));
+        self::assertArrayHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::POST));
+        self::assertArrayNotHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
+    }
+
+    public function testLoopFieldsReadTheRegistryOfThePostAndAreEmptyWithoutIt(): void
+    {
+        $seen = [];
+        $map = new DynamicContentMap(static function (int $postId) use (&$seen): array {
+            $seen[] = $postId;
+
+            return DiviFixtures::dynamicContentRegistry();
+        });
+
+        $map->loopFields(LoopScope::POST, 42);
+        $map->loopFields(LoopScope::TERM);
+
+        self::assertSame([42, 0], $seen);
+        self::assertSame([], (new DynamicContentMap(static fn (): array => []))->loopFields(LoopScope::POST));
+        self::assertSame([], (new DynamicContentMap())->loopFields(LoopScope::POST), 'without Divi');
+    }
+
     private static function assertRejected(callable $call, string $fragment): void
     {
         try {
  • Step 2a.2: Tests laufen lassen – RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DynamicContentMapTest Expected: FAIL – Tests: 14, Assertions: 79, Errors: 4., die vier neuen Tests mit Error: Call to undefined method LIT\MCP\Integrations\Divi\Pages\DynamicContentMap::loopFields(); die zehn bestehenden (auch der JSON-Snapshot mit der erweiterten Fixture) grün.

  • Step 2a.3: loopFields() implementieren

src/Integrations/Divi/Pages/DynamicContentMap.php (die Feld-Schleife von fromOptions() wird zu fields() herausgelöst, damit forPost() und loopFields() dieselbe Form liefern):

diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php
--- a/src/Integrations/Divi/Pages/DynamicContentMap.php
+++ b/src/Integrations/Divi/Pages/DynamicContentMap.php
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
 use Closure;
 use LIT\MCP\Integrations\Divi\Compose\Placeholders;
 use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Release\Call;
 use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
 use LIT\MCP\Integrations\Divi\Release\DiviFeature;
@@ -25,8 +26,8 @@ defined('ABSPATH') || exit;
  * date_format => select]. DynamicContentRules routes each setting by that type and keeps its fixed
  * rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28).
  *
- * - loop_* options never enter the map: Divi offers them only inside loop-enabled modules,
- *   which the tools do not track – DynamicContentRules refuses them ("nur innerhalb von Divi-Schleifen").
+ * - loop_* options never enter this map: loopFields() keeps them apart, grouped by loop kind (ruling X2), for
+ *   a composition inside a Divi loop (LoopScope); DynamicContentRules allows one only in a loop of its kind.
  * - The map REPLACES DynamicContentRules' default, so every Placeholders::POST option is added when
  *   the registry lacks it, with the settings the placeholders write (DynamicContentRules' default).
  * - Registry unavailable (no Divi, empty, error): null – DynamicContentRules' default map applies.
@@ -38,6 +39,13 @@ class DynamicContentMap implements DiviAdapter
     /** Divi's dynamic-content registry class; listed in manifest(). */
     public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
 
+    /** Divi's loop option groups of other kinds than post (DynamicContentOptionTermsGroups.php, …UsersGroups.php). */
+    private const LOOP_GROUPS = [
+        'Loop Terms' => LoopScope::TERM,
+        'Loop Users' => LoopScope::USER,
+        'Loop Menus' => LoopScope::MENU,
+    ];
+
     /**
      * @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
      */
@@ -64,7 +72,7 @@ class DynamicContentMap implements DiviAdapter
 
     /**
      * The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
-     * names and types as forPost(), loop_* left out). custom_meta_<key> options a filter registers
+     * names and types as forPost(), loop_* left out – see loopFields()). custom_meta_<key> options a filter registers
      * for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so
      * the listing never names them. Only label, group and setting types leave Divi's registry –
      * no select options or defaults (they list meta keys). Null when the registry is unavailable.
@@ -95,6 +103,31 @@ class DynamicContentMap implements DiviAdapter
         return $options;
     }
 
+    /**
+     * The loop_* options of one loop kind (LoopScope::POST, TERM, USER, MENU): option name => [setting key =>
+     * field type], the shape of forPost() – a composition inside a loop of that kind adds them to its map.
+     * Kind (ruling X2): the three meta key options by their name (their groups are translated:
+     * "Loop %s Custom Fields", DynamicContentOptionLoopPostMetaKey.php:225), the others by Divi's group
+     * ("Loop Terms", "Loop Users", "Loop Menus"; every other group – "Loop", "Loop Product" – is post).
+     * ACF repeater options (loop_acf_*) have no kind (X2/X7e). Names are the registry keys – the names
+     * Divi stores and renders (get_options() sets every id to its key). [] when the registry is unavailable.
+     *
+     * @return array<string, array<string, string>>
+     */
+    public function loopFields(string $kind, int $postId = 0): array
+    {
+        $raw = $this->raw($postId) ?? [];
+
+        $fields = [];
+        foreach ($raw as $name => $option) {
+            if (is_string($name) && is_array($option) && self::loopKind($name, $option) === $kind) {
+                $fields[$name] = self::fields($option);
+            }
+        }
+
+        return $fields;
+    }
+
     /**
      * Divi's registry for the post; any filter error is logged (message only) and yields null.
      *
@@ -125,13 +158,7 @@ class DynamicContentMap implements DiviAdapter
                 continue;
             }
 
-            $fields = [];
-            foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
-                if (is_string($key) && $key !== '') {
-                    $fields[$key] = self::type($key, $field);
-                }
-            }
-            $map[$name] = $fields;
+            $map[$name] = self::fields($option);
         }
 
         foreach (Placeholders::POST as $option) {
@@ -141,6 +168,39 @@ class DynamicContentMap implements DiviAdapter
         return $map;
     }
 
+    /**
+     * Setting key => field type of one registry option.
+     *
+     * @param array<array-key, mixed> $option
+     * @return array<string, string>
+     */
+    private static function fields(array $option): array
+    {
+        $fields = [];
+        foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
+            if (is_string($key) && $key !== '') {
+                $fields[$key] = self::type($key, $field);
+            }
+        }
+
+        return $fields;
+    }
+
+    /**
+     * Loop kind of a registry option (see loopFields()); null for other names and ACF repeater options.
+     *
+     * @param array<array-key, mixed> $option
+     */
+    private static function loopKind(string $name, array $option): ?string
+    {
+        $byName = LoopScope::kindOfOption($name);
+        if ($byName === null || str_contains($name, '_meta_key_')) {
+            return $byName;
+        }
+
+        return self::LOOP_GROUPS[is_string($option['group'] ?? null) ? $option['group'] : ''] ?? LoopScope::POST;
+    }
+
     /**
      * Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi
      * outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's
  • Step 2a.4: GREEN, Gesamtlauf und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DynamicContentMapTest && composer test && composer lint Expected: OK (14 tests, 95 assertions), dann OK (3397 tests, …); phpcs sauber. (Die erweiterte Fixture berührt keinen anderen Test: DiviDynamicSourcesAbilityTest, DiviContentComposerTest, ShortformWriterTest, PlaceholdersTest, StoredNodeGuardTest, DiviCompositionTest, DiviWriterTest gehen über fromOptions()/options(), die loop_* verwerfen.)

  • Step 2a.5: Commit
git add src/Integrations/Divi/Pages/DynamicContentMap.php tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php tests/Support/DiviFixtures.php
git commit -m "feat: DynamicContentMap::loopFields() – loop_*-Optionen nach Art (X2); echte Loop-Optionen in DiviFixtures" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 2b: DynamicContentRules und VariableRef mit LoopScope (X5, Typabgleich)

DynamicContentRulesTest (neu, reine Klasse) prüft fieldsFor() direkt mit einer Map, die Optionen aller Arten nennt (der ungünstigste Fall: ein Map-Eintrag darf die Art-Prüfung nicht ersetzen), die Meldungen wörtlich. VariableRefTest prüft den Weg über canonical() (Einstellungen, enable_html, Meta-Schlüssel in einer Term-Schleife). Die bestehenden Meta-Schlüssel-Tests bekommen den echten Namen loop_post_meta_key_manual_custom_field (Helfer loopMeta()) und einen Beitrags-Kontext; testMetaKeysAreCheckedAfterDivisDerivation verlangt jetzt zusätzlich eine Meldung „Meta-Schlüssel“/„Unbekannter Name“, damit keine Zeile vakuös an der Schleifen-Prüfung scheitert. testInjectedDynamicContentMap prüft statt der alten kontextfreien Annahme: ohne Schleife abgelehnt, in der Beitrags-Schleife angenommen (A3).

  • Step 2b.1: Failing Tests schreiben

Datei tests/Unit/Integrations/Divi/Compose/DynamicContentRulesTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;

use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;

/** Rulings X2/X5 and the security boundary "Typabgleich": loop_* names only in a loop of their kind. */
final class DynamicContentRulesTest extends TestCase
{
    /** The map of a composition inside a loop: forPost() plus loopFields() of every kind (worst case). */
    private const MAP = [
        'post_title' => ['before' => 'richtext', 'after' => 'richtext'],
        'loop_post_title' => ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
        'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
        'loop_term_name' => ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
        'loop_term_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
        'loop_user_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
        'loop_menu_text' => ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
        'loop_acf_team|||name' => ['before' => 'richtext', 'after' => 'richtext'],
    ];

    private static function rules(?LoopScope $loop = null): DynamicContentRules
    {
        return new DynamicContentRules(DiviFixtures::designSystem(), self::MAP, $loop);
    }

    private static function assertRefused(DynamicContentRules $rules, string $name, string $message): void
    {
        try {
            $rules->fieldsFor($name);
            self::fail('Accepted: ' . $name);
        } catch (InvalidArgumentException $e) {
            self::assertSame($message, $e->getMessage(), $name);
        }
    }

    /** Spec §9 / X5: without a loop every loop_* name is refused – also one the map names. */
    public function testWithoutALoopEveryLoopOptionIsRefused(): void
    {
        foreach ([self::rules(), self::rules(LoopScope::none())] as $rules) {
            foreach (array_keys(self::MAP) as $name) {
                if (! str_starts_with($name, 'loop_')) {
                    continue;
                }
                self::assertRefused(
                    $rules,
                    $name,
                    sprintf(
                        'Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten '
                            . 'Element (Abschnitt, Zeile, Spalte, Gruppe).',
                        $name
                    )
                );
            }
        }
    }

    public function testALoopOptionOfTheLoopsKindHasItsMapSettings(): void
    {
        self::assertSame(self::MAP['loop_post_title'], self::rules(LoopScope::ofQueryType('post_types'))
            ->fieldsFor('loop_post_title'));
        self::assertSame(self::MAP['loop_post_title'], self::rules(LoopScope::ofQueryType('current_page'))
            ->fieldsFor('loop_post_title'));
        self::assertSame(self::MAP['loop_term_name'], self::rules(LoopScope::ofQueryType('post_taxonomies'))
            ->fieldsFor('loop_term_name'));
        self::assertSame(
            self::MAP['loop_user_meta_key_manual_custom_field'],
            self::rules(LoopScope::ofQueryType('user_roles'))->fieldsFor('loop_user_meta_key_manual_custom_field')
        );
        self::assertSame(self::MAP['loop_menu_text'], self::rules(LoopScope::ofQueryType('menus'))
            ->fieldsFor('loop_menu_text'));
    }

    /**
     * Security boundary: Divi reads the meta of the loop object's ID whatever the object is – post meta
     * of the post with the term's ID (DynamicContentLoopOptions.php:145-158). A hard error, not a hint.
     *
     * @return array<string, array{string, string, string}>
     */
    public static function mismatches(): array
    {
        return [
            'post meta in a term loop' => ['post_taxonomies', 'loop_post_meta_key_manual_custom_field', 'Terme'],
            'post meta in a user loop' => ['user_roles', 'loop_post_meta_key_manual_custom_field', 'Benutzer'],
            'post meta in a menu loop' => ['menus', 'loop_post_meta_key_manual_custom_field', 'Menü'],
            'user meta in a post loop' => ['post_types', 'loop_user_meta_key_manual_custom_field', 'Beiträge'],
            'term meta in a post loop' => ['current_page', 'loop_term_meta_key_manual_custom_field', 'Beiträge'],
            'term meta in a user loop' => ['user_roles', 'loop_term_meta_key_manual_custom_field', 'Benutzer'],
            'term name in a post loop' => ['post_types', 'loop_term_name', 'Beiträge'],
            'post title in a menu loop' => ['menus', 'loop_post_title', 'Menü'],
            'menu text in a term loop' => ['post_taxonomies', 'loop_menu_text', 'Terme'],
            'acf row in a post loop' => ['post_types', 'loop_acf_team|||name', 'Beiträge'],
            'post title in an acf repeater loop' => ['repeater_team', 'loop_post_title', 'repeater_team'],
            'acf row in an acf repeater loop' => ['repeater_team', 'loop_acf_team|||name', 'repeater_team'],
        ];
    }

    /** @dataProvider mismatches */
    public function testAnOptionOfAnotherKindIsRefused(string $queryType, string $name, string $label): void
    {
        self::assertRefused(
            self::rules(LoopScope::ofQueryType($queryType)),
            $name,
            sprintf('Schleifen-Inhalt "%s" passt nicht zur Schleife dieses Elements (Typ: %s).', $name, $label)
        );
    }

    public function testALoopOptionTheMapDoesNotNameIsUnknown(): void
    {
        $rules = self::rules(LoopScope::ofQueryType('post_types'));

        foreach (['loop_post_date', 'loop_post_meta_key_telefon', 'loop_'] as $name) {
            try {
                $rules->fieldsFor($name);
                self::fail('Accepted: ' . $name);
            } catch (InvalidArgumentException $e) {
                self::assertStringContainsString('Unbekannter Name "' . $name . '"', $e->getMessage());
            }
        }
        // The default map (registry unavailable) names no loop option.
        $this->expectExceptionMessage('Unbekannter Name "loop_post_title"');
        (new DynamicContentRules(DiviFixtures::designSystem(), null, LoopScope::ofQueryType('post_types')))
            ->fieldsFor('loop_post_title');
    }

    public function testOtherNamesAreTheSameInsideALoop(): void
    {
        foreach ([null, LoopScope::ofQueryType('post_taxonomies')] as $loop) {
            $rules = self::rules($loop);

            self::assertSame(self::MAP['post_title'], $rules->fieldsFor('post_title'));
            self::assertSame(DynamicContentRules::META_SETTINGS, $rules->fieldsFor('custom_meta_telefon'));
            self::assertSame(DynamicContentRules::DESIGN_SETTINGS, $rules->fieldsFor('gvid-s1'));
            foreach (['custom_meta__edit_lock', 'site_title'] as $name) {
                try {
                    $rules->fieldsFor($name);
                    self::fail('Accepted: ' . $name);
                } catch (InvalidArgumentException $e) {
                    self::assertStringContainsString('Unbekannter Name "' . $name . '"', $e->getMessage());
                }
            }
        }
    }
}

tests/Unit/Integrations/Divi/Compose/VariableRefTest.php (Hunks gegen den Stand nach 2a = 6203dbb):

diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
--- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
+++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
 
 use InvalidArgumentException;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Compose\VariableRef;
 use LIT\MCP\Tests\Support\ComposeFixture;
 use LIT\MCP\Tests\Support\DiviFixtures;
@@ -14,9 +15,14 @@ use PHPUnit\Framework\TestCase;
 final class VariableRefTest extends TestCase
 {
     /** @param array<string, array<string, string>>|null $dynamicContent */
-    private static function ref(?array $dynamicContent = null): VariableRef
+    private static function ref(?array $dynamicContent = null, ?LoopScope $loop = null): VariableRef
     {
-        return new VariableRef(DiviFixtures::designSystem(), $dynamicContent);
+        return new VariableRef(DiviFixtures::designSystem(), $dynamicContent, $loop);
+    }
+
+    private static function postLoop(): LoopScope
+    {
+        return LoopScope::ofQueryType('post_types');
     }
 
     private static function content(string $name, string $settings): string
@@ -24,6 +30,12 @@ final class VariableRefTest extends TestCase
         return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
     }
 
+    /** Divi's one post meta loop option (DynamicContentOptionLoopPostMetaKey.php:218-227). */
+    private static function loopMeta(string $settings): string
+    {
+        return self::content('loop_post_meta_key_manual_custom_field', $settings);
+    }
+
     /** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */
     private static function escape(string $hex): string
     {
@@ -219,7 +231,18 @@ final class VariableRefTest extends TestCase
             self::content('post_date', '{"date_format":"d.m.Y","offset":3}'),
             $ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer)
         );
-        self::assertSame(self::content('loop_post_title', '{}'), $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer));
+        // Ruling X5: a loop_* name the map names still needs a loop of its kind.
+        try {
+            $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer);
+            self::fail('loop_post_title accepted without a loop');
+        } catch (InvalidArgumentException $e) {
+            self::assertStringContainsString('braucht eine Divi-Schleife', $e->getMessage());
+        }
+        $inLoop = self::ref(['loop_post_title' => []], self::postLoop());
+        self::assertSame(
+            self::content('loop_post_title', '{}'),
+            $inLoop->canonical(self::content('loop_post_title', '{}'), $sanitizer)
+        );
         // Names outside the map: custom_meta_ keeps its rule, the rest is unknown.
         self::assertSame(
             self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'),
@@ -241,6 +264,57 @@ final class VariableRefTest extends TestCase
         }
     }
 
+    /** Ruling X2/X5: loop content in a loop of its kind is cleaned like any other content reference. */
+    public function testLoopContentInALoopOfItsKind(): void
+    {
+        $meta = [
+            'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
+            'select_loop_meta_key' => 'select', 'loop_meta_key' => 'text', 'enable_html' => 'yes_no_button',
+        ];
+        $map = [
+            'loop_post_date' => [
+                'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
+                'date_format' => 'select', 'custom_date_format' => 'text',
+            ],
+            'loop_post_meta_key_manual_custom_field' => $meta,
+            'loop_term_meta_key_manual_custom_field' => $meta,
+        ];
+        $sanitizer = ComposeFixture::sanitizer();
+        $date = self::content(
+            'loop_post_date',
+            '{"before":"Am ","after":"","loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}'
+        );
+
+        self::assertSame($date, self::ref($map, self::postLoop())->canonical($date, $sanitizer));
+        self::assertSame(
+            self::content('loop_post_date', '{"before":"Am ","after":""}'),
+            self::ref($map, self::postLoop())->canonical(
+                self::content('loop_post_date', '{"before":"<script>x()</script>Am ","after":""}'),
+                $sanitizer
+            )
+        );
+
+        $terms = self::ref($map, LoopScope::ofQueryType('post_taxonomies'));
+        $termMeta = self::content('loop_term_meta_key_manual_custom_field', '{"select_loop_meta_key":"%s"}');
+        $farbe = sprintf($termMeta, 'loop_term_meta_key_farbe');
+        self::assertSame($farbe, $terms->canonical($farbe, $sanitizer));
+
+        $rejected = [
+            'Typ: Terme' => [$terms, self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}')],
+            'Meta-Schlüssel' => [$terms, sprintf($termMeta, 'loop_term_meta_key__x')],
+            'HTML-Ausgabe' => [self::ref($map, self::postLoop()), self::loopMeta('{"enable_html":"on"}')],
+            'braucht eine Divi-Schleife' => [self::ref($map), $date],
+        ];
+        foreach ($rejected as $fragment => [$ref, $value]) {
+            try {
+                $ref->canonical($value, $sanitizer);
+                self::fail('Accepted: ' . $value);
+            } catch (InvalidArgumentException $e) {
+                self::assertStringContainsString($fragment, $e->getMessage());
+            }
+        }
+    }
+
     /** S28: the split-marker rows really carry no marker before sanitizing. */
     public function testSplitMarkerRowsHaveNoRawMarker(): void
     {
@@ -258,8 +332,8 @@ final class VariableRefTest extends TestCase
         $ref = self::ref([
             'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'],
             'custom_meta__edit_lock' => ['before' => 'text'],
-            'loop_post_meta_key' => ['loop_meta_key' => 'text'],
-        ]);
+            'loop_post_meta_key_manual_custom_field' => ['loop_meta_key' => 'text'],
+        ], self::postLoop());
         $sanitizer = ComposeFixture::sanitizer();
 
         self::assertSame(
@@ -275,7 +349,7 @@ final class VariableRefTest extends TestCase
             self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key',
             self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key',
             self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key',
-            self::content('loop_post_meta_key', '{"loop_meta_key":"_secret"}') => 'loop_meta_key',
+            self::loopMeta('{"loop_meta_key":"_secret"}') => 'loop_meta_key',
             self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock',
         ];
         foreach ($rejected as $value => $fragment) {
@@ -300,20 +374,20 @@ final class VariableRefTest extends TestCase
                 self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'),
             ],
             'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')],
-            'loop post prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
-            'loop user prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
-            'loop term prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
+            'loop post prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
+            'loop user prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
+            'loop term prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
             'doubled loop prefix' => [
-                self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
+                self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
             ],
             'mixed loop and meta prefix' => [
-                self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
+                self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
             ],
             'mixed meta and loop prefix' => [
                 self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'),
             ],
             'two loop prefixes' => [
-                self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
+                self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
             ],
         ];
     }
@@ -328,10 +402,12 @@ final class VariableRefTest extends TestCase
     {
         $ref = self::ref([
             'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
-            'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
-        ]);
+            'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
+        ], self::postLoop());
 
         $this->expectException(InvalidArgumentException::class);
+        // The meta key rule refuses it – not the loop check (the post loop fits the post meta option).
+        $this->expectExceptionMessageMatches('/Meta-Schlüssel|Unbekannter Name/');
 
         $ref->canonical($value, ComposeFixture::sanitizer());
     }
@@ -340,18 +416,17 @@ final class VariableRefTest extends TestCase
     {
         $ref = self::ref([
             'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
-            'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
-        ]);
+            'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
+        ], self::postLoop());
         $sanitizer = ComposeFixture::sanitizer();
 
         foreach ([
             self::content('custom_meta_telefon', '{}'),
             self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'),
-            self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
-            self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
-            self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
-            self::content(
-                'loop_post_meta_key',
+            self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
+            self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
+            self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
+            self::loopMeta(
                 '{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}'
             ),
         ] as $value) {
  • Step 2b.2: Tests laufen lassen – RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'VariableRefTest|DynamicContentRulesTest' Expected: FAIL – Tests: 96, Assertions: 226, Failures: 16.: testWithoutALoopEveryLoopOptionIsRefused (Accepted: loop_post_title), die zwölf Zeilen von testAnOptionOfAnotherKindIsRefused (Accepted: …), testALoopOptionTheMapDoesNotNameIsUnknown (… ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).' contains "Unbekannter Name "loop_post_date""), VariableRefTest::testInjectedDynamicContentMap (loop_post_title accepted without a loop), VariableRefTest::testLoopContentInALoopOfItsKind (Accepted: …loop_post_meta_key_manual_custom_field…). Die umbenannten Meta-Schlüssel-Tests sind schon grün (heute nimmt die Map jeden genannten loop_*-Namen an; PHP ignoriert das überzählige Konstruktor-Argument) – sie sichern in 2b.4 ab, dass S29/S30 im Kontext greifen.

  • Step 2b.3: Regeln und VariableRef implementieren

src/Integrations/Divi/Compose/DynamicContentRules.php:

diff --git a/src/Integrations/Divi/Compose/DynamicContentRules.php b/src/Integrations/Divi/Compose/DynamicContentRules.php
--- a/src/Integrations/Divi/Compose/DynamicContentRules.php
+++ b/src/Integrations/Divi/Compose/DynamicContentRules.php
@@ -15,7 +15,8 @@ defined('ABSPATH') || exit;
  *
  * Allowed names: a non-color global variable or a theme font (no settings, DESIGN_SETTINGS,
  * S27); custom_meta_<key> with META_KEY (no leading "_", ruling S12; settings from the map entry
- * of that exact name, else META_SETTINGS); or a key of the dynamic content map.
+ * of that exact name, else META_SETTINGS); a loop_* key of the map only inside a Divi loop of its
+ * kind (constructor argument $loop, rulings X2/X5 – see loopFieldsFor()); or another key of the map.
  *
  * Dynamic content map (ruling S22), constructor argument $map:
  *   array<string, array<string, string>>  option name => [setting key => field type]
@@ -27,7 +28,7 @@ defined('ABSPATH') || exit;
  * Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry
  * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key =>
  * field "type"). Always, whatever the map says: "enable_html" is rejected (VariableRef), loop_*
- * names only when the map names them, custom_meta_<key> names and the values of settings whose
+ * names only in a loop of their kind, custom_meta_<key> names and the values of settings whose
  * key ends in "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's
  * own prefix stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28)
  * – may contain a reference or ")$". A map of another shape fails in the constructor with
@@ -86,13 +87,17 @@ final class DynamicContentRules
     /** @var array<string, array<string, string>> */
     private array $map;
 
+    private LoopScope $loop;
+
     /**
-     * @param array<array-key, mixed>|null $map See the class docblock.
+     * @param array<array-key, mixed>|null $map  See the class docblock.
+     * @param LoopScope|null               $loop The Divi loop the content sits in; null = none (no loop_* names).
      * @throws InvalidArgumentException When the map does not have that shape.
      */
-    public function __construct(private DesignSystem $design, ?array $map = null)
+    public function __construct(private DesignSystem $design, ?array $map = null, ?LoopScope $loop = null)
     {
         $this->map = $map === null ? self::DEFAULT_DYNAMIC_CONTENT : self::checkMap($map);
+        $this->loop = $loop ?? LoopScope::none();
     }
 
     /**
@@ -110,22 +115,53 @@ final class DynamicContentRules
             return $this->map[$name] ?? self::META_SETTINGS;
         }
 
+        if (str_starts_with($name, 'loop_')) {
+            return $this->loopFieldsFor($name);
+        }
+
         if (isset($this->map[$name])) {
             return $this->map[$name];
         }
 
-        if (str_starts_with($name, 'loop_')) {
+        if (in_array($name, $this->design->contentVariableIds(), true)) {
+            return self::DESIGN_SETTINGS;
+        }
+
+        throw VariableRef::unknownName($name, 'content');
+    }
+
+    /**
+     * A loop_* option (rulings X2/X5): only inside a Divi loop, only of the loop's kind (LoopScope::fits(),
+     * by the option name – Divi reads the meta of the loop object's ID whatever the object is, so a post
+     * meta option in a term loop would print the post with the term's ID: a security boundary, always an
+     * error), and only when the map names it. Its settings then follow the usual rules (meta keys S29/S30).
+     *
+     * @return array<string, string>
+     * @throws InvalidArgumentException
+     */
+    private function loopFieldsFor(string $name): array
+    {
+        if ($this->loop->queryType() === null) {
             throw new InvalidArgumentException(sprintf(
-                'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).',
+                'Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten '
+                    . 'Element (Abschnitt, Zeile, Spalte, Gruppe).',
                 $name
             ));
         }
 
-        if (in_array($name, $this->design->contentVariableIds(), true)) {
-            return self::DESIGN_SETTINGS;
+        if (! $this->loop->fits($name)) {
+            throw new InvalidArgumentException(sprintf(
+                'Schleifen-Inhalt "%s" passt nicht zur Schleife dieses Elements (Typ: %s).',
+                $name,
+                $this->loop->label()
+            ));
         }
 
-        throw VariableRef::unknownName($name, 'content');
+        if (! isset($this->map[$name])) {
+            throw VariableRef::unknownName($name, 'content');
+        }
+
+        return $this->map[$name];
     }
 
     /**

src/Integrations/Divi/Compose/VariableRef.php:

diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php
--- a/src/Integrations/Divi/Compose/VariableRef.php
+++ b/src/Integrations/Divi/Compose/VariableRef.php
@@ -27,7 +27,8 @@ defined('ABSPATH') || exit;
  * - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_<key>
  *   (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content
  *   setting. "enable_html" is always rejected here. Internal links post_link_url_<type> ({{link:…}},
- *   E13a) only in URL fields.
+ *   E13a) only in URL fields. loop_* options only inside a Divi loop of their kind (constructor
+ *   argument $loop, rulings X2/X5); without it no loop_* name is allowed.
  * A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException.
  */
 final class VariableRef
@@ -47,11 +48,12 @@ final class VariableRef
 
     /**
      * @param array<array-key, mixed>|null $dynamicContent See DynamicContentRules.
+     * @param LoopScope|null               $loop           The Divi loop of the values; null = none.
      * @throws InvalidArgumentException When the map does not have that shape.
      */
-    public function __construct(private DesignSystem $design, ?array $dynamicContent = null)
+    public function __construct(private DesignSystem $design, ?array $dynamicContent = null, ?LoopScope $loop = null)
     {
-        $this->content = new DynamicContentRules($design, $dynamicContent);
+        $this->content = new DynamicContentRules($design, $dynamicContent, $loop);
     }
 
     /** Whether $value contains a reference anywhere (such a value may only be a whole reference). */
  • Step 2b.4: GREEN, Gesamtlauf und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'VariableRefTest|DynamicContentRulesTest' && composer test && composer lint Expected: OK (96 tests, 250 assertions), dann OK (3414 tests, …); phpcs sauber. Unverändert grün bleiben insbesondere DiviFixtures::maliciousRefs() „loop content outside a loop“ (die Meldung nennt loop_post_title, in VariableRefTest, AttrWriterTest, ShortformWriterTest) und DynamicContentMapTest::testTheMapWorksInVariableRef ('Schleifen' steckt in „Schleifen-Inhalt“). Gegenprobe im Arbeitsstand: fits()-Prüfung entfernt → 13 Failures.

  • Step 2b.5: Commit
git add src/Integrations/Divi/Compose/DynamicContentRules.php src/Integrations/Divi/Compose/VariableRef.php tests/Unit/Integrations/Divi/Compose/DynamicContentRulesTest.php tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
git commit -m "feat: DynamicContentRules/VariableRef mit LoopScope – loop_* nur in passender Schleife (X2, X5)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 2c: Docblocks (S36 abgelöst) und Pin-Test der Loop-Map

Die S36-Stellen aus Recherche §5 im Code: DynamicContentRules.php:29-31 und :117-122 (in 2b), DynamicContentMap.php:28-29, :67 (in 2a), DiviDynamicSourcesAbility.php:25 (hier). Placeholders.php gehört zu Task 3, Tool-Beschreibungen und Guides zu Task 7, Spec/Notizen zu Task 9. Der neue Test pinnt die Kombination, die Task 4 baut (forPost() + loopFields() mit Kontext) – er ist sofort grün (Charakterisierung, kein RED).

  • Step 2c.1: Docblock, Testkommentar und Pin-Test
diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
--- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
+++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
@@ -22,8 +22,8 @@ defined('ABSPATH') || exit;
 /**
  * lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
  * (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0;
- * no loop_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
- * the fields of the type usable as {{field:<key>}}.
+ * no loop_* – they need a node in a Divi loop, ruling X6; via DynamicContentMap::options()) with the
+ * placeholder that writes each, and the fields of the type usable as {{field:<key>}}.
  *
  * Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
  * table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
--- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
+++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
@@ -101,7 +101,7 @@ final class DynamicContentMapTest extends TestCase
         // enable_html stays forbidden even though the registry lists it.
         $html = $ref('post_meta_key', ['enable_html' => 'on']);
         self::assertRejected(static fn () => $references->canonical($html, $sanitizer), 'enable_html');
-        // Loop content: Divi offers it only inside loops – never through the tools.
+        // Loop content needs a Divi loop of its kind (ruling X5) – this VariableRef has none.
         $loop = $ref('loop_post_title', []);
         self::assertRejected(static fn () => $references->canonical($loop, $sanitizer), 'Schleifen');
     }
@@ -276,6 +276,36 @@ final class DynamicContentMapTest extends TestCase
         self::assertArrayNotHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
     }
 
+    /** The map a loop composition builds (Task 4): forPost() + loopFields() of the loop's kind. */
+    public function testLoopFieldsWorkInVariableRefInsideALoopOfTheirKind(): void
+    {
+        $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
+        $terms = LoopScope::ofQueryType('post_taxonomies');
+        $references = new VariableRef(
+            DiviFixtures::designSystem(),
+            (array) $map->forPost(831) + $map->loopFields((string) $terms->kind(), 831),
+            $terms
+        );
+        $sanitizer = ComposeFixture::sanitizer();
+        $ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings);
+
+        $name = $ref('loop_term_name', ['before' => 'Kategorie: ', 'after' => '', 'loop_position' => '']);
+        self::assertSame($name, $references->canonical($name, $sanitizer));
+        $title = $ref('post_title', ['before' => '', 'after' => '']);
+        self::assertSame($title, $references->canonical($title, $sanitizer));
+        self::assertRejected(
+            static fn () => $references->canonical($ref('loop_post_title', []), $sanitizer),
+            'passt nicht zur Schleife dieses Elements (Typ: Terme)'
+        );
+        self::assertRejected(
+            static fn () => $references->canonical(
+                $ref('loop_term_meta_key_manual_custom_field', ['enable_html' => 'on']),
+                $sanitizer
+            ),
+            'enable_html'
+        );
+    }
+
     public function testLoopFieldsReadTheRegistryOfThePostAndAreEmptyWithoutIt(): void
     {
         $seen = [];
  • Step 2c.2: Test laufen lassen

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DynamicContentMapTest Expected: OK (15 tests, 99 assertions).

  • Step 2c.3: Task-Abschluss und Commit

Run: composer test && composer lint Expected: OK (3415 tests, 21157 assertions); phpcs sauber. grep -rn "S36\|nur innerhalb von Divi-Schleifen" src tests findet nichts mehr.

git add src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
git commit -m "docs/test: S36-Hinweise abgelöst (X5); loopFields() in VariableRef gepinnt" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 3: Kurzform {{loop:<option>}} in Placeholders (X4)

{{loop:<kurzname>}} schreibt die Divi-Loop-Option loop_<kurzname> (z. B. {{loop:post_date}} → loop_post_date), mit leeren before/after wie {{post:…}} (A10). Die Tabelle Placeholders::LOOP nennt die echten Optionen aus Divi 5.15.1 (Recherche 4e, Tabelle §2) ohne Präfix: Post (post_*), Term (term_*), Benutzer (user_*), Menü (menu_*). Bewusst nicht in der Tabelle: die drei Meta-Schlüssel-Optionen loop_<post|user|term>_meta_key_manual_custom_field (sie brauchen den Schlüssel als Einstellung select_loop_meta_key/loop_meta_key, also nur Vollform), loop_product_* (WooCommerce, außerhalb des Umfangs) und loop_acf_* (X7 e).

Welche Optionen before/after tragen (X4): Placeholders hat keinen Zugriff auf die DynamicContentMap. Es bekommt deshalb im Konstruktor (optionaler dritter Parameter) die Loop-Felder seiner Komposition – genau das Ergebnis von DynamicContentMap::loopFields() für den Schleifen-Kontext –, und schreibt before/after nur, wenn die Option dort ein before hat (Divi: alle außer loop_post_author_profile_picture, loop_post_featured_image und den Produkt-Bildern). Steht die Option dort nicht (kein Schleifen-Kontext oder andere Art), schreibt der Platzhalter {} – die Prüfung in VariableRef/DynamicContentRules (Task 2) lehnt sie dann mit der passenden X5-Meldung ab; der Platzhalter selbst bleibt kontextfrei. Die Komposition speist die Loop-Felder erst in Task 4 ein (DiviComposition::placeholders()); bis dahin ist der Parameter [].

Dokumentations-Haken: nur Code-Docblocks (Klassen-Docblock, LOOP). ShortformMap/ShortformCheck validieren keine Platzhalter (nur die Beschreibung ShortformMap.php:179 für Farben) und bleiben unverändert; die Guides (divi-placeholders.md, divi-shortform.md) schreibt Task 7. DiviDynamicSourcesAbility::placeholder() nennt {{loop:…}} ab Task 5.

Files:

  • Modify: src/Integrations/Divi/Compose/Placeholders.php
  • Modify: tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php

Interfaces:

  • Consumes: LoopScope::ofQueryType() (Task 1), VariableRef::__construct(DesignSystem, ?array, ?LoopScope) (Task 2), DynamicContentMap::fromOptions() (vorhanden), die X5-Regeln in DynamicContentRules (Task 2: loop_* aus der Map nur mit aktivem Kontext).

  • Produces:

    • Placeholders::LOOP (public const, Kurzname ⇒ Option, 31 Einträge).
    • Placeholders::__construct(DesignSystem $design, ?LinkTargets $links = null, array $loopFields = []) – $loopFields = array<string, array<string, string>> (Option ⇒ Einstellung ⇒ Feldtyp, Form von DynamicContentMap::loopFields()). Bestehende Aufrufe (DiviComposition::placeholders(), ComposeFixture::placeholders(), Tests) bleiben gültig.
    • Fehler „Unbekannter Schleifen-Inhalt "%s". Vorhanden: … Den Namen ohne "loop_" angeben (z. B. {{loop:post_date}}); Schleifen-Felder mit Meta-Schlüssel nur in der Vollform (lit/divi-dynamic-sources).“ und die Liste „bekannt: color, var, font, post, field, link, loop“.
  • Step 3.1: Failing Tests in PlaceholdersTest ergänzen

In tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php (Import, Konstante am Klassenanfang, vier Fehlerzeilen, zwei Tests):

diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
--- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
+++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
 
 use InvalidArgumentException;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Compose\Placeholders;
 use LIT\MCP\Integrations\Divi\Compose\VariableRef;
 use LIT\MCP\Integrations\Divi\Design\DesignSystem;
@@ -15,6 +16,15 @@ use PHPUnit\Framework\TestCase;
 
 final class PlaceholdersTest extends TestCase
 {
+    /** Loop fields of a post loop as DynamicContentMap::loopFields() gives them (Divi 5.15.1 shapes). */
+    private const POST_LOOP_FIELDS = [
+        'loop_post_date' => [
+            'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
+            'date_format' => 'select', 'custom_date_format' => 'text',
+        ],
+        'loop_post_featured_image' => ['loop_position' => 'text', 'thumbnail_size' => 'select'],
+    ];
+
     /**
      * Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode
      * refuses colour references by design).
@@ -78,6 +88,10 @@ final class PlaceholdersTest extends TestCase
             'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
             'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
             'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
+            'embedded loop value' => ['Am {{loop:post_date}}', 'post, field, link, loop'],
+            'unknown loop value' => ['{{loop:views}}', 'Unbekannter Schleifen-Inhalt "views". Vorhanden: post_title,'],
+            'loop value with its prefix' => ['{{loop:loop_post_date}}', 'ohne "loop_"'],
+            'loop meta key option' => ['{{loop:post_meta_key_manual_custom_field}}', 'nur in der Vollform'],
             'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
             'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
             'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],
@@ -168,4 +182,44 @@ final class PlaceholdersTest extends TestCase
 
         (new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
     }
+
+    /**
+     * Ruling X4: {{loop:<option>}} names the loop option without its prefix; before/after only where the loop
+     * fields of the composition's scope give the option those settings (Divi has none on image options).
+     */
+    public function testLoopPlaceholdersNameTheLoopOption(): void
+    {
+        $placeholders = new Placeholders(DiviFixtures::designSystem(), null, self::POST_LOOP_FIELDS);
+
+        $date = $placeholders->resolve('{{ loop : Post_Date }}');
+        self::assertSame(
+            '$variable({"type":"content","value":{"name":"loop_post_date","settings":{"before":"","after":""}}})$',
+            $date
+        );
+        self::assertSame(
+            '$variable({"type":"content","value":{"name":"loop_post_featured_image","settings":{}}})$',
+            $placeholders->resolve('{{loop:post_featured_image}}')
+        );
+        // Not in the scope's fields (no loop, another kind): no settings – VariableRef names the loop problem.
+        self::assertSame(
+            '$variable({"type":"content","value":{"name":"loop_term_name","settings":{}}})$',
+            $placeholders->resolve('{{loop:term_name}}')
+        );
+
+        // What the placeholder writes passes the checker of a post loop unchanged.
+        $references = new VariableRef(
+            DiviFixtures::designSystem(),
+            DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + self::POST_LOOP_FIELDS,
+            LoopScope::ofQueryType('post_types')
+        );
+        self::assertSame($date, $references->canonical($date, ComposeFixture::sanitizer()));
+    }
+
+    public function testEveryLoopPlaceholderNamesALoopOption(): void
+    {
+        foreach (Placeholders::LOOP as $short => $option) {
+            self::assertSame('loop_' . $short, $option);
+            self::assertStringNotContainsString('meta_key', $option, 'meta key options need settings (full form)');
+        }
+    }
 }
  • Step 3.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php Expected: FAIL – Tests: 35, Assertions: 67, Errors: 2, Failures: 4: die vier neuen Fehlerzeilen bekommen die alte Meldung „Platzhalter müssen allein im Wert stehen … (bekannt: color, var, font, post, field, link).“, testLoopPlaceholdersNameTheLoopOption wirft dieselbe Meldung, testEveryLoopPlaceholderNamesALoopOption endet mit Error: Undefined constant …Placeholders::LOOP.

  • Step 3.3: Placeholders erweitern
diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php
--- a/src/Integrations/Divi/Compose/Placeholders.php
+++ b/src/Integrations/Divi/Compose/Placeholders.php
@@ -12,7 +12,9 @@ defined('ABSPATH') || exit;
 /**
  * Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
  * {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
- * {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a).
+ * {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a),
+ * {{loop:<option>}} (a loop option without "loop_", ruling X4 – whether the node sits in a loop of the
+ * option's kind VariableRef checks against the composition's LoopScope, not this class).
  * A placeholder must be the whole value (D9); unknown names fail with the list of existing ones,
  * labels two entries share fail with their IDs (ruling S13a, DesignSystem::colorIdFor/variableFor).
  * The result is still checked by VariableRef wherever it is written. Pure PHP.
@@ -32,16 +34,64 @@ final class Placeholders
         'tags' => 'post_tags',
     ];
 
+    /**
+     * {{loop:<key>}} => Divi loop option (Divi 5.15.1, DynamicContentOption*.php): the key is the option without
+     * "loop_". The meta key options (loop_*_meta_key_manual_custom_field) are missing on purpose – they need the
+     * meta key as a setting, so only the full form can write them.
+     */
+    public const LOOP = [
+        'post_title' => 'loop_post_title',
+        'post_excerpt' => 'loop_post_excerpt',
+        'post_date' => 'loop_post_date',
+        'post_modified_date' => 'loop_post_modified_date',
+        'post_author' => 'loop_post_author',
+        'post_author_bio' => 'loop_post_author_bio',
+        'post_author_profile_picture' => 'loop_post_author_profile_picture',
+        'post_comment_count' => 'loop_post_comment_count',
+        'post_featured_image' => 'loop_post_featured_image',
+        'post_id' => 'loop_post_id',
+        'post_link' => 'loop_post_link',
+        'post_terms' => 'loop_post_terms',
+        'term_name' => 'loop_term_name',
+        'term_description' => 'loop_term_description',
+        'term_count' => 'loop_term_count',
+        'term_permalink' => 'loop_term_permalink',
+        'term_taxonomy' => 'loop_term_taxonomy',
+        'term_featured_image' => 'loop_term_featured_image',
+        'user_name' => 'loop_user_name',
+        'user_username' => 'loop_user_username',
+        'user_email' => 'loop_user_email',
+        'user_avatar' => 'loop_user_avatar',
+        'user_description' => 'loop_user_description',
+        'user_url' => 'loop_user_url',
+        'menu_text' => 'loop_menu_text',
+        'menu_link' => 'loop_menu_link',
+        'menu_menu_order' => 'loop_menu_menu_order',
+        'menu_attr_title' => 'loop_menu_attr_title',
+        'menu_classes' => 'loop_menu_classes',
+        'menu_xfn' => 'loop_menu_xfn',
+        'menu_description' => 'loop_menu_description',
+    ];
+
     /** Dynamic content that is text and takes before/after settings. */
     private const TEXT_SETTINGS = [
         'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags',
     ];
 
-    private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/';
+    private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link|loop)\s*:\s*([^{}]+?)\s*\}\}$/';
 
-    /** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */
-    public function __construct(private DesignSystem $design, private ?LinkTargets $links = null)
-    {
+    /**
+     * @param LinkTargets|null                     $links      Entries {{link:…}} may point to; without them every
+     *                                                         link fails.
+     * @param array<string, array<string, string>> $loopFields Loop options of the composition's loop scope
+     *                                                         (DynamicContentMap::loopFields): {{loop:…}} writes
+     *                                                         before/after only for options that have them.
+     */
+    public function __construct(
+        private DesignSystem $design,
+        private ?LinkTargets $links = null,
+        private array $loopFields = []
+    ) {
     }
 
     /**
@@ -55,7 +105,7 @@ final class Placeholders
             if (str_contains($value, '{{')) {
                 throw new InvalidArgumentException(
                     'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
-                        . 'post, field, link).'
+                        . 'post, field, link, loop).'
                 );
             }
 
@@ -70,6 +120,7 @@ final class Placeholders
             'font' => $this->font($name),
             'post' => $this->post($name),
             'link' => $this->link($name),
+            'loop' => $this->loop($name),
             default => $this->field($name),
         };
     }
@@ -154,6 +205,31 @@ final class Placeholders
         return VariableRef::serialize('content', $option, $settings);
     }
 
+    /**
+     * {{loop:<key>}} → loop_<key> with empty before/after where the scope's loop fields have them, else no
+     * settings. Outside a loop or in one of another kind the option is not in those fields: the reference is
+     * written without settings and VariableRef refuses it with the loop message (ruling X5).
+     *
+     * @throws InvalidArgumentException
+     */
+    private function loop(string $name): string
+    {
+        $option = self::LOOP[strtolower($name)] ?? null;
+        if ($option === null) {
+            throw new InvalidArgumentException(sprintf(
+                'Unbekannter Schleifen-Inhalt "%s". Vorhanden: %s. Den Namen ohne "loop_" angeben '
+                    . '(z. B. {{loop:post_date}}); Schleifen-Felder mit Meta-Schlüssel nur in der Vollform '
+                    . '(lit/divi-dynamic-sources).',
+                $name,
+                implode(', ', array_keys(self::LOOP))
+            ));
+        }
+
+        $settings = isset($this->loopFields[$option]['before']) ? ['before' => '', 'after' => ''] : [];
+
+        return VariableRef::serialize('content', $option, $settings);
+    }
+
     /**
      * {{link:<ID>}} → Divi's own internal link post_link_url_<type> with settings.post_id (F27):
      * rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.
  • Step 3.4: GREEN, Gesamtlauf, Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php Expected: OK (35 tests, 133 assertions).

Run: composer test && composer lint Expected: grün, sechs Tests mehr als nach Task 2 (vier Datenzeilen, zwei Tests); phpcs ohne Meldung.

  • Step 3.5: Commit
git add src/Integrations/Divi/Compose/Placeholders.php tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
git commit -m "feat: Kurzform {{loop:<option>}} in Placeholders – Tabelle LOOP, before/after aus den Loop-Feldern der Komposition (X4)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4: Schleifen-Kontext in lit-divi-insert, lit-divi-update, lit-divi-move (X3)

Die Werkzeuge bestimmen den Schleifen-Kontext im Blockbaum (LoopContext, Task 1) und komponieren in einer an diesen Kontext gebundenen Komposition (DiviComposition::inLoop()). Die unteren Schichten (Composer, AttrWriter, ShortformWriter, ValueCleaner) bleiben unverändert: der Kontext steckt in der VariableRef der Komposition.

Entscheidungen (X3, verbindlich für diesen Task):

  • Kontext an einem Knoten: DiviWriteAbility::loopAt(NodeIndex, ?Block, array $newAttrs = []) baut die Attributkette Wurzel → Knoten über NodeIndex::parentBlock() und gibt sie an LoopContext::fromChain(); $newAttrs (Pfad-ID ⇒ Attribute) ersetzt die gespeicherten Attribute von Knoten, die derselbe Aufruf ändert. Top-Level (null) → LoopScope::none(). Ein Helfer für alle drei Werkzeuge.
  • DiviComposition::inLoop(LoopScope): Klon nach dem Muster forPost() (gemeinsamer privater fresh(), der alle Caches leert). references() = new VariableRef($design, $map + loopFields, $scope) mit $map = forPost($postId); placeholders() bekommt dieselben Loop-Felder (Task 3). Ohne Eintrag (postId === null) oder ohne Registry (forPost() = null) gibt es keine Loop-Felder → loop_* bleibt abgelehnt (fail closed). Ruling S31 heißt jetzt: eine VariableRef je Komposition, eine Komposition je Eintrag und Schleifen-Kontext (Klassen-Docblock; die Rulings-Notiz 2026-09-26-etappe-2b-rulings.md ergänzt Task 9).
  • Insert: alle neuen Knoten eines Aufrufs teilen den Kontext des Ziel-Elternknotens ($position['parent']). Ein sauberer Weg, den Kontext für die Nachfahren eines neuen Knotens mit eigener loop-Einstellung umzuschalten, fehlt: Composer::compose() komponiert den ganzen Teilbaum mit einer Komposition, ein Neukomponieren einzelner Unterspezifikationen (Abschnitt → Zeile → Spalte → Modul) würde den Composer aufbrechen. Deshalb fail closed: schaltet ein neuer Knoten oder ein Nachfahre eine Schleife ein (LoopContext::enabled() auf den komponierten Blöcken) und die Eingabe enthält Schleifen-Inhalt ({{loop:…}} oder ein Verweis auf eine loop_*-Option), lehnt das Werkzeug den ganzen Aufruf ab und nennt den Weg („zuerst das Element mit Schleife einfügen, dann die Schleifen-Inhalte mit lit-divi-insert (parent = Pfad-ID des Elements) ergänzen“). Das schließt auch die Sicherheitslücke, dass eine neue Term-Schleife in einer Post-Schleife loop_post_*-Inhalte gegen den äußeren Kontext bestehen ließe (Divi läse dann Post-Meta mit der Term-ID). Grobkörnig: auch Schleifen-Inhalte an anderer Stelle desselben Aufrufs fallen darunter (dokumentiert, Guide in Task 7).
  • Update: pro Änderung der Kontext des bestehenden Knotens. Zwei Durchgänge in DiviUpdateAbility::composeInLoops(): (1) Composer::update() je Änderung im Kontext der gespeicherten Vorfahren; (2) mit den neuen Attributen des Aufrufs (Knoten selbst und geänderte Vorfahren) wird der Kontext neu bestimmt, und nur Änderungen, deren Kontext sich dadurch verschiebt, werden im neuen Kontext erneut komponiert. Loop-Attribute selbst enthalten keinen Dynamic Content, also sieht Durchgang 2 dieselben Schleifen. Ein Composer je Kontext (Cache nach queryType, '' = keine Schleife) – ohne Schleifen bleibt es bei genau einem Composer wie bisher.
  • Move: ändert keine Werte, prüft also nichts; Kontext alt = Vorfahren des Knotens, Kontext neu = Ziel-Elternknoten, beide aus dem Baum vor dem Verschieben (TreeEditor::move() garantiert, dass das Ziel nicht im Knoten liegt). Enthält der verschobene Teilbaum loop_*-Verweise (über VariableRef::nameOf(), Muster ThemeBuilderEdits::collectKeys(), ohne opake Blöcke und ohne Teilbäume mit eigener Schleife) und unterscheidet sich die Art (kind()) von alt und neu, kommt eine Warnung über DiviChange::warning() (A11: Divi lässt das zu). Fälle: raus aus jeder Schleife („Divi zeigt sie dort leer“) oder in eine Schleife anderer Art bzw. aus keiner in eine („Typ: Terme (vorher: Beiträge|keine Schleife)“). Ein Knoten mit eigener Schleife nimmt sie mit – keine Warnung.
  • lit-content-create und Theme-Builder-Anlage (LayoutBuilder über DiviContentComposer/TbCreateTemplateAbility mit forPost(…)) haben nie einen Schleifen-Kontext: keine Code-Änderung, loop_* wird mit der X5-Meldung „braucht eine Divi-Schleife …“ abgelehnt (Test in 4e; auch neue Loop-Container mit Inhalt in einem Aufruf – fail closed wie Insert).
  • lit-divi-node-copy, lit/library-insert, StoredNodeGuard: keine Änderung (X7 c). storedGuard() kommt aus der Komposition ohne inLoop() → kopierte loop_*-Verweise bleiben für Benutzer ohne unfiltered_html abgelehnt (jetzt mit der X5-Meldung); Benutzer mit unfiltered_html kopieren wie bisher ungeprüft (StoredNodeGuard::problems() gibt []), auch in Schleifen anderer Art – Restrisiko für Task 8.
  • Update ohne Warnung: schaltet ein Aufruf über attrs (module.advanced.loop, X7 a) die Schleife eines Containers um, dessen unveränderte Kinder loop_* zeigen, prüft das niemand (wie heute). Restrisiko für Task 8.

Files:

  • Modify: src/Integrations/Divi/Pages/DiviComposition.php (4a)
  • Modify: src/Integrations/Divi/Pages/DiviWriteAbility.php, src/Integrations/Divi/Pages/DiviInsertAbility.php (4b)
  • Modify: src/Integrations/Divi/Pages/DiviUpdateAbility.php (4c)
  • Modify: src/Integrations/Divi/Pages/DiviMoveAbility.php (4d)
  • Modify: tests/Support/DiviWriterFixture.php (4b: writer() bekommt die Registry)
  • Create: tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php (4b, erweitert in 4c/4d)
  • Modify: tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php (4a), tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php (4e)

Interfaces:

  • Consumes: LoopScope::{none, ofQueryType, active, kind, queryType, label}, LoopContext::{enabled, fromChain} (Task 1); DynamicContentMap::loopFields(string $kind, int $postId = 0) (Task 2, siehe ABWEICHUNGEN A1); VariableRef::__construct(DesignSystem, ?array, ?LoopScope) und die X5-Meldungen (Task 2); Placeholders::__construct(…, array $loopFields) (Task 3).
  • Produces:
    • DiviComposition::inLoop(LoopScope $scope): static (Vertrag: self; static wie forPost(), ABWEICHUNGEN A3).
    • DiviWriteAbility::loopAt(NodeIndex $index, ?Block $block, array $newAttrs = []): LoopScope (protected static).
    • DiviWriterFixture::writer(…, ?SimilarSections $similar = null, ?DynamicContentMap $registry = null) (Testhilfe).
    • Neue Fehlermeldung (Insert): „Schleifen-Inhalte unter einer neuen Divi-Schleife gehen nicht in einem Aufruf; nichts wurde gespeichert.“ (Feld nodes); neue Warnung (Move): „Schleifen-Inhalte (loop_…) liegen jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer. Zurückverschieben oder die Inhalte anpassen.“ bzw. „… liegen jetzt in einer Divi-Schleife vom Typ: Terme (vorher: Beiträge); passen sie nicht zu dieser Art, zeigt Divi sie leer oder mit Daten eines anderen Objekts mit derselben ID. …“.

Testseite (in DiviLoopWriteTest, kein Fixture-File – die echte Loop-Fixture bringt Task 6): s0.r0.c0 Post-Schleife (m0 zeigt loop_post_title, m1 fester Text, m2 mit eigener Term-Schleife und loop_term_name), s0.r0.c1 Term-Schleife, s0.r0.c2 ohne Schleife. Die Registry ist DiviFixtures::dynamicContentRegistry() plus loop_post_date (Gruppe Loop) und loop_term_name (Gruppe Loop Terms) in Divi-5.15.1-Form (Union +: Einträge, die Task 2 in DiviFixtures ergänzt, gewinnen).

Task 4a: DiviComposition::inLoop()

  • Step 4a.1: Failing Test in DiviCompositionTest ergänzen
diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
--- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
+++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Block;
 use LIT\MCP\Integrations\Divi\BlockTree\Document;
 use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
 use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
 use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
 use LIT\MCP\Integrations\Divi\Compose\VariableRef;
@@ -265,4 +266,36 @@ final class DiviCompositionTest extends TestCase
         self::assertCount(1, $covering);
         self::assertStringContainsString('„CTA primär“', $covering[0]);
     }
+
+    /**
+     * Ruling X3: inLoop() gives a fresh composition bound to a loop scope – placeholders and references of the
+     * entry's map plus the loop options of that kind; outside a loop and in another kind loop content fails.
+     */
+    public function testInLoopChecksLoopContentAgainstItsScope(): void
+    {
+        $post = $this->composition()->forPost(7);
+        $inPostLoop = $post->inLoop(LoopScope::ofQueryType('post_types'));
+        $compose = static fn (DiviComposition $composition): array => $composition->composer()->compose(
+            [['type' => 'text', 'content' => '{{loop:post_title}}']],
+            Block::create('divi/column'),
+            'nodes'
+        );
+
+        $inside = $compose($inPostLoop);
+        self::assertSame([], $inside['errors']);
+        self::assertSame(
+            VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']),
+            AttrMap::desktop($inside['blocks'][0]->attrs(), 'content.innerContent')
+        );
+
+        self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', $compose($post)['errors']));
+        self::assertStringContainsString(
+            'passt nicht zur Schleife dieses Elements (Typ: Terme)',
+            implode(' ', $compose($post->inLoop(LoopScope::ofQueryType('post_taxonomies')))['errors'])
+        );
+
+        self::assertNotSame($post->references(), $inPostLoop->references());
+        self::assertNotSame($post->placeholders(), $inPostLoop->placeholders());
+        self::assertSame($inPostLoop->references(), $inPostLoop->references(), 'one VariableRef per scope');
+    }
 }
  • Step 4a.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php Expected: FAIL – Tests: 12, Assertions: 43, Errors: 1 mit Error: Call to undefined method LIT\MCP\Integrations\Divi\Pages\DiviComposition::inLoop().

  • Step 4a.3: DiviComposition erweitern
diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php
--- a/src/Integrations/Divi/Pages/DiviComposition.php
+++ b/src/Integrations/Divi/Pages/DiviComposition.php
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
 use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
 use LIT\MCP\Integrations\Divi\Compose\Composer;
 use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
 use LIT\MCP\Integrations\Divi\Compose\Placeholders;
 use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
@@ -27,10 +28,11 @@ defined('ABSPATH') || exit;
 
 /**
  * Wires the pure Compose classes with the live catalog, design system, Divi's dynamic content
- * registry and the WordPress sanitizer/media lookup – once per request and entry. Ruling S31:
- * ONE VariableRef (carrying the live dynamic content map of the entry, S22), ONE Placeholders
- * and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the
- * same AttrWriter serves Composer and the abilities.
+ * registry and the WordPress sanitizer/media lookup – once per request, entry and loop scope. Ruling S31
+ * (as reworded by X3): ONE VariableRef per composition (carrying the live dynamic content map of the entry, S22,
+ * plus the loop options of its LoopScope), ONE Placeholders and ONE ValueCleaner instance are shared by
+ * AttrWriter, ShortformWriter and Composer, and the same AttrWriter serves Composer and the abilities. A tool whose
+ * nodes sit in several loop scopes uses one composition – and so one Composer – per scope (inLoop()).
  *
  * Presets (D7) reach blocks through ONE PresetAssignment per composition: its book is Divi's stored preset
  * inventory (DesignSystemWriter::presets()), read lazily and at most once – only when a write names a preset or a
@@ -38,8 +40,9 @@ defined('ABSPATH') || exit;
  * Without a preset source (null, unit tests) there are no presets.
  *
  * forPost() binds the composition to the entry being written (DiviWriter::composition() does it
- * for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be
- * tested with a mock.
+ * for the abilities); unbound, VariableRef's default map applies. inLoop() binds it to the loop scope of the
+ * nodes it composes (LoopContext); without it the scope is none and every loop_* option is refused. Not final so
+ * abilities can be tested with a mock.
  */
 class DiviComposition
 {
@@ -51,6 +54,10 @@ class DiviComposition
     private ?AttrWriter $attrWriter = null;
     private ?Composer $composer = null;
     private ?PresetAssignment $assignment = null;
+    private LoopScope $loop;
+
+    /** @var array<string, array<string, string>>|null Loop options of $loop for the entry (read once). */
+    private ?array $loopFields = null;
 
     public function __construct(
         private DiviModules $modules,
@@ -61,35 +68,40 @@ class DiviComposition
         private LinkTargets $links = new WpLinkTargets(),
         private ?DesignSystemWriter $presetSource = null
     ) {
+        $this->loop = LoopScope::none();
     }
 
     /** A fresh composition for entry $postId (own VariableRef with its live map; TB area for StructureRules). */
     public function forPost(int $postId, ?string $themeBuilderArea = null): static
     {
-        $bound = clone $this;
+        $bound = $this->fresh();
         $bound->postId = $postId;
         $bound->themeBuilderArea = $themeBuilderArea;
-        $bound->references = null;
-        $bound->placeholders = null;
-        $bound->cleaner = null;
-        $bound->attrWriter = null;
-        $bound->composer = null;
-        $bound->assignment = null;
 
         return $bound;
     }
 
     /**
-     * The one checker of $variable(…)$ values of this composition, with the entry's live map (S22, S31).
+     * A fresh composition of the same entry for nodes inside $scope (ruling X3): its VariableRef and Placeholders
+     * know the loop options of that kind; the abilities take $scope from LoopContext.
+     */
+    public function inLoop(LoopScope $scope): static
+    {
+        $bound = $this->fresh();
+        $bound->loop = $scope;
+
+        return $bound;
+    }
+
+    /**
+     * The one checker of $variable(…)$ values of this composition: the entry's live map (S22, S31) plus the loop
+     * options of its scope, and the scope itself for the loop messages (X5).
      *
      * @internal Public for tests; code uses cleaner() / composer().
      */
     public function references(): VariableRef
     {
-        return $this->references ??= new VariableRef(
-            $this->design->read(),
-            $this->postId === null ? null : $this->dynamicContent->forPost($this->postId)
-        );
+        return $this->references ??= new VariableRef($this->design->read(), $this->dynamicContentMap(), $this->loop);
     }
 
     /**
@@ -99,7 +111,7 @@ class DiviComposition
      */
     public function placeholders(): Placeholders
     {
-        return $this->placeholders ??= new Placeholders($this->design->read(), $this->links);
+        return $this->placeholders ??= new Placeholders($this->design->read(), $this->links, $this->loopFields());
     }
 
     /** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */
@@ -161,4 +173,37 @@ class DiviComposition
     {
         return new Warnings($this->design->read(), $this->presets());
     }
+
+    /** @return array<string, array<string, string>>|null The entry's live map with the scope's loop options. */
+    private function dynamicContentMap(): ?array
+    {
+        $map = $this->postId === null ? null : $this->dynamicContent->forPost($this->postId);
+
+        return $map === null ? null : $map + $this->loopFields();
+    }
+
+    /** @return array<string, array<string, string>> The loop options of this scope for the entry ([] without). */
+    private function loopFields(): array
+    {
+        $kind = $this->loop->kind();
+
+        return $this->loopFields ??= $this->postId === null || $kind === null
+            ? []
+            : $this->dynamicContent->loopFields($kind, $this->postId);
+    }
+
+    /** A clone without any cached instance (they hang on the entry and the loop scope). */
+    private function fresh(): static
+    {
+        $bound = clone $this;
+        $bound->references = null;
+        $bound->placeholders = null;
+        $bound->cleaner = null;
+        $bound->attrWriter = null;
+        $bound->composer = null;
+        $bound->assignment = null;
+        $bound->loopFields = null;
+
+        return $bound;
+    }
 }

dynamicContentMap() liest die Registry nur beim ersten references() (der Test testOneLiveMapIsSharedByTheFullAndTheShortForm – „the registry is read once per post“ – bleibt grün); eine Komposition mit Schleife fragt die Registry zweimal (forPost() und loopFields()), siehe ABWEICHUNGEN A6.

  • Step 4a.4: GREEN und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php && composer lint Expected: OK (12 tests, 50 assertions); phpcs ohne Meldung.

  • Step 4a.5: Commit
git add src/Integrations/Divi/Pages/DiviComposition.php tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
git commit -m "feat: DiviComposition::inLoop() – VariableRef und Placeholders mit Schleifen-Kontext; S31 je Kontext (X3)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4b: Insert im Kontext des Ziel-Elternknotens, fail closed bei neuer Schleife

  • Step 4b.1: DiviWriterFixture::writer() um die Registry erweitern
diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php
--- a/tests/Support/DiviWriterFixture.php
+++ b/tests/Support/DiviWriterFixture.php
@@ -92,7 +92,8 @@ final class DiviWriterFixture
     }
 
     /**
-     * The writer over the page of DiviFixtures::givenPost(), saving into $saved (see saver()).
+     * The writer over the page of DiviFixtures::givenPost(), saving into $saved (see saver()); $registry is the
+     * composition's dynamic content map (see composition()).
      *
      * @param list<string>    $saved
      * @param list<Throwable> $failures
@@ -104,7 +105,8 @@ final class DiviWriterFixture
         ?DiviPageLoader $loader = null,
         ?GlobalEdits $globals = null,
         ?ThemeBuilderEdits $themeBuilder = null,
-        ?SimilarSections $similar = null
+        ?SimilarSections $similar = null,
+        ?DynamicContentMap $registry = null
     ): DiviWriter {
         $design = self::designReader();
 
@@ -113,7 +115,7 @@ final class DiviWriterFixture
             self::saver($saved, $failures),
             self::log(),
             $design,
-            self::composition($design, $unfiltered),
+            self::composition($design, $unfiltered, $registry),
             $globals,
             $themeBuilder,
             $similar
  • Step 4b.2: Failing Test DiviLoopWriteTest anlegen (Insert-Teil)

Datei tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php anlegen (die Helfer changeAll() und moveNode() nutzen 4c/4d):

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;

use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use WP_Error;

/**
 * Ruling X3: lit/divi-insert, lit/divi-update and lit/divi-move use the loop scope of the node in the tree
 * (LoopContext). The page: s0.r0.c0 runs a post loop (m0 shows loop_post_title, m1 is plain text, m2 runs its own
 * term loop and shows loop_term_name), s0.r0.c1 a term loop, s0.r0.c2 no loop.
 */
final class DiviLoopWriteTest extends DiviWriteAbilityTestCase
{
    private const POST_LOOP = ['enable' => 'on', 'queryType' => 'post_types'];
    private const TERM_LOOP = ['enable' => 'on', 'queryType' => 'post_taxonomies'];

    protected function setUp(): void
    {
        parent::setUp();
        $this->original = self::page();
        $this->post->post_content = $this->original;
        $registry = self::registry();
        $this->writer = DiviWriterFixture::writer(
            $this->saved,
            registry: new DynamicContentMap(static fn (int $postId): array => $registry)
        );
    }

    /** DiviFixtures' registry plus the loop options these tests name (Divi 5.15.1 groups and fields). */
    private static function registry(): array
    {
        $field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => ''];
        $common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')];
        $option = static fn (string $id, string $group, array $fields): array => [
            'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
        ];

        return DiviFixtures::dynamicContentRegistry() + [
            'loop_post_date' => $option('loop_post_date', 'Loop', $common + [
                'date_format' => $field('select'), 'custom_date_format' => $field('text'),
            ]),
            'loop_term_name' => $option('loop_term_name', 'Loop Terms', $common),
        ];
    }

    /** @param array<string, mixed> $attrs */
    private static function block(string $name, array $attrs, string $inner = ''): string
    {
        $json = (string) json_encode($attrs + ['builderVersion' => '5.2.1'], JSON_UNESCAPED_UNICODE);

        return sprintf('<!-- wp:divi/%s %s -->%s<!-- /wp:divi/%s -->', $name, $json, $inner, $name);
    }

    /** @param array<string, string> $loop */
    private static function loop(array $loop): array
    {
        return ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => $loop]]]]];
    }

    private static function text(string $value): string
    {
        return self::block('text', ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]]);
    }

    private static function page(): string
    {
        $title = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']);
        $term = VariableRef::serialize('content', 'loop_term_name', ['before' => '', 'after' => '']);
        $own = self::block('text', self::loop(self::TERM_LOOP) + [
            'content' => ['innerContent' => ['desktop' => ['value' => $term]]],
        ]);

        return '<!-- wp:divi/placeholder -->' . self::block('section', [], self::block('row', [], implode('', [
            self::block('column', self::loop(self::POST_LOOP), self::text($title) . self::text('<p>Fest</p>') . $own),
            self::block('column', self::loop(self::TERM_LOOP), self::text('<p>Term</p>')),
            self::block('column', [], self::text('<p>Ohne</p>')),
        ]))) . '<!-- /wp:divi/placeholder -->';
    }

    /** @param array<string, mixed> $input */
    private function insertInto(string $parent, array $input): mixed
    {
        return $this->insert()->execute(
            ['post_id' => 7, 'tree_rev' => $this->currentRev(), 'parent' => $parent] + $input
        );
    }

    /** @param list<array<string, mixed>> $changes */
    private function changeAll(array $changes): mixed
    {
        return $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev(), 'changes' => $changes]);
    }

    /** @param array<string, mixed> $input */
    private function moveNode(array $input): mixed
    {
        return $this->move()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev()] + $input);
    }

    /** tree_rev of what the page holds now (the saver puts every saved content on the post). */
    private function currentRev(): string
    {
        return Rev::of((string) $this->post->post_content);
    }

    private static function problems(mixed $error): string
    {
        self::assertInstanceOf(WP_Error::class, $error);

        return implode(' | ', self::errorFields($error));
    }

    public function testInsertUsesTheLoopOfTheTargetParent(): void
    {
        $result = $this->insertInto('s0.r0.c0', ['nodes' => [
            ['type' => 'text', 'content' => '{{loop:post_date}}'],
            ['type' => 'text', 'content' => '{{loop:post_title}}'],
        ]]);

        self::assertTrue($result['ok'], (string) json_encode($result));
        $date = VariableRef::serialize('content', 'loop_post_date', ['before' => '', 'after' => '']);
        self::assertStringContainsString(str_replace('"', '\u0022', $date), $this->saved[0]);

        $term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:term_name}}']]]);
        self::assertTrue($term['ok'], (string) json_encode($term));
    }

    public function testInsertRefusesLoopContentOutsideALoopOrOfAnotherKind(): void
    {
        $outside = $this->insertInto('s0.r0.c2', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]);
        self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($outside));

        $term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]);
        $wrongKind = self::problems($term);
        self::assertStringContainsString('passt nicht zur Schleife dieses Elements (Typ: Terme)', $wrongKind);

        $topLevel = $this->insert()->execute([
            'post_id' => 7,
            'tree_rev' => $this->currentRev(),
            'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]],
        ]);
        self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($topLevel));
        self::assertSame([], $this->saved);
    }

    /**
     * Ruling X3, fail closed: a loop a new node of this call switches on counts only once it is saved – loop
     * content in the same call is refused even where the parent's loop would let it pass (a term loop inside the
     * post loop c0 would otherwise get post content: Divi reads the post with the term's ID).
     */
    public function testANewLoopInTheSameCallIsRefusedWithLoopContent(): void
    {
        $ownLoop = ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]];

        foreach (['s0.r0.c0' => '{{loop:post_title}}', 's0.r0.c2' => '{{loop:term_name}}'] as $parent => $content) {
            $refused = $this->insertInto($parent, ['nodes' => [
                ['type' => 'text', 'attrs' => $ownLoop, 'content' => $content],
            ]]);
            self::assertStringContainsString('erst nach dem Speichern', self::problems($refused), $parent);
        }
        self::assertSame([], $this->saved);

        $withoutLoopContent = $this->insertInto('s0.r0.c2', ['nodes' => [
            ['type' => 'text', 'attrs' => $ownLoop, 'content' => '<p>Liste</p>'],
        ]]);
        self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent));
    }
}
  • Step 4b.3: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php Expected: FAIL – Tests: 3, Assertions: 15, Errors: 1, Failures: 2: testInsertUsesTheLoopOfTheTargetParent bekommt ein WP_Error statt ok („Cannot use object of type WP_Error as array“, die Komposition hat keinen Kontext), testInsertRefusesLoopContentOutsideALoopOrOfAnotherKind findet „braucht eine Divi-Schleife …“ statt „passt nicht zur Schleife dieses Elements (Typ: Terme)“, testANewLoopInTheSameCallIsRefusedWithLoopContent findet „braucht eine Divi-Schleife …“ statt „erst nach dem Speichern“.

  • Step 4b.4: DiviWriteAbility::loopAt() ergänzen
diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php
--- a/src/Integrations/Divi/Pages/DiviWriteAbility.php
+++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php
@@ -6,6 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
 
 use LIT\MCP\Abilities\Ability;
 use LIT\MCP\Abilities\ToolResult;
+use LIT\MCP\Integrations\Divi\BlockTree\Block;
+use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Release\DiviFeature;
 use LIT\MCP\Integrations\Divi\Release\FeatureSets;
 use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
@@ -114,4 +118,20 @@ abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures
     {
         return null;
     }
+
+    /**
+     * Ruling X3: the loop scope at $block – its nearest loop-enabled ancestor-or-self (LoopContext, X1); none at
+     * the top level. $newAttrs (node ID => attrs) replaces the stored attrs of nodes this call changes.
+     *
+     * @param array<string, array<string, mixed>> $newAttrs
+     */
+    protected static function loopAt(NodeIndex $index, ?Block $block, array $newAttrs = []): LoopScope
+    {
+        $chain = [];
+        for ($node = $block; $node !== null; $node = $index->parentBlock($node)) {
+            array_unshift($chain, $newAttrs[(string) $index->idOf($node)] ?? $node->attrs());
+        }
+
+        return LoopContext::fromChain($chain);
+    }
 }
  • Step 4b.5: DiviInsertAbility im Kontext komponieren lassen
diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php
--- a/src/Integrations/Divi/Pages/DiviInsertAbility.php
+++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php
@@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages;
 
 use LIT\MCP\Abilities\Annotations;
 use LIT\MCP\Abilities\Errors;
+use LIT\MCP\Integrations\Divi\BlockTree\Block;
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
+use LIT\MCP\Integrations\Divi\Compose\VariableRef;
 use LIT\MCP\Integrations\Divi\Release\DiviFeature;
 use WP_Error;
 
@@ -17,6 +20,9 @@ defined('ABSPATH') || exit;
  * validated first and every error is returned together; the blocks come only from Composer
  * (client values pass its choke point) and enter the tree only through TreeEditor::insert(),
  * which also wraps the first section of an empty page in divi/placeholder (ruling S9).
+ *
+ * Loop content (ruling X3): all new nodes share the loop scope of the target parent. A loop a new node of this
+ * call switches on counts only once it is saved – with loop content in the same call the insert fails closed.
  */
 final class DiviInsertAbility extends DiviWriteAbility
 {
@@ -97,7 +103,18 @@ final class DiviInsertAbility extends DiviWriteAbility
             ]);
         }
 
-        $composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context);
+        $scope = self::loopAt($page->index, $position['parent']);
+        $composed = $composition->inLoop($scope)->composer()->compose($nodes, $position['parent'], 'nodes', $context);
+        if (self::startsLoop($composed['blocks']) && self::hasLoopContent($nodes)) {
+            return Errors::invalidInput(
+                'Schleifen-Inhalte unter einer neuen Divi-Schleife gehen nicht in einem Aufruf; nichts wurde '
+                    . 'gespeichert.',
+                ['nodes' => 'Die Schleife eines neuen Elements gilt erst nach dem Speichern: zuerst das Element mit '
+                    . 'Schleife einfügen, dann die Schleifen-Inhalte mit lit-divi-insert (parent = Pfad-ID des '
+                    . 'Elements) ergänzen']
+            );
+        }
+
         if ($composed['errors'] !== []) {
             return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.');
         }
@@ -117,4 +134,38 @@ final class DiviInsertAbility extends DiviWriteAbility
 
         return $change;
     }
+
+    /**
+     * Whether one of $blocks or a descendant switches a loop on.
+     *
+     * @param list<Block> $blocks
+     */
+    private static function startsLoop(array $blocks): bool
+    {
+        foreach ($blocks as $block) {
+            if (LoopContext::enabled($block->attrs()) || self::startsLoop($block->children())) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Whether the input names loop content: a {{loop:…}} placeholder or a reference to a loop_* option.
+     *
+     * @param array<array-key, mixed> $nodes
+     */
+    private static function hasLoopContent(array $nodes): bool
+    {
+        $found = false;
+        array_walk_recursive($nodes, static function (mixed $value) use (&$found): void {
+            $found = $found || (is_string($value) && (
+                preg_match('/^\s*\{\{\s*loop\s*:/', $value) === 1
+                || str_starts_with((string) VariableRef::nameOf($value), 'loop_')
+            ));
+        });
+
+        return $found;
+    }
 }
  • Step 4b.6: GREEN, Insert-Suite, Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php Expected: OK (3 tests, 29 assertions).

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages && composer lint Expected: grün (die bestehenden Insert-Tests, auch die Byte-Identität in DiviInsertAbilityTest, bleiben unverändert); phpcs ohne Meldung.

  • Step 4b.7: Commit
git add src/Integrations/Divi/Pages/DiviWriteAbility.php src/Integrations/Divi/Pages/DiviInsertAbility.php \
    tests/Support/DiviWriterFixture.php tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php
git commit -m "feat: lit-divi-insert prüft Schleifen-Inhalte im Kontext des Ziels; neue Schleife plus Inhalt in einem Aufruf fail closed (X3)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4c: Update – ein Composer je Kontext, neue Loop-Attribute desselben Aufrufs

  • Step 4c.1: Failing Tests an DiviLoopWriteTest anhängen

Vor der schließenden } der Klasse einfügen:


    /** Ruling X3: one Composer per loop scope – changes in a post loop and a term loop in one call. */
    public function testUpdateChecksEveryChangeInItsOwnLoop(): void
    {
        $result = $this->changeAll([
            ['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:post_date}}']],
            ['id' => 's0.r0.c1.m0', 'set' => ['content' => '{{loop:term_name}}']],
            ['id' => 's0.r0.c2.m0', 'set' => ['content' => '<p>Neu</p>']],
        ]);

        self::assertTrue($result['ok'], (string) json_encode($result));
        self::assertStringContainsString('loop_term_name', $this->saved[0]);

        $refused = $this->changeAll([
            ['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:term_name}}']],
            ['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:post_date}}']],
        ]);
        $problems = self::errorFields($refused);
        self::assertStringContainsString('Typ: Beiträge', $problems['changes[0].set.content'] ?? '');
        self::assertStringContainsString('braucht eine Divi-Schleife', $problems['changes[1].set.content'] ?? '');
    }

    /**
     * Ruling X3: the scope of a change takes the loop attrs this call gives the node or an ancestor – a loop
     * switched on admits its content, a loop switched to another kind refuses the old kind's content.
     */
    public function testUpdateUsesTheLoopsThisCallSets(): void
    {
        $switchedOn = $this->changeAll([
            ['id' => 's0.r0.c2', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
            ['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:term_name}}']],
        ]);
        self::assertTrue($switchedOn['ok'], (string) json_encode($switchedOn));

        $own = $this->changeAll([[
            'id' => 's0.r0.c0.m1',
            'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]],
            'set' => ['content' => '{{loop:term_name}}'],
        ]]);
        self::assertTrue($own['ok'], (string) json_encode($own));

        $switchedKind = $this->changeAll([
            ['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
            ['id' => 's0.r0.c0.m0', 'set' => ['content' => '{{loop:post_date}}']],
        ]);
        $problems = self::errorFields($switchedKind);
        self::assertStringContainsString('Typ: Terme', $problems['changes[1].set.content'] ?? '');
        self::assertCount(2, $this->saved);
    }
  • Step 4c.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php Expected: FAIL – Tests: 5, Assertions: 31, Errors: 2: beide Update-Tests bekommen ein WP_Error („Cannot use object of type WP_Error as array“), weil der eine Composer ohne Kontext jeden {{loop:…}}-Wert ablehnt.

  • Step 4c.3: DiviUpdateAbility umbauen

Vorher (Zeile 126 ff.) ein $composer = $composition->composer(); für alle Änderungen und update() im Validierungs-Loop; nachher sammelt der Loop nur [$block, $change, $field] und composeInLoops() komponiert je Kontext:

diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php
--- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php
+++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php
@@ -7,7 +7,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
 use LIT\MCP\Abilities\Annotations;
 use LIT\MCP\Abilities\Errors;
 use LIT\MCP\Integrations\Divi\BlockTree\Block;
+use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
 use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
+use LIT\MCP\Integrations\Divi\Compose\Composer;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
 use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
 use LIT\MCP\Integrations\Divi\Release\DiviFeature;
 use WP_Error;
@@ -21,6 +24,9 @@ defined('ABSPATH') || exit;
  * come back together; only then are the attributes Composer::update() computed set. A node may
  * appear once per call (ruling S14); set and attrs of one change must not reach the same path
  * (ruling S38, checked by Composer::update()).
+ *
+ * Loop content (ruling X3): each change is checked in the loop scope of its node – the stored ancestors plus the
+ * loop attrs this call gives the node or an ancestor – with one Composer per scope.
  */
 final class DiviUpdateAbility extends DiviWriteAbility
 {
@@ -123,14 +129,13 @@ final class DiviUpdateAbility extends DiviWriteAbility
             ]);
         }
 
-        $composer = $composition->composer();
         $errors = [];
         /** @var list<string> $invalidPaths */
         $invalidPaths = [];
         /** @var array<string, int> $seen id => position of its first change */
         $seen = [];
-        /** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
-        $updates = [];
+        /** @var list<array{0: Block, 1: array<array-key, mixed>, 2: string}> $pending */
+        $pending = [];
         foreach ($changes as $position => $change) {
             $field = sprintf('changes[%d]', $position);
             if (! is_array($change)) {
@@ -159,10 +164,15 @@ final class DiviUpdateAbility extends DiviWriteAbility
                 continue;
             }
 
-            $result = $composer->update($block, $change, $field);
+            $pending[] = [$block, $change, $field];
+        }
+
+        /** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
+        $updates = [];
+        foreach (self::composeInLoops($page->index, $composition, $pending) as $position => $result) {
             $errors += $result['errors'];
             array_push($invalidPaths, ...$result['invalid_paths']);
-            $updates[] = [$block, $result['attrs'], $field];
+            $updates[] = [$pending[$position][0], $result['attrs'], $pending[$position][2]];
         }
 
         if ($errors === []) {
@@ -189,4 +199,46 @@ final class DiviUpdateAbility extends DiviWriteAbility
 
         return $change;
     }
+
+    /**
+     * Composer::update() of every pending change in its loop scope (ruling X3). First with the stored ancestors;
+     * then changes whose scope the new attrs of this call move (a loop switched on, off or to another kind on the
+     * node or an ancestor) are composed again in that scope – loop attrs are no client content, so the second
+     * pass sees the same loops.
+     *
+     * @param list<array{0: Block, 1: array<array-key, mixed>, 2: string}> $pending
+     * @return list<array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}>
+     */
+    private static function composeInLoops(NodeIndex $index, DiviComposition $composition, array $pending): array
+    {
+        /** @var array<string, Composer> $composers query type ('' = no loop) => Composer of that scope */
+        $composers = [];
+        $scopes = [];
+        $results = [];
+        foreach ($pending as $position => [$block, $change, $field]) {
+            $scopes[$position] = self::loopAt($index, $block);
+            $results[$position] = self::composerFor($composition, $scopes[$position], $composers)
+                ->update($block, $change, $field);
+        }
+
+        $newAttrs = [];
+        foreach ($pending as $position => [$block]) {
+            $newAttrs[(string) $index->idOf($block)] = $results[$position]['attrs'];
+        }
+        foreach ($pending as $position => [$block, $change, $field]) {
+            $scope = self::loopAt($index, $block, $newAttrs);
+            if ($scope->queryType() !== $scopes[$position]->queryType()) {
+                $results[$position] = self::composerFor($composition, $scope, $composers)
+                    ->update($block, $change, $field);
+            }
+        }
+
+        return $results;
+    }
+
+    /** @param array<string, Composer> $composers One Composer per loop scope (S31 as reworded by X3). */
+    private static function composerFor(DiviComposition $composition, LoopScope $scope, array &$composers): Composer
+    {
+        return $composers[$scope->queryType() ?? ''] ??= $composition->inLoop($scope)->composer();
+    }
 }

Die Reihenfolge der Fehler ändert sich nur darin, dass id-Fehler (changes[i].id) vor den Kompositionsfehlern stehen; die Fehler sind nach Feldpfad geschlüsselt, DiviUpdateAbilityTest bleibt unverändert grün.

  • Step 4c.4: GREEN, Update-Suite, Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php Expected: OK (5 tests, 43 assertions).

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php && composer lint Expected: OK (16 tests, 143 assertions); phpcs ohne Meldung.

  • Step 4c.5: Commit
git add src/Integrations/Divi/Pages/DiviUpdateAbility.php tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php
git commit -m "feat: lit-divi-update prüft jede Änderung im Schleifen-Kontext ihres Knotens – ein Composer je Kontext, neue loop-Attribute zählen (X3, S31)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4d: Move-Warnung

  • Step 4d.1: Failing Tests an DiviLoopWriteTest anhängen

Vor der schließenden } der Klasse einfügen:


    public function testMovingLoopContentOutOfItsLoopWarns(): void
    {
        $out = $this->moveNode([
            'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
        ]);
        self::assertTrue($out['ok'], 'Divi allows the move (A11): a warning, no refusal');
        $warnings = implode(' | ', $out['warnings']);
        self::assertStringContainsString('loop_post_title', $warnings);
        self::assertStringContainsString('außerhalb jeder Divi-Schleife', $warnings);

        $other = $this->moveNode([
            'id' => 's0.r0.c2.m1', 'parent' => 's0.r0.c1',
        ]);
        self::assertStringContainsString('Typ: Terme (vorher: keine Schleife)', implode(' | ', $other['warnings']));
    }

    public function testMovesThatKeepTheLoopDoNotWarn(): void
    {
        $withinLoop = $this->moveNode([
            'id' => 's0.r0.c0.m0', 'after' => 's0.r0.c0.m1',
        ]);
        $plain = $this->moveNode([
            'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
        ]);
        // A module with its own loop takes the loop along.
        $loopItself = $this->moveNode([
            'id' => 's0.r0.c0.m1', 'parent' => 's0.r0.c2',
        ]);

        foreach ([$withinLoop, $plain, $loopItself] as $result) {
            self::assertTrue($result['ok'], (string) json_encode($result));
            self::assertStringNotContainsString('Schleife', implode(' | ', $result['warnings']));
        }
    }
  • Step 4d.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php Expected: FAIL – Tests: 7, Assertions: 53, Failures: 1: testMovingLoopContentOutOfItsLoopWarns – Failed asserting that '' contains "loop_post_title". testMovesThatKeepTheLoopDoNotWarn ist schon grün (heute warnt nichts); er sichert die Bedingung gegen Fehlalarme ab.

  • Step 4d.3: DiviMoveAbility warnen lassen
diff --git a/src/Integrations/Divi/Pages/DiviMoveAbility.php b/src/Integrations/Divi/Pages/DiviMoveAbility.php
--- a/src/Integrations/Divi/Pages/DiviMoveAbility.php
+++ b/src/Integrations/Divi/Pages/DiviMoveAbility.php
@@ -6,6 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
 
 use LIT\MCP\Abilities\Annotations;
 use LIT\MCP\Abilities\Errors;
+use LIT\MCP\Integrations\Divi\BlockTree\Block;
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
+use LIT\MCP\Integrations\Divi\Compose\VariableRef;
 use LIT\MCP\Integrations\Divi\Release\DiviFeature;
 use WP_Error;
 
@@ -14,7 +18,8 @@ defined('ABSPATH') || exit;
 /**
  * lit/divi-move: moves a node (with everything inside) to parent / before / after – also into
  * another row or section, as long as the structure rules allow it (spec §5.7). Read-only
- * nodes (global modules, foreign blocks) move as a whole, unchanged.
+ * nodes (global modules, foreign blocks) move as a whole, unchanged. Loop content that leaves its loop scope (out
+ * of a loop, into one of another kind) is moved with a warning – Divi allows it (rulings X3, A11).
  */
 final class DiviMoveAbility extends DiviWriteAbility
 {
@@ -76,6 +81,9 @@ final class DiviMoveAbility extends DiviWriteAbility
             return DiviErrors::position($position);
         }
 
+        // Both scopes from the tree before the move: the new parent never lies inside $node (TreeEditor::move()).
+        $from = self::loopAt($page->index, $page->index->parentBlock($node));
+        $to = self::loopAt($page->index, $position['parent']);
         $problem = $editor->move($page->index, $node, $position);
         if ($problem !== null) {
             return Errors::invalidInput(
@@ -84,6 +92,58 @@ final class DiviMoveAbility extends DiviWriteAbility
             );
         }
 
-        return (new DiviChange(sprintf('Knoten %s verschoben', $id)))->record($node, 'moved');
+        $change = (new DiviChange(sprintf('Knoten %s verschoben', $id)))->record($node, 'moved');
+        $names = self::loopContent($node);
+        if ($names !== [] && $from->kind() !== $to->kind()) {
+            $change->warning(self::loopWarning($names, $from, $to));
+        }
+
+        return $change;
+    }
+
+    /**
+     * The loop_* options $block and its descendants show in the scope around $block; a block that runs its own
+     * loop keeps its content's scope, read-only blocks are not entered.
+     *
+     * @return list<string>
+     */
+    private static function loopContent(Block $block): array
+    {
+        if ($block->isOpaque() || LoopContext::enabled($block->attrs())) {
+            return [];
+        }
+
+        $names = [];
+        $attrs = $block->attrs();
+        array_walk_recursive($attrs, static function (mixed $value) use (&$names): void {
+            $name = is_string($value) ? VariableRef::nameOf($value) : null;
+            if ($name !== null && str_starts_with($name, 'loop_')) {
+                $names[] = $name;
+            }
+        });
+        foreach ($block->children() as $child) {
+            array_push($names, ...self::loopContent($child));
+        }
+
+        return array_values(array_unique($names));
+    }
+
+    /** @param list<string> $names */
+    private static function loopWarning(array $names, LoopScope $from, LoopScope $to): string
+    {
+        $where = $to->active()
+            ? sprintf(
+                'jetzt in einer Divi-Schleife vom Typ: %s (vorher: %s); passen sie nicht zu dieser Art, zeigt Divi '
+                    . 'sie leer oder mit Daten eines anderen Objekts mit derselben ID',
+                $to->label(),
+                $from->active() ? $from->label() : 'keine Schleife'
+            )
+            : 'jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer';
+
+        return sprintf(
+            'Schleifen-Inhalte (%s) liegen %s. Zurückverschieben oder die Inhalte anpassen.',
+            implode(', ', $names),
+            $where
+        );
     }
 }
  • Step 4d.4: GREEN, Move-Suite, Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php Expected: OK (7 tests, 55 assertions).

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviMoveAbilityTest.php && composer lint Expected: grün (Byte-Identität „move there and back“ unverändert); phpcs ohne Meldung.

  • Step 4d.5: Commit
git add src/Integrations/Divi/Pages/DiviMoveAbility.php tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php
git commit -m "feat: lit-divi-move warnt, wenn Schleifen-Inhalte ihre Schleife verlassen oder in eine andere Art kommen (X3, A11)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4e: Neue Einträge ohne Schleifen-Kontext (Absicherung)

  • Step 4e.1: Test in DiviContentComposerTest ergänzen
diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
--- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
+++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
@@ -177,6 +177,17 @@ final class DiviContentComposerTest extends TestCase
         self::assertSame([], $this->saved);
     }
 
+    /** Ruling X3: a new entry has no loop scope (nor has a new Theme Builder layout, same LayoutBuilder). */
+    public function testNewEntriesRefuseLoopContentWithTheLoopMessage(): void
+    {
+        $refused = $this->composer()->check('page', [
+            ['type' => 'section', 'modules' => [['type' => 'text', 'content' => '{{loop:post_title}}']]],
+        ]);
+
+        self::assertInstanceOf(WP_Error::class, $refused);
+        self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', self::errorFields($refused)));
+    }
+
     public function testAFailingSaveIsThrown(): void
     {
         $this->saver = Mockery::mock(DiviSaver::class);
  • Step 4e.2: Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php Expected: OK (10 tests, 54 assertions) – sofort grün: Absicherungstest ohne Code-Änderung (DiviContentComposer und TbCreateTemplateAbility rufen nie inLoop()). Er bricht, falls jemand dort später einen Kontext einspeist oder Task 2 die Meldung ändert.

  • Step 4e.3: Gesamtlauf und Lint

Run: composer test && composer lint Expected: grün, neun Tests mehr als nach Task 3 (1 + 7 + 1); phpcs ohne Meldung.

  • Step 4e.4: Commit
git add tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
git commit -m "test: lit-content-create hat keinen Schleifen-Kontext – loop_* mit der Schleifen-Meldung abgelehnt (X3)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 5: lit/divi-dynamic-sources mit post_id + node (X6)

Optional post_id (Ganzzahl) und node (Pfad-ID), nur zusammen. Dann fragt das Werkzeug Divis Registry mit dem Eintrag selbst (statt eines Beispiel-Eintrags, sample_post_id = post_id) und ergänzt:

  • loop: {node, query_type, label} – query_type/label null ohne Schleife (LoopContext::of(), Task 1; label() deutsch: Beiträge, Terme, Benutzer, Menü);
  • loop_sources: [{name, placeholder, settings}] aus DynamicContentMap::loopFields($kind, $postId) (Task 2) – Einstellung ⇒ Feldtyp wie bei sources (JsonMap::of), Platzhalter {{loop:<kurz>}} aus Placeholders::LOOP (Task 3), Meta-Schlüssel-Optionen ohne Platzhalter (Vollform);
  • ohne Schleife eine Notiz „Knoten … liegt in keiner Divi-Schleife (kein Schleifen-Kontext): …“ und loop_sources = []. sources bleibt ohne loop_* (DynamicContentMap::options(), Smoke divi-library-smoke.sh:344-349 gilt weiter). loop/loop_sources stehen nicht in required und fehlen ohne node.

Rechte und Laden: Muster lit-divi-node-get: DiviPageLoader::load($postId, false) in permission() und erneut in execute() (Leserecht = edit_post, auch Bibliothekseinträge und Theme-Builder-Layouts über ihre Zugriffe); node gegen NodeIndex::ID_PATTERN, unbekannter Knoten → DiviErrors::unknownNode() (not_found). Der Typ des Eintrags muss post_type sein (sonst validation_failed). Read-Tools nehmen keine rev/tree_rev entgegen; die Antwort bleibt ohne tree_rev (sie beschreibt Möglichkeiten, keinen Lesestand). Die Fehlerprüfungen laufen vor jedem Registry-Aufruf.

Fähigkeiten: Ohne post_id unverändert [dynamic-sources, roles] (Tabelle DiviToolFeaturesTest::EXPECTED bleibt); mit post_id zusätzlich page-read und FeatureSets::ofEntry($postId, false) wie bei lit-divi-node-get – zwei neue Input-Fälle in DiviToolFeaturesTest::inputCases().

Verdrahtung: DiviIntegration::abilities() gibt den schon vorhandenen $loader mit (benanntes Argument loader:); DiviIntegrationTest und DiviToolFeaturesTest bauen die Abilities über DiviIntegration und bleiben grün (der Loader wird erst in permission()/execute() benutzt). Ohne Loader (Konstruktor-Standard null) antworten post_id/node mit unsupported.

Files:

  • Modify: src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
  • Modify: src/Integrations/Divi/DiviIntegration.php
  • Modify: tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php
  • Modify: tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php

Interfaces:

  • Consumes: LoopContext::of(NodeIndex, string), LoopScope::{kind, queryType, label} (Task 1); DynamicContentMap::loopFields(string $kind, int $postId = 0) (Task 2, A1); Placeholders::LOOP (Task 3); DiviPageLoader::load(), DiviErrors::unknownNode(), FeatureSets::{merge, ofEntry}, NodeIndex::ID_PATTERN (vorhanden).

  • Produces:

    • DiviDynamicSourcesAbility::__construct(ContentSchema $schema, DynamicContentMap $map, DiviRoles $roles, ?Closure $samplePost = null, ?Closure $podStorage = null, ?DiviPageLoader $loader = null).
    • Eingabe {post_type (Pflicht), post_id?, node?}; Ausgabe zusätzlich loop?, loop_sources? (siehe oben).
  • Step 5.1: Failing Tests in DiviDynamicSourcesAbilityTest ergänzen

diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php
--- a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php
+++ b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
 
 use Brain\Monkey\Functions;
 use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
+use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
 use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
 use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
 use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
@@ -16,6 +17,9 @@ use Mockery;
 
 final class DiviDynamicSourcesAbilityTest extends TestCase
 {
+    /** @var list<int> Post IDs Divi's registry was asked for. */
+    private array $registryCalls = [];
+
     protected function setUp(): void
     {
         parent::setUp();
@@ -51,17 +55,111 @@ final class DiviDynamicSourcesAbilityTest extends TestCase
     private function ability(
         array $deniedRoles = [],
         ?string $storage = 'meta',
-        ?array $registry = null
+        ?array $registry = null,
+        ?DiviPageLoader $loader = null
     ): DiviDynamicSourcesAbility {
         $registry ??= DiviFixtures::dynamicContentRegistry();
 
         return new DiviDynamicSourcesAbility(
             $this->schema(),
-            new DynamicContentMap(static fn (int $id): array => $registry),
+            new DynamicContentMap(function (int $id) use ($registry): array {
+                $this->registryCalls[] = $id;
+
+                return $registry;
+            }),
             LibraryFixtures::roles($deniedRoles),
             static fn (string $postType): int => 831,
-            static fn (string $postType): ?string => $storage
+            static fn (string $postType): ?string => $storage,
+            $loader
+        );
+    }
+
+    /**
+     * Ruling X6: the ability with a loader over post 7 – s0.r0.c0 runs a term loop, s0.r0.c1 none – and a
+     * registry with one post and one term loop option (Divi 5.15.1 groups and fields).
+     */
+    private function withLoopPage(): DiviDynamicSourcesAbility
+    {
+        $loop = ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => [
+            'enable' => 'on', 'queryType' => 'post_taxonomies',
+        ]]]]]];
+        $text = '<!-- wp:divi/text {"builderVersion":"5.2.1"} --><!-- /wp:divi/text -->';
+        $column = static fn (array $attrs): string => '<!-- wp:divi/column '
+            . json_encode($attrs + ['builderVersion' => '5.2.1']) . ' -->' . $text . '<!-- /wp:divi/column -->';
+        DiviFixtures::givenPost(
+            '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} --><!-- wp:divi/row '
+                . '{"builderVersion":"5.2.1"} -->' . $column($loop) . $column([])
+                . '<!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
+        );
+        $field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => ''];
+        $common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')];
+        $registry = DiviFixtures::dynamicContentRegistry() + [
+            'loop_post_date' => ['label' => 'Datum', 'group' => 'Loop', 'fields' => $common + [
+                'date_format' => $field('select'),
+            ]],
+            'loop_term_name' => ['label' => 'Name', 'group' => 'Loop Terms', 'fields' => $common],
+        ];
+
+        return $this->ability([], 'meta', $registry, new DiviPageLoader(DiviFixtures::schema()));
+    }
+
+    /** Ruling X6: post_id + node add the loop sources of the node's loop kind, read from that entry. */
+    public function testANodeInALoopAddsTheLoopSourcesOfItsKind(): void
+    {
+        $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c0.m0']);
+
+        self::assertSame(7, $result['sample_post_id'], 'the entry itself, not a sample');
+        self::assertSame([7], array_values(array_unique($this->registryCalls)));
+        self::assertSame(
+            ['node' => 's0.r0.c0.m0', 'query_type' => 'post_taxonomies', 'label' => 'Terme'],
+            $result['loop']
         );
+        self::assertSame(['loop_term_name'], array_column($result['loop_sources'], 'name'));
+        self::assertSame('{{loop:term_name}}', $result['loop_sources'][0]['placeholder']);
+        self::assertEquals(
+            ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
+            (array) $result['loop_sources'][0]['settings']
+        );
+        self::assertSame([], array_filter(
+            array_column($result['sources'], 'name'),
+            static fn (string $name): bool => str_starts_with($name, 'loop_')
+        ));
+    }
+
+    public function testANodeOutsideALoopGetsNoLoopSources(): void
+    {
+        $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c1.m0']);
+
+        self::assertSame(['node' => 's0.r0.c1.m0', 'query_type' => null, 'label' => null], $result['loop']);
+        self::assertSame([], $result['loop_sources']);
+        self::assertStringContainsString('kein Schleifen-Kontext', implode(' ', $result['notes']));
+    }
+
+    public function testWithoutANodeTheAnswerHasNoLoopPart(): void
+    {
+        $result = $this->ability()->execute(['post_type' => 'page']);
+
+        self::assertArrayNotHasKey('loop', $result);
+        self::assertArrayNotHasKey('loop_sources', $result);
+    }
+
+    public function testPostIdAndNodeComeTogetherAndNeedTheEntry(): void
+    {
+        $ability = $this->withLoopPage();
+
+        foreach ([['post_id' => 7], ['node' => 's0']] as $half) {
+            self::assertToolError($ability->permission(['post_type' => 'page'] + $half), 'validation_failed');
+            self::assertToolError($ability->execute(['post_type' => 'page'] + $half), 'validation_failed');
+        }
+        $node = ['post_type' => 'page', 'post_id' => 7, 'node' => 's0'];
+        self::assertToolError($ability->execute(['node' => 's9'] + $node), 'not_found');
+        self::assertToolError($ability->execute(['node' => 'x'] + $node), 'validation_failed');
+        self::assertToolError($ability->execute(['post_type' => 'post'] + $node), 'validation_failed');
+
+        Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post');
+        self::assertToolError($ability->permission($node), 'forbidden');
+        self::assertToolError($ability->execute($node), 'forbidden');
+        self::assertSame([], $this->registryCalls, 'nothing is read for a refused entry');
     }
 
     public function testListsDiviSourcesWithTheirPlaceholders(): void
  • Step 5.2: Input-Fälle in DiviToolFeaturesTest ergänzen
diff --git a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
--- a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
+++ b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
@@ -203,6 +203,14 @@ final class DiviToolFeaturesTest extends TestCase
                 'lit/divi-design-restore', ['snapshot_id' => 'theme_options-20261016T100000Z-aaaaaa'],
                 ['theme-option-read', 'theme-option-write'],
             ],
+            'dynamic-sources of a node' => [
+                'lit/divi-dynamic-sources', ['post_type' => 'page', 'post_id' => $page, 'node' => 's0'],
+                ['dynamic-sources', 'roles', 'page-read'],
+            ],
+            'dynamic-sources of a node in a layout' => [
+                'lit/divi-dynamic-sources', ['post_type' => 'et_body_layout', 'post_id' => $layout, 'node' => 's0'],
+                ['dynamic-sources', 'roles', 'page-read'],
+            ],
             'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
             'library-list of one entry' => [
                 'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],
  • Step 5.3: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php Expected: FAIL – Tests: 14, Assertions: 46, Errors: 1, Failures: 2: testANodeInALoopAddsTheLoopSourcesOfItsKind (sample 831 statt 7), testANodeOutsideALoopGetsNoLoopSources (Schlüssel loop fehlt), testPostIdAndNodeComeTogetherAndNeedTheEntry (kein Fehler für eine halbe Angabe). testWithoutANodeTheAnswerHasNoLoopPart ist schon grün (Absicherung des alten Antwortformats).

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php Expected: FAIL – Tests: 41, Assertions: 335, Failures: 2 (die zwei neuen Input-Fälle: page-read fehlt).

  • Step 5.4: DiviDynamicSourcesAbility erweitern
diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
--- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
+++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
@@ -7,11 +7,15 @@ namespace LIT\MCP\Integrations\Divi\Pages;
 use Closure;
 use LIT\MCP\Abilities\Ability;
 use LIT\MCP\Abilities\Annotations;
+use LIT\MCP\Abilities\Errors;
 use LIT\MCP\Abilities\JsonMap;
+use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
 use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
 use LIT\MCP\Integrations\Divi\Compose\Placeholders;
 use LIT\MCP\Integrations\Divi\Library\DiviRoles;
 use LIT\MCP\Integrations\Divi\Release\DiviFeature;
+use LIT\MCP\Integrations\Divi\Release\FeatureSets;
 use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
 use LIT\MCP\Integrations\WordPress\PostGuard;
 use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -22,9 +26,13 @@ defined('ABSPATH') || exit;
 /**
  * lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
  * (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0;
- * no loop_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
+ * no loop_* there; via DynamicContentMap::options()) with the placeholder that writes each, and
  * the fields of the type usable as {{field:<key>}}.
  *
+ * Ruling X6: with post_id and node (path ID) the entry itself is asked instead of a sample – loaded with the
+ * rights of lit-divi-node-get (DiviPageLoader) – and loop_sources lists the loop_* options of the node's loop
+ * kind (LoopContext, DynamicContentMap::loopFields()); a node outside any loop gets a note instead.
+ *
  * Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
  * table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
  * left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor
@@ -44,13 +52,15 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
      * @param (Closure(string): int)|null     $samplePost Post type → an entry to ask Divi with (0 = none).
      * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table");
      *                                                    null without a pod.
+     * @param DiviPageLoader|null             $loader     Loads the entry of post_id; null: post_id/node unsupported.
      */
     public function __construct(
         private ContentSchema $schema,
         private DynamicContentMap $map,
         private DiviRoles $roles,
         ?Closure $samplePost = null,
-        ?Closure $podStorage = null
+        ?Closure $podStorage = null,
+        private ?DiviPageLoader $loader = null
     ) {
         $this->samplePost = $samplePost ?? self::defaultSample(...);
         $this->fields = new DynamicFields($schema, $podStorage);
@@ -71,14 +81,26 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
         return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, '
             . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:<ID>}}) bzw. dem Namen für die Vollform, '
             . 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} eignen. Nur textartige Felder; Beziehungs-, '
-            . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.';
+            . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted. Mit post_id und node (Pfad-ID '
+            . 'aus lit-divi-outline) zusätzlich die Schleifen-Quellen ({{loop:…}}), die an diesem Knoten passen.';
     }
 
     public function inputSchema(): array
     {
         return [
             'type' => 'object',
-            'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']],
+            'properties' => [
+                'post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".'],
+                'post_id' => [
+                    'type' => 'integer',
+                    'minimum' => 1,
+                    'description' => 'Eintrag dieses Typs; nur zusammen mit node.',
+                ],
+                'node' => [
+                    'type' => 'string',
+                    'description' => 'Pfad-ID aus lit-divi-outline, z. B. "s0.r0.c1.m0"; nur zusammen mit post_id.',
+                ],
+            ],
             'required' => ['post_type'],
             'additionalProperties' => false,
         ];
@@ -117,6 +139,28 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
                 'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']),
                 'omitted' => $entry(['key', 'label', 'reason']),
                 'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
+                'loop' => [
+                    'type' => 'object',
+                    'description' => 'Nur mit node: die Divi-Schleife des Knotens (query_type null = keine).',
+                    'properties' => [
+                        'node' => ['type' => 'string'],
+                        'query_type' => ['type' => ['string', 'null']],
+                        'label' => ['type' => ['string', 'null']],
+                    ],
+                    'required' => ['node', 'query_type', 'label'],
+                ],
+                'loop_sources' => [
+                    'type' => 'array',
+                    'items' => [
+                        'type' => 'object',
+                        'properties' => [
+                            'name' => ['type' => 'string'],
+                            'placeholder' => ['type' => ['string', 'null']],
+                            'settings' => ['type' => 'object'],
+                        ],
+                        'required' => ['name', 'placeholder', 'settings'],
+                    ],
+                ],
             ],
             'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'],
         ];
@@ -135,17 +179,28 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
     /**
      * Divi's dynamic content registry (DynamicSources); listing custom fields asks the role option
      * read_dynamic_content_custom_fields (Roles) and omits them without it, so a broken roles check must not pass.
+     * post_id loads that entry (PageRead, plus what the entry adds like in lit-divi-node-get).
      */
     public function diviFeatures(array $input): array
     {
-        return [DiviFeature::DynamicSources, DiviFeature::Roles];
+        $base = [DiviFeature::DynamicSources, DiviFeature::Roles];
+        if (! array_key_exists('post_id', $input)) {
+            return $base;
+        }
+
+        return FeatureSets::merge($base, [DiviFeature::PageRead], FeatureSets::ofEntry($input['post_id'], false));
     }
 
     public function permission(array $input): bool|WP_Error
     {
         $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
+        if ($type instanceof WP_Error) {
+            return $type;
+        }
 
-        return $type instanceof WP_Error ? $type : true;
+        $page = $this->page($input);
+
+        return $page instanceof WP_Error ? $page : true;
     }
 
     public function execute(array $input): mixed
@@ -156,7 +211,17 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
         }
 
         $postType = (string) $input['post_type'];
-        $sample = ($this->samplePost)($postType);
+        $page = $this->page($input);
+        if ($page instanceof WP_Error) {
+            return $page;
+        }
+
+        $node = is_string($input['node'] ?? null) ? $input['node'] : '';
+        if ($page !== null && $page->index->find($node) === null) {
+            return DiviErrors::unknownNode($node, $page->post);
+        }
+
+        $sample = $page === null ? ($this->samplePost)($postType) : (int) $page->post->ID;
         $notes = [
             'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"<name>",'
                 . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.',
@@ -191,7 +256,7 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
                 . 'Felder lesen“ nicht (Divi → Rollen-Editor).';
         }
 
-        return [
+        $result = [
             'post_type' => $postType,
             'sample_post_id' => $sample,
             'sources' => $sources,
@@ -199,6 +264,93 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
             'omitted' => $omitted,
             'notes' => $notes,
         ];
+
+        return $page === null ? $result : $this->withLoop($result, $page, $node);
+    }
+
+    /**
+     * The entry of post_id with node checked as a path ID; null when neither is given. Both or none (X6); the entry
+     * is loaded with read rights (DiviPageLoader::load(…, false), like lit-divi-node-get) and must be of post_type.
+     *
+     * @param array<string, mixed> $input
+     */
+    private function page(array $input): DiviPage|WP_Error|null
+    {
+        $hasPost = array_key_exists('post_id', $input);
+        if (! $hasPost && ! array_key_exists('node', $input)) {
+            return null;
+        }
+
+        if (! $hasPost || ! is_string($input['node'] ?? null)) {
+            return Errors::invalidInput('post_id und node nur zusammen angeben.', [
+                $hasPost ? 'node' : 'post_id' => 'fehlt (post_id und node gehören zusammen)',
+            ]);
+        }
+
+        if (preg_match(NodeIndex::ID_PATTERN, $input['node']) !== 1) {
+            return Errors::invalidInput('node ist keine Pfad-ID.', ['node' => 'z. B. "s0.r1.c0.m2"']);
+        }
+
+        if ($this->loader === null) {
+            return Errors::unsupported(
+                'Schleifen-Quellen je Knoten sind hier nicht verfügbar.',
+                'lit/divi-dynamic-sources ohne post_id und node aufrufen.'
+            );
+        }
+
+        $page = $this->loader->load($input['post_id'], false);
+        if ($page instanceof WP_Error || $page->post->post_type === $input['post_type']) {
+            return $page;
+        }
+
+        $type = (string) $page->post->post_type;
+
+        return Errors::invalidInput(
+            sprintf('Eintrag %d ist vom Typ "%s", nicht "%s".', $page->post->ID, $type, $input['post_type']),
+            ['post_type' => sprintf('"%s" (Typ von Eintrag %d)', $type, $page->post->ID)]
+        );
+    }
+
+    /**
+     * Adds the loop of $node and the loop_* sources of its kind (X6), or the note that there is no loop context.
+     *
+     * @param array<string, mixed> $result
+     * @return array<string, mixed>
+     */
+    private function withLoop(array $result, DiviPage $page, string $node): array
+    {
+        $scope = LoopContext::of($page->index, $node);
+        $kind = $scope->kind();
+        $result['loop'] = [
+            'node' => $node,
+            'query_type' => $scope->queryType(),
+            'label' => $kind === null ? null : $scope->label(),
+        ];
+        $result['loop_sources'] = [];
+        if ($kind === null) {
+            $result['notes'][] = sprintf(
+                'Knoten %s liegt in keiner Divi-Schleife (kein Schleifen-Kontext): Schleifen-Inhalte (loop_…, '
+                    . '{{loop:…}}) gehen dort nicht. Eine Schleife wird im Divi-Builder (Loop Builder) eingerichtet.',
+                $node
+            );
+
+            return $result;
+        }
+
+        foreach ($this->map->loopFields($kind, (int) $page->post->ID) as $name => $settings) {
+            $result['loop_sources'][] = [
+                'name' => (string) $name,
+                'placeholder' => self::placeholder((string) $name),
+                'settings' => JsonMap::of($settings),
+            ];
+        }
+        $result['notes'][] = sprintf(
+            'Schleifen-Quellen gelten nur in dieser Schleife (Typ: %s) – in diesem Knoten und darunter, bis eine '
+                . 'innere Schleife beginnt.',
+            $scope->label()
+        );
+
+        return $result;
     }
 
     private static function placeholder(string $name): ?string
@@ -208,6 +360,11 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
             return '{{post:' . $post . '}}';
         }
 
+        $loop = array_search($name, Placeholders::LOOP, true);
+        if ($loop !== false) {
+            return '{{loop:' . $loop . '}}';
+        }
+
         if (! str_starts_with($name, DynamicContentRules::LINK_PREFIX)) {
             return null;
         }
  • Step 5.5: Loader in DiviIntegration durchreichen
diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php
--- a/src/Integrations/Divi/DiviIntegration.php
+++ b/src/Integrations/Divi/DiviIntegration.php
@@ -276,7 +276,12 @@ final class DiviIntegration implements Integration, Bootable
             new DiviDuplicateNodeAbility($writer),
             new DiviReplaceTextAbility($this->schema, $loader, $writer),
             new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable),
-            new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
+            new DiviDynamicSourcesAbility(
+                $this->schema,
+                new DynamicContentMap(),
+                $this->access->roles(),
+                loader: $loader
+            ),
             new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),
             new DiviPresetsSetAbility(
                 $this->designWriter,
  • Step 5.6: GREEN

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php Expected: OK (14 tests, 82 assertions).

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php Expected: grün (DiviToolFeaturesTest allein: OK (41 tests, 335 assertions)).

  • Step 5.7: Gesamtlauf und Lint

Run: composer test && composer lint Expected: grün, sechs Tests mehr als nach Task 4 (vier Tests, zwei Input-Fälle); phpcs ohne Meldung.

  • Step 5.8: Commit
git add src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php src/Integrations/Divi/DiviIntegration.php \
    tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php \
    tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
git commit -m "feat: lit/divi-dynamic-sources mit post_id + node – Schleife des Knotens und passende loop_*-Quellen (X6)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 6: Smoke gegen echtes Divi (Schleifen-Inhalte, Kontext, Rendern)

Files:

  • Create: tests/Integration/divi-loop-smoke.sh

Interfaces:

  • Consumes: lit-divi-outline, lit-divi-insert, lit-divi-update, lit-divi-move (direkte Tools, tool …) und lit/divi-dynamic-sources (indirekt, ability …) über den OAuth-Endpunkt; die Hilfen aus tests/Integration/lib.sh (connect, tool, ability, pass, fail, finish, guard_exit, cleanup_app_passwords_for); das Verhalten der Tools laut Rulings X1–X6 (Schleifen-Kontext aus dem Baum, Meldungen X5, {{loop:…}} X4, dynamic-sources mit post_id + node X6, Move-Warnung X3).
  • Produces: ein Skript, das 4e gegen Divi 5.15.1 prüft (echte Loop-Seite, Speichern, Rendern) und alles, was es ändert, im EXIT-Trap zurücksetzt: die Seite und den temporären Beitrag (nur per exakter ID, samt Revisionen und Protokollzeilen), die Optionen et_divi und et_divi_global_variables byte-genau (serialisiert, base64 über die Umgebung; fehlende Option → Sentinel, wird wieder gelöscht). Bei leeren Erfassungen bricht es vor der ersten Änderung ab.

Das Skript wird ausgeführt und – wie in 4c/4d – anhand der echten Antworten korrigiert. Die Tools aus Task 4 und 5 haben ihre Eingabe- und Antwortformen erst im Lauf dieses Plans bekommen; das Skript ist gegen die Rulings geschrieben. Stellen, die der Implementierer gegen die echten Antworten abgleichen muss, tragen im Skript den Kommentar ADAPT (Task n):

Stelle im Skript Annahme Prüfen gegen
LOOP_NODE=s0.r0.c0.m1 (Schritt 2) das eingefügte Modul ist das zweite Kind der Schleifen-Spalte (das statische Modul hat m0, Einfügen ohne index hängt hinten an) Antwort von lit-divi-insert (changed)
{{loop:term_name}} (Schritt 4, Positivkontrolle) Kurzname term_name → loop_term_name Tabelle LOOP in Placeholders (Task 3)
sources() / loop_names (Schritt 5) Eingabe post_type + post_id + node; die Antwort nennt die Loop-Optionen irgendwo als name; ohne Schleife enthält sie den Text „Schleifen-Kontext“ Schema und Antwort von lit/divi-dynamic-sources (Task 5), ggf. jq-Pfad verengen
.warnings mit „Schleife“ (Schritt 6) die Move-Warnung ist ein String in .warnings, der „Schleife“ enthält Antwort von lit-divi-move (Task 4)

Weitere Annahmen, die bei einem Fehlschlag zuerst zu prüfen sind (Skriptfehler, nicht Produktfehler): Erfolg heißt .ok == true; Fehlertexte stehen als JSON-String in .__error und enthalten die Meldung; lit-divi-update nimmt set: {content: …} für das Text-Modul; die Position heißt parent; lit-divi-outline nennt die Pfad-IDs s0.r0.c0.m0, s0.r1.c0.m0, s0.r2.c0.m0. Die Prüfungen selbst sind verbindlich und werden nicht gelockert.

Die Reihenfolge im Skript weicht von der Nummerierung der Prüfungen ab: Rendern (7) kommt vor dem Verschieben (6), weil das Modul nach dem Verschieben außerhalb der Schleife liegt und leer rendert.

Prüfung Schritt im Skript Inhalt
(1) Loop-Seite 1 Entwurf mit Post-Schleifen-Spalte (post_types, Beitragstyp post), Spalte ohne Schleife in der Zeile darunter, Term-Schleifen-Spalte (post_taxonomies, category); je ein statisches Text-Modul
(2) {{loop:post_date}} in der Schleife 2 Kurzform und Vollform ($variable(…)$, Datumsformat d.m.Y) angenommen, gespeicherter Inhalt enthält loop_post_date; Positivkontrolle für die Feld-Option in der Post-Schleife
(3) außerhalb jeder Schleife 3 Insert (Kurz- und Vollform) und Update abgelehnt mit „braucht eine Divi-Schleife“ und dem Optionsnamen; Seite unverändert (MD5 des gespeicherten Inhalts)
(4) Sicherheitsgrenze 4 in der Term-Schleife werden loop_post_date, loop_post_meta_key_manual_custom_field und loop_user_meta_key_manual_custom_field abgelehnt („passt nicht zur Schleife“, Typ Terme), umgekehrt die Benutzer-Feld-Option in der Post-Schleife; Positivkontrolle {{loop:term_name}} in der Term-Schleife
(5) dynamic-sources 5 post_id + node: Post-Optionen im Post-Schleifen-Modul (keine Term-/Benutzer-Optionen), Term-Optionen im Term-Modul, keine Optionen und der Hinweis „Schleifen-Kontext“ außerhalb
(7) Rendern 7 apply_filters('the_content', …) der Seite: kein PHP-Fehlertext, kein offener $variable(-Verweis, kein Optionsname, mindestens ein formatiertes Datum (d.m.Y) eines veröffentlichten Beitrags (gibt es keinen, legt das Skript einen temporären an)
(6) Move 6 Verschieben des Moduls aus der Schleife: ok mit Warnung zu Schleifen-Inhalt, Inhalt bleibt gespeichert
(8) Aufräumen 8 Seite und temporärer Beitrag gelöscht (nicht mehr auffindbar, kein Beitrag „LIT Smoke “ übrig), et_divi und et_divi_global_variables gleich der Erfassung
  • Step 1: Das Skript anlegen

Datei tests/Integration/divi-loop-smoke.sh (danach chmod +x, wie die übrigen Skripte):

#!/usr/bin/env bash
# End-to-end checks of Etappe 4e (loop content: loop_* options inside a Divi loop, refused outside and in a loop of
# another kind, dynamic sources per node, move warning, real frontend render) through the OAuth-protected endpoint
# against the real Divi. Runs INSIDE the DDEV web container:
#   cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-loop-smoke.sh
# Runs only with the plugins postlos and asynclistry deactivated (Monolog 2 / deprecation output of the WP-CLI calls).
# Creates one draft page with a post loop, a plain row and a term loop (and one temporary published post when the site
# has none). The EXIT trap deletes the page and the temporary post by exact ID, removes their change-log rows and puts
# the options et_divi and et_divi_global_variables back byte-exact (serialized, base64 through the environment).
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh

ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
RUN="$(date +%s)-$$"
# $1 = option name. An option that does not exist is captured as the sentinel string, so restore() can delete it again.
capture() { wp eval "echo base64_encode(serialize(get_option('$1', '__lit_absent__')));"; }
ORIGINAL_THEME=$(capture et_divi)
ORIGINAL_VARIABLES=$(capture et_divi_global_variables)
# Abort before any change: without a capture there is nothing to restore.
[ -n "$ORIGINAL_THEME" ] || { echo "FAIL: could not capture et_divi"; exit 1; }
[ -n "$ORIGINAL_VARIABLES" ] || { echo "FAIL: could not capture et_divi_global_variables"; exit 1; }
PAGE_ID=""
TEMP_POST_ID=""

restore() { # $1 = option name, $2 = name of the environment variable that holds the capture
  local LIT_CAPTURE="$2"; export LIT_CAPTURE
  LIT_OPTION="$1" wp eval '$n = getenv("LIT_OPTION"); $v = unserialize(base64_decode(getenv(getenv("LIT_CAPTURE")))); if ($v === "__lit_absent__") { delete_option($n); exit(get_option($n, "__lit_absent__") === $v ? 0 : 1); } update_option($n, $v); exit(serialize(get_option($n)) === serialize($v) ? 0 : 1);' >/dev/null 2>&1 \
    || { echo "FAIL: $1 not restored"; return 1; }
}
delete_content() { # deletes the page and the temporary post by exact ID (with revisions), then their change-log rows
  local ids="$PAGE_ID $TEMP_POST_ID"
  for id in $ids; do
    wp post delete "$id" --force >/dev/null 2>&1 || true
  done
  LIT_IDS="$ids" wp eval '
global $wpdb;
$table = $wpdb->prefix . "lit_mcp_log";
foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
    $wpdb->delete($table, ["object_id" => $id]);
}' >/dev/null 2>&1 || true
}
cleanup() {
  local failed=0
  delete_content
  restore et_divi LIT_THEME || failed=1
  restore et_divi_global_variables LIT_VARIABLES || failed=1
  wp eval 'ET_Core_PageResource::remove_static_resources("all", "all", true);' >/dev/null 2>&1 || true
  cleanup_app_passwords_for "$ADMIN_LOGIN"
  return "$failed"
}
export LIT_THEME="$ORIGINAL_THEME" LIT_VARIABLES="$ORIGINAL_VARIABLES"
trap 'rc=$?; cleanup || rc=1; guard_exit "$rc" || exit $?' EXIT

[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
connect "$ADMIN_LOGIN"

# --- helpers -----------------------------------------------------------------------------------------------------

# Draft page: s0.r0 = column c0 with a POST loop (queryType post_types, post type "post") and one static text module,
# s0.r1 = column c0 WITHOUT a loop and one static text module (below the loop), s0.r2 = column c0 with a TERM loop
# (queryType post_taxonomies, taxonomy category) and one static text module. Loop attributes exactly as Divi's own
# builder stores them (module.advanced.loop.desktop.value, enable "on").
create_loop_page() { # prints the ID
  LIT_TITLE="LIT Smoke $RUN Schleife" wp --user="$ADMIN_LOGIN" eval '
$b = static fn (string $n, array $a, array $k = []): array => ["blockName" => $n, "attrs" => $a, "innerBlocks" => $k,
    "innerHTML" => "", "innerContent" => array_fill(0, count($k), null)];
$text = static fn (string $t): array => $b("divi/text", ["content" => ["innerContent" => ["desktop" => ["value" => $t]]]]);
$width = ["module" => ["decoration" => ["sizing" => ["desktop" => ["value" => ["flexType" => "24_24"]]]]]];
$loop = static fn (string $id, string $type, string $sub, string $label): array => array_replace_recursive($width, [
    "module" => ["advanced" => ["loop" => ["desktop" => ["value" => ["enable" => "on", "loopId" => $id,
        "queryType" => $type, "subTypes" => [["value" => $sub, "label" => $label]], "postPerPage" => "3"]]]]]]);
$row = static fn (array $column): array => $b("divi/row", [], [$column]);
$section = $b("divi/section", [], [
    $row($b("divi/column", $loop("loop-litsmoke01", "post_types", "post", "Post"), [$text("<p>Schleife</p>")])),
    $row($b("divi/column", $width, [$text("<p>Ausserhalb</p>")])),
    $row($b("divi/column", $loop("loop-litsmoke02", "post_taxonomies", "category", "Categories"), [$text("<p>Terme</p>")])),
]);
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
    "post_content" => wp_slash(serialize_block($b("divi/placeholder", [], [$section])))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;' 2>/dev/null
}
stored() { wp post get "$PAGE_ID" --field=post_content; }
# Substring tests without a pipe: with pipefail, grep -q closing the pipe early would fail the pipeline (SIGPIPE).
stored_has() { local s; s=$(stored); [[ "$s" == *"$1"* ]]; }
has_line() { grep -qx -- "$2" <<<"$1"; } # $1 = lines, $2 = exact line
has_prefix() { grep -q -- "^$2" <<<"$1"; }
tree_rev() { tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev'; }

# insert <parent path id> <content value>: one text module with the given content (short form or $variable string).
insert_text() {
  tool lit-divi-insert "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg parent "$1" --arg c "$2" \
    '{post_id: $p, tree_rev: $r, parent: $parent, nodes: [{type: "text", content: $c}]}')"
}
# refused_with <answer> <text>: the call was refused (an error, not the dry run of anything) and the error names <text>.
refused_with() {
  echo "$1" | jq -e --arg t "$2" 'has("__error") and (.__error | contains($t))' >/dev/null
}
# variable_ref <option name> [settings JSON]: the full form $variable({...})$ of a content reference.
variable_ref() {
  local settings="${2:-}"; [ -n "$settings" ] || settings='{}'
  jq -rn --arg n "$1" --argjson s "$settings" \
    '"$variable(" + ({type: "content", value: {name: $n, settings: ({before: "", after: ""} + $s)}} | tojson) + ")$"'
}
# loop_names <dynamic-sources answer>: every option name starting with loop_ anywhere in the answer, one per line.
# ADAPT (Task 5): the jq walk finds "name" keys in any nesting; narrow it when the answer has a fixed list.
loop_names() { echo "$1" | jq -r '[.. | objects | .name? // empty | strings | select(startswith("loop_"))] | unique[]'; }

# --- 1. The loop page --------------------------------------------------------------------------------------------
PAGE_ID=$(create_loop_page)
[ -n "$PAGE_ID" ] || fail "loop page not created"
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
echo "$OUTLINE" | jq -e '.tree_rev | type == "string"' >/dev/null || fail "outline of the loop page: $OUTLINE"
for NODE in s0.r0.c0.m0 s0.r1.c0.m0 s0.r2.c0.m0; do
  [[ "$OUTLINE" == *"$NODE"* ]] || fail "outline lacks $NODE: $OUTLINE"
done
stored_has '"enable":"on"' || fail "stored page has no enabled loop"
pass "loop page $PAGE_ID created (post loop, plain row, term loop)"

# --- 2. {{loop:post_date}} inside the post loop is accepted and stored -----------------------------------------------
# ADAPT (Task 4): the new module is the second child of the loop column, so its path id is s0.r0.c0.m1.
LOOP_NODE=s0.r0.c0.m1
DONE=$(insert_text s0.r0.c0 '{{loop:post_date}}')
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "{{loop:post_date}} in the loop refused: $DONE"
stored_has 'loop_post_date' || fail "stored content lacks loop_post_date after the insert"
# Full form with a German date format (the select of Divi offers no German format, so "custom"): update the module.
FULL=$(variable_ref loop_post_date '{"loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}')
DONE=$(tool lit-divi-update "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg n "$LOOP_NODE" --arg c "$FULL" \
  '{post_id: $p, tree_rev: $r, changes: [{id: $n, set: {content: $c}}]}')")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "full-form loop_post_date in the loop refused: $DONE"
stored_has 'custom_date_format' || fail "stored content lacks the date format of the full form"
# Positive control for the security boundary below: the post meta key option belongs to a post loop.
META=$(variable_ref loop_post_meta_key_manual_custom_field \
  '{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
DONE=$(insert_text s0.r0.c0 "$META")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "post meta key option in a post loop refused: $DONE"
pass "loop_post_date (short and full form) and the post meta key option accepted in the post loop"

# --- 3. The same content OUTSIDE any loop is refused ---------------------------------------------------------------
BEFORE=$(stored | md5sum)
for VALUE in '{{loop:post_date}}' "$FULL"; do
  BAD=$(insert_text s0.r1.c0 "$VALUE")
  refused_with "$BAD" 'braucht eine Divi-Schleife' || fail "loop content outside a loop not refused with the loop message: $BAD"
  refused_with "$BAD" 'loop_post_date' || fail "refusal does not name the option: $BAD"
done
# Updating the static module outside the loop is refused as well, and so is a module whose parent is the section row.
BAD=$(tool lit-divi-update "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg c "$FULL" \
  '{post_id: $p, tree_rev: $r, changes: [{id: "s0.r1.c0.m0", set: {content: $c}}]}')")
refused_with "$BAD" 'braucht eine Divi-Schleife' || fail "update outside a loop not refused: $BAD"
[ "$(stored | md5sum)" = "$BEFORE" ] || fail "a refused call changed the stored page"
pass "loop content outside a loop refused (insert short and full form, update), page unchanged"

# --- 4. A term loop refuses post-kind options (security boundary: meta keys read foreign objects) -----------------------
BEFORE=$(stored | md5sum)
POST_META=$(variable_ref loop_post_meta_key_manual_custom_field \
  '{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
USER_META=$(variable_ref loop_user_meta_key_manual_custom_field \
  '{"select_loop_meta_key":"loop_user_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
for VALUE in '{{loop:post_date}}' "$FULL" "$POST_META" "$USER_META"; do
  BAD=$(insert_text s0.r2.c0 "$VALUE")
  refused_with "$BAD" 'passt nicht zur Schleife' || fail "post/user-kind option in a term loop not refused: $BAD"
  refused_with "$BAD" 'Terme' || fail "refusal does not name the loop kind (Terme): $BAD"
done
[ "$(stored | md5sum)" = "$BEFORE" ] || fail "a refused call changed the stored page"
# The reverse direction: a user-kind option in the POST loop.
BAD=$(insert_text s0.r0.c0 "$USER_META")
refused_with "$BAD" 'passt nicht zur Schleife' || fail "user meta key option in a post loop not refused: $BAD"
# Positive control: an option of the term kind is accepted in the term loop (ADAPT (Task 3): short name term_name).
DONE=$(insert_text s0.r2.c0 '{{loop:term_name}}')
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "{{loop:term_name}} in the term loop refused: $DONE"
stored_has 'loop_term_name' || fail "stored content lacks loop_term_name"
pass "post/user-kind options (incl. meta keys) refused in the wrong loop kind, term option accepted in the term loop"

# --- 5. lit/divi-dynamic-sources per node -------------------------------------------------------------------------
# ADAPT (Task 5): input post_type + post_id + node; the answer lists the loop options; without a loop context it says so.
sources() { ability lit/divi-dynamic-sources "$(jq -cn --argjson p "$PAGE_ID" --arg n "$1" '{post_type: "page", post_id: $p, node: $n}')"; }
IN_LOOP=$(sources "$LOOP_NODE")
IN_LOOP_NAMES=$(loop_names "$IN_LOOP")
has_line "$IN_LOOP_NAMES" loop_post_date || fail "no loop_post_date for the module in the post loop: $IN_LOOP"
has_line "$IN_LOOP_NAMES" loop_post_meta_key_manual_custom_field || fail "no post meta key option in the post loop: $IN_LOOP"
! has_prefix "$IN_LOOP_NAMES" 'loop_term_\|^loop_user_' || fail "post loop lists term/user options: $IN_LOOP"
IN_TERM=$(sources s0.r2.c0.m0)
IN_TERM_NAMES=$(loop_names "$IN_TERM")
has_line "$IN_TERM_NAMES" loop_term_name || fail "no loop_term_name for the module in the term loop: $IN_TERM"
! has_prefix "$IN_TERM_NAMES" loop_post_ || fail "term loop lists post options: $IN_TERM"
OUTSIDE=$(sources s0.r1.c0.m0)
[ -z "$(loop_names "$OUTSIDE")" ] || fail "loop options listed for a node outside any loop: $OUTSIDE"
[[ "$OUTSIDE" == *'Schleifen-Kontext'* ]] || fail "no hint about the missing loop context: $OUTSIDE"
pass "dynamic sources: post options in the post loop, term options in the term loop, none outside"

# --- 7. Frontend render (before the move: afterwards the module is outside the loop and renders empty) ------------------
# At least one published post must exist; otherwise one temporary post is created (and deleted by exact ID).
if [ "$(wp post list --post_type=post --post_status=publish --format=count)" = "0" ]; then
  TEMP_POST_ID=$(wp post create --post_type=post --post_status=publish --post_title="LIT Smoke $RUN Beitrag" --porcelain)
fi
HTML=$(LIT_ID="$PAGE_ID" wp eval 'global $post; $post = get_post((int) getenv("LIT_ID")); setup_postdata($post); echo apply_filters("the_content", $post->post_content);' 2>&1)
[ -n "$HTML" ] || fail "the page rendered nothing"
if grep -Eqi 'Fatal error|Parse error|Warning:|Notice:|Deprecated:|Stack trace|Uncaught' <<<"$HTML"; then
  fail "PHP error text in the rendered page: $(grep -Eio '.{0,80}(Fatal error|Parse error|Warning:|Notice:|Deprecated:|Stack trace|Uncaught).{0,120}' <<<"$HTML" | head -3)"
fi
[[ "$HTML" != *'$variable('* ]] || fail "an unresolved \$variable reference is in the rendered page"
[[ "$HTML" != *loop_post_date* ]] || fail "the option name loop_post_date is in the rendered page"
# The module renders the date of a published post in d.m.Y (the loop shows up to 3 of them, newest first).
DATES=$(wp eval 'foreach (get_posts(["post_type" => "post", "post_status" => "publish", "numberposts" => -1, "fields" => "ids"]) as $i) { echo get_the_date("d.m.Y", $i), "\n"; }')
[ -n "$DATES" ] || fail "no published post to compare against"
grep -qF -f <(echo "$DATES") <<<"$HTML" \
  || fail "the rendered page shows no formatted date (d.m.Y) of a published post; dates: $(echo "$DATES" | head -3 | tr '\n' ' ')"
pass "frontend render shows a formatted post date, no PHP error text"

# --- 6. Moving loop content out of the loop: warning, not refusal ----------------------------------------------------
# ADAPT (Task 4): the warning is a string in .warnings that names the loop content ("Schleife").
MOVED=$(tool lit-divi-move "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg n "$LOOP_NODE" \
  '{post_id: $p, tree_rev: $r, id: $n, parent: "s0.r1.c0"}')")
[ "$(echo "$MOVED" | jq -r '.ok')" = "true" ] || fail "moving loop content out of the loop was refused: $MOVED"
echo "$MOVED" | jq -e '(.warnings // []) | map(select(test("Schleife"))) | length > 0' >/dev/null \
  || fail "move out of the loop carries no warning about loop content: $MOVED"
stored_has 'loop_post_date' || fail "the moved module lost its loop content"
pass "move out of the loop answers ok with a loop warning"

# --- 8. Everything restored, no leftover pages ----------------------------------------------------------------------
GONE_PAGE=$PAGE_ID
GONE_POST=$TEMP_POST_ID
delete_content
PAGE_ID=""
TEMP_POST_ID=""
[ -z "$(wp post list --post_type=any --post_status=any --post__in="$GONE_PAGE" --format=ids)" ] || fail "page $GONE_PAGE still exists"
[ -z "$GONE_POST" ] || [ -z "$(wp post list --post_type=any --post_status=any --post__in="$GONE_POST" --format=ids)" ] \
  || fail "temporary post $GONE_POST still exists"
[ -z "$(wp post list --post_type=any --post_status=any --s="LIT Smoke $RUN" --format=ids)" ] || fail "a post of this run is left"
[ "$(capture et_divi)" = "$ORIGINAL_THEME" ] || fail "et_divi differs from its state before the run"
[ "$(capture et_divi_global_variables)" = "$ORIGINAL_VARIABLES" ] || fail "et_divi_global_variables differs from its state before the run"
pass "page and temporary post deleted, options unchanged"

finish
  • Step 2: Syntax prüfen

Run: bash -n tests/Integration/divi-loop-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x -e SC2016 tests/Integration/divi-loop-smoke.sh || true) Expected: keine Syntaxfehler (SC2016 – $ in einfachen Anführungszeichen – ist hier gewollt, das PHP/jq läuft in wp eval bzw. jq).

  • Step 3: Zustand notieren, Plugins deaktivieren, Skript in DDEV ausführen und korrigieren

Vorbereitung: cd /home/janniclas/Projekte/WordPress && ddev exec wp plugin list notieren (Stand beim Schreiben dieses Plans: postlos, asynclistry, breva-map und WP-LIT-MCP aktiv), dann ddev exec wp plugin deactivate postlos asynclistry (nur falls aktiv). Vor dem Lauf die Vergleichswerte festhalten:

cd /home/janniclas/Projekte/WordPress
ddev exec wp eval 'global $wpdb; echo md5(serialize(get_option("et_divi"))), " ", md5(serialize(get_option("et_divi_global_variables", "absent"))), " log=", $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}lit_mcp_log"), "\n";'
ddev exec wp post list --post_type=any --post_status=any --format=count

Run: cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-loop-smoke.sh Expected: ALL CHECKS PASSED. Schlägt eine Prüfung fehl, die Antwort lesen (fail druckt sie): Ein Skriptfehler (Feldname, Pfad-ID, Quoting, jq-Pfad, Annahme aus der Tabelle oben) wird im Skript berichtigt – die Prüfung bleibt. Ein Produktfehler (z. B. ein Tool nimmt loop_post_date außerhalb einer Schleife an, lässt loop_post_meta_key_manual_custom_field in der Term-Schleife durch oder rendert eine Fehlermeldung) wird NICHT im Skript versteckt, sondern mit Diagnose (Antwort, erwartet/ist, Verdacht mit Datei:Zeile) berichtet (DONE_WITH_CONCERNS). Besonders prüfen, falls Schritt 7 scheitert: (a) lief die Schleife überhaupt (kein loop-Attribut im gespeicherten Inhalt nach Divis Normalisierung? wp post get <ID> --field=post_content), (b) das Rendern braucht Divis Hook während the_content – bei Bedarf zusätzlich über LIT\MCP\Integrations\WordPress\Render\PageRenderer rendern (wie divi-write-smoke.sh, probe_findings) und beide Ausgaben vergleichen, (c) der Beitragstyp post hat veröffentlichte Beiträge (das Skript legt sonst einen an).

Danach prüfen, dass nichts übrig ist: dieselbe wp eval-Zeile wie vorher – MD5 von et_divi und et_divi_global_variables und die Zahl der Protokollzeilen (log=) identisch zu vorher; ddev exec wp post list --post_type=any --post_status=any --s="LIT Smoke" --format=ids leer; ddev exec wp post list --post_type=any --post_status=any --format=count gleich dem Wert von vorher.

Zusätzlich müssen die übrigen Smokes weiter bestehen, je mit cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh und ALL CHECKS PASSED: divi-colors-variables-smoke.sh, divi-presets-smoke.sh, divi-presets-assign-smoke.sh, divi-release-smoke.sh (unverändert sechzehn Fähigkeiten, 4e fügt keine hinzu), divi-theme-fonts-smoke.sh (braucht Netz im Container). Und ddev exec wp --user=<Admin> lit-mcp divi-check mit Exit-Code 0 (echo $?).

Zum Schluss den notierten Plugin-Zustand wiederherstellen (ddev exec wp plugin activate postlos asynclistry, falls sie vorher aktiv waren) und ddev exec wp plugin list mit der Notiz vergleichen.

  • Step 4: composer test && composer lint

Run: composer test && composer lint Expected: grün und sauber (das Skript ändert keinen PHP-Code).

  • Step 5: Commit
git add tests/Integration/divi-loop-smoke.sh
git commit -m "test: Smoke für Schleifen-Inhalte gegen Divi 5.15.1" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 7: Guides (Etappe-4-Werkzeuge, Schleifen-Inhalte)

Files:

  • Modify: docs/guides/divi-design-system.md, docs/guides/divi-placeholders.md, docs/guides/divi-shortform.md, docs/guides/workflows.md, docs/guides/overview.md
  • Modify: tests/Unit/Guides/ToolNamingTest.php
  • Create: tests/Unit/Guides/LoopGuideTest.php

Interfaces:

  • Consumes: die fertigen Tools (Beschreibungen und Eingaben in src/Integrations/Divi/Design/*Ability.php, Task 2–5: Meldungen X5, {{loop:…}}, lit/divi-dynamic-sources mit post_id + node, Move-Warnung); Ruling X8 (keine Prompts, keine neue Guide-Datei, Ablauf „Corporate Design übernehmen“ in workflows.md) und X7 (Grenzen als Benutzerhinweise).
  • Produces: aktualisierte Guides; zwei Tests, die die Guides an den Code binden (testOverviewNamesEveryIndirectTool: jedes indirekte Tool steht in overview.md; LoopGuideTest: Meldungsphrasen stehen im Code und im Guide, jedes {{loop:<name>}}-Beispiel ist eine echte Divi-Option).

Bestehende Tests, an die der Text gebunden ist (nicht umbauen): ToolNamingTest (indirekte Tools nie als lit-<name>, direkte nie als lit/<name>; Fehlercodes in Backticks ohne lit_; „Weitere Themen“ in overview.md = genau die vorhandenen Guide-Dateien – es kommt keine Datei hinzu, die Liste bleibt unverändert; „Direkt sichtbar“ = genau die direkten Tools – alle Design-Tools sind indirekt, die Liste bleibt), ModuleCatalogTest (Tabelle „Kurzform-Felder“ in divi-shortform.md bleibt unberührt), FeatureTextsTest (keine Formulierungen wie „Schreiben ist gesperrt“).

Veraltete Aussagen, die dieser Task entfernt (Recherche 4e A.3): divi-design-system.md Regel 3 („im Divi-Builder anlegen“, Farben/Variablen/Theme-Optionen/Schriften lassen sich seit 4c/4d per Tool anlegen), der Snapshot-Punkt „vor jeder Änderung … je Bereich die letzten 20“ ohne Bereiche und ohne die Ausnahme Schriften, der Rechte-Absatz nur für Presets; overview.md: der Absatz „Über mcp-adapter-execute-ability“ ohne die zwölf indirekten Design-Tools, Grundregel 4 ohne design_rev, Fehlercodes ohne conflict/in_use/forbidden/snapshot_*/font_*; workflows.md: „Design auf Seiten ausrollen“ als einziger Designablauf. Es gibt in den Guides keine Aussage „folgt in Etappe …“ (geprüft per grep -nE "folgt|noch nicht|4c|4d|Etappe" docs/guides/*.md: keine Treffer).

Geprüfte Fakten (gegen den Code, nicht gegen die Pläne): Eingaben und Beschreibungen von lit/divi-colors-set (DiviColorsSetAbility.php, ColorValue.php: Hex/rgb()/hsl()/Farbname/{{color:<Name>}} + relative mit den Bereichen −360…360 bzw. −100…100, Hauptfarben nur color), lit/divi-variables-set (DiviVariablesSetAbility.php, VariableValue::TYPES: numbers, strings, images, links, fonts; je Aufruf höchstens 50 Einträge, aktive Variablen brauchen einen Wert), lit/divi-theme-options-get/-update (ThemeOptionSpec::KEYS und Bereiche), lit/divi-fonts-upload (FontNameRules: nur A–Z a–z 0–9 Leerzeichen _ -, 1–100 Zeichen, Namen von Google-/Web-Safe-/hochgeladenen Schriften = conflict; Fehlercodes font_fetch_failed, font_invalid; kein Snapshot), Snapshots/Restore (SnapshotStore::LIMIT 20, MAX_BYTES 2 000 000 → snapshot_too_large, snapshot_not_found; Semantik je Bereich laut Beschreibung von lit/divi-design-restore), Rechte (DesignRights: Presets/Farben = edit_theme_options + Visual Builder, Variablen zusätzlich Variablen-Manager, Theme-Optionen = edit_theme_options + Theme-Anpassung, Schriften = upload_files + edit_theme_options + „Eigene Schriften verwalten“; Snapshots listen = edit_theme_options). Nicht übernommen aus der Recherche, weil der Code es nicht hergibt: ein Fehlercode ambiguous_name (mehrdeutige Namen sind validation_failed mit den IDs in fields).

Die Texte unten wurden in einer Kopie des Repos (…/scratchpad/work-4e-C) angewendet; dort laufen die Guide-Tests grün (ToolNamingTest, FeatureTextsTest, ModuleCatalogTest, GuideRepositoryTest, InstructionsBuilderTest), testOverviewNamesEveryIndirectTool ist ohne die Überschrift-Änderungen rot (zwölf fehlende Tools) und mit ihnen grün, LoopGuideTest::testRefusalPhrasesAreTheOnesTheCodeSays ist dort erwartungsgemäß rot, solange Task 2–5 fehlen (Code sagt die Phrasen noch nicht), phpcs ist sauber.

  • Step 1: Tests zuerst (RED)

In tests/Unit/Guides/ToolNamingTest.php vor testOverviewListsExactlyTheDirectTools einfügen:

    /** Every indirect tool is named in the overview (the only place a client learns which `lit/` tools exist). */
    public function testOverviewNamesEveryIndirectTool(): void
    {
        $overview = (string) file_get_contents(dirname(__DIR__, 3) . '/docs/guides/overview.md');
        preg_match_all('#`lit/([a-z]+(?:-[a-z]+)*)`#', $overview, $named);

        $indirect = array_keys(array_filter(self::abilities(), static fn (bool $direct): bool => ! $direct));
        self::assertNotEmpty($indirect);
        self::assertSame([], array_values(array_diff($indirect, $named[1])), 'indirect tools missing in overview.md');
    }

Neue Datei tests/Unit/Guides/LoopGuideTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Guides;

use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * The loop part of the guides (Etappe 4e) says what the tools say: the refusal phrases come from the code, and every
 * {{loop:<name>}} example names an option Divi 5.15.1 really registers (research 4e, table of the loop_* options).
 */
final class LoopGuideTest extends TestCase
{
    /** Divi 5.15.1 option keys (without WooCommerce and ACF repeater options). */
    private const DIVI_LOOP_OPTIONS = [
        'loop_post_title', 'loop_post_excerpt', 'loop_post_date', 'loop_post_modified_date', 'loop_post_author',
        'loop_post_author_bio', 'loop_post_author_profile_picture', 'loop_post_comment_count',
        'loop_post_featured_image', 'loop_post_id', 'loop_post_link', 'loop_post_terms',
        'loop_term_name', 'loop_term_description', 'loop_term_count', 'loop_term_permalink', 'loop_term_taxonomy',
        'loop_term_featured_image', 'loop_user_name', 'loop_user_username', 'loop_user_email', 'loop_user_avatar',
        'loop_user_description', 'loop_user_url', 'loop_menu_text', 'loop_menu_link', 'loop_menu_menu_order',
        'loop_menu_attr_title', 'loop_menu_classes', 'loop_menu_xfn', 'loop_menu_description',
    ];

    private const PHRASES = ['braucht eine Divi-Schleife', 'passt nicht zur Schleife', 'kein Schleifen-Kontext'];

    private static function root(): string
    {
        return dirname(__DIR__, 3);
    }

    /** @return string Content of all guides. */
    private static function guides(): string
    {
        $text = '';
        foreach (glob(self::root() . '/docs/guides/*.md') ?: [] as $file) {
            $text .= (string) file_get_contents($file) . "\n";
        }

        return $text;
    }

    public function testGuidesExplainTheLoopRefusals(): void
    {
        $guides = self::guides();
        foreach (self::PHRASES as $phrase) {
            self::assertStringContainsString($phrase, $guides, 'guides lack "' . $phrase . '"');
        }
    }

    public function testRefusalPhrasesAreTheOnesTheCodeSays(): void
    {
        $code = '';
        $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(self::root() . '/src'));
        foreach ($files as $file) {
            if (str_ends_with((string) $file, '.php')) {
                $code .= (string) file_get_contents((string) $file);
            }
        }

        foreach (self::PHRASES as $phrase) {
            self::assertNotFalse(mb_stripos($code, $phrase), 'no source says "' . $phrase . '"');
        }
    }

    public function testLoopShortFormExamplesNameRealDiviOptions(): void
    {
        preg_match_all('/\{\{loop:([a-z0-9_]+)\}\}/', self::guides(), $examples);
        self::assertNotEmpty($examples[1], 'the guides show no {{loop:…}} example');
        foreach (array_unique($examples[1]) as $name) {
            self::assertContains('loop_' . $name, self::DIVI_LOOP_OPTIONS, '{{loop:' . $name . '}} is no Divi option');
        }
    }
}

Run: php -d memory_limit=512M vendor/bin/phpunit --filter "ToolNamingTest|LoopGuideTest" Expected: FAIL – testOverviewNamesEveryIndirectTool nennt zwölf fehlende Tools (divi-colors-set, divi-design-detach, divi-design-restore, divi-design-snapshots, divi-fonts-upload, divi-preset-get, divi-presets-assign, divi-presets-delete, divi-presets-set, divi-theme-options-get, divi-theme-options-update, divi-variables-set); LoopGuideTest::testGuidesExplainTheLoopRefusals und testLoopShortFormExamplesNameRealDiviOptions scheitern an den noch fehlenden Guide-Texten. (testRefusalPhrasesAreTheOnesTheCodeSays ist grün, wenn Task 2 und 5 die Meldungen „braucht eine Divi-Schleife“, „passt nicht zur Schleife“ und „kein Schleifen-Kontext“ im Code haben; sonst gehört der Fehler zu diesen Tasks, nicht zu den Guides – dann den Wortlaut im Guide an den Code angleichen, nie umgekehrt.)

  • Step 2: Guide-Texte anwenden

Jede Änderung ist ein Edit mit genau diesem Anker (old_string, kommt in der Datei genau einmal vor) und dem Ersatz (new_string). Reihenfolge je Datei wie unten.

docs/guides/divi-design-system.md

Änderung 1 (Anker, alt):

- **Größen** (`sizes`): Schriftgrößen, Inhaltsbreite und Spaltenabstand aus den Theme-Optionen.

Neu:

- **Größen** (`sizes`): Schriftgrößen, Inhaltsbreite und Spaltenabstand aus den Theme-Optionen. Alle 20
  Theme-Optionen mit Standardwerten liefert `lit/divi-theme-options-get` (siehe „Theme-Optionen“).

Änderung 2 (Anker, alt):

3. Gibt es für einen Zweck noch keine Farbe, Variable oder kein Preset, dem Nutzer vorschlagen, es im
   Divi-Builder anzulegen, statt feste Werte zu verteilen.

## Presets anlegen, ändern, löschen

Neu:

3. Gibt es für einen Zweck noch keine Farbe, Variable oder kein Preset, dem Nutzer vorschlagen, es anzulegen
   (`lit/divi-colors-set`, `lit/divi-variables-set`, `lit/divi-presets-set`), statt feste Werte zu verteilen –
   zuerst Probelauf, nach Rückfrage `confirm: true`.
4. Das Designsystem wirkt auf alle Seiten der Website. Werte, Schriften und Abstände nennt der Nutzer; nichts
   erfinden und nie ohne seine Rückfrage bestätigen.
5. Alle Schritte eines Designwechsels (Schriften, Farben, Variablen, Theme-Optionen, Presets) beschreibt
   `workflows`, Abschnitt „Corporate Design übernehmen“.

## Ablauf jeder Änderung

Presets, Farben, Variablen und Theme-Optionen (`lit/divi-presets-set`, `lit/divi-presets-delete`,
`lit/divi-colors-set`, `lit/divi-variables-set`, `lit/divi-theme-options-update`) sowie
`lit/divi-design-restore` und `lit/divi-design-detach` laufen gleich, alle über `mcp-adapter-execute-ability`:

1. Aufruf ohne `confirm` = Probelauf. Er schreibt nichts und antwortet mit `confirm_required`: `diff` (was sich
   ändert), je nach Werkzeug die betroffenen Seiten und `warnings`, dazu die `design_rev`.
2. Dem Nutzer die Änderung zeigen und **erst nach seiner Antwort** denselben Aufruf mit `confirm: true` und der
   `design_rev` senden (wo der Probelauf eine nennt).
3. Vor dem Schreiben legt LIT MCP einen Snapshot an; die Antwort nennt seine `snapshot_id` (siehe „Snapshots und
   Wiederherstellen“).
4. Hat sich der Bestand inzwischen geändert (z. B. im Divi-Builder), kommt `rev_mismatch` – neu prüfen.
   `confirm: true` ohne `design_rev` ergibt `validation_failed`; läuft gerade eine andere Änderung am selben
   Bereich, kommt `conflict` – kurz warten und den Probelauf wiederholen.

Eigene Schriften hochladen (`lit/divi-fonts-upload`) hat einen eigenen Probelauf und keinen Snapshot, siehe
„Eigene Schriften hochladen“.

## Presets anlegen, ändern, löschen

Änderung 3 (Anker, alt):

- `lit/divi-design-snapshots` / `lit/divi-design-restore` – vor jeder Änderung legt LIT MCP einen Snapshot an
  (je Bereich die letzten 20); wiederherstellen mit `snapshot_id`.

Ablauf jeder Änderung: Aufruf ohne `confirm` = Probelauf (`confirm_required` mit `diff`, betroffenen Seiten und
`design_rev`), dem Nutzer die Änderung zeigen, dann derselbe Aufruf mit `confirm: true` und `design_rev`. Hat sich
der Bestand inzwischen geändert (z. B. im Divi-Builder), kommt `rev_mismatch` – neu prüfen. `confirm: true` ohne
`design_rev` ergibt `validation_failed`; läuft gerade eine andere Änderung, kommt `conflict` – kurz warten und den
Probelauf wiederholen.

Rechte: Presets ändern verlangt das Recht, das Design anzupassen, und in Divis Rollen-Editor den Visual Builder.

Neu:

- `lit/divi-design-snapshots` / `lit/divi-design-restore` – Snapshots listen und wiederherstellen (siehe
  „Snapshots und Wiederherstellen“).

Der Ablauf (Probelauf, `confirm: true`, `design_rev`) steht oben unter „Ablauf jeder Änderung“.

Rechte: Presets ändern verlangt das Recht, das Design anzupassen, und in Divis Rollen-Editor den Visual Builder.

Änderung 4 (Anker, alt):

- Danach `lit/divi-presets-delete` aufrufen (Probelauf, `confirm`, `design_rev`); es meldet kein `in_use` mehr,
  solange niemand das Preset neu zugewiesen hat.

Neu:

- Danach `lit/divi-presets-delete` aufrufen (Probelauf, `confirm`, `design_rev`); es meldet kein `in_use` mehr,
  solange niemand das Preset neu zugewiesen hat.

## Farben und Variablen anlegen und ändern

Über `mcp-adapter-execute-ability`, Ablauf wie oben, bis zu 50 Einträge je Aufruf in `items`. Farben und
Variablen werden nie gelöscht, nur deaktiviert bzw. archiviert. Angelegte Farben und Variablen lassen sich sofort
als `{{color:<Name>}}` und `{{var:<Name>}}` verwenden (`divi-placeholders`). Fehlerhafte Angaben (unbekannte ID,
vergebener oder mehrdeutiger Name – die Meldung nennt dann die IDs –, ungültiger Wert) sammelt das Werkzeug unter
`fields` als `validation_failed`; nichts wird gespeichert.

**`lit/divi-colors-set`** – je Eintrag:

- `id` nennt eine vorhandene Farbe (ID `gcid-…`, Name, Alias `primary`, `secondary`, `heading`, `body`, `link` oder
  Optionsname wie `accent_color`); ohne `id` entsteht aus `label` (Name) und `color` eine neue Farbe.
- `color`: Hex (`#rgb`, `#rrggbb`, auch mit Alpha), `rgb()`/`rgba()`/`hsl()`/`hsla()`, ein Farbname (z. B. `red`)
  oder der Platzhalter `{{color:<Name>}}` zusammen mit `relative`. So entstehen abgeleitete Farben, z. B.
  `{"label": "Primär abgestuft", "color": "{{color:primary}}", "relative": {"lightness": 20}}`; `relative` nimmt
  `hue` (−360 bis 360), `saturation`, `lightness` und `opacity` (je −100 bis 100).
- `label` benennt um, `status: inactive` deaktiviert die Farbe, `active` holt sie zurück.
- Die fünf Hauptfarben (`primary`, `secondary`, `heading`, `body`, `link`) ändern nur `color`, und nur mit einem
  festen Wert (kein Platzhalter, kein `label`, `folder` oder `status`).

**`lit/divi-variables-set`** – je Eintrag:

- `id` nennt eine vorhandene Variable (ID `gvid-…` oder Name); ohne `id` entsteht aus `type` (`numbers`,
  `strings`, `images`, `links` oder `fonts`), `label` und `value` eine neue. Der Typ einer Variable bleibt.
- `value` wird je Typ geprüft (Zahl, Text, Bild- oder Link-Adresse; Schrift-Variablen nur mit einem Schriftnamen,
  den Divi kennt). Aktive Variablen brauchen einen Wert.
- `label` benennt um, `status: archived` archiviert, `active` holt die Variable zurück.
- Vor dem Archivieren die Verweise ersetzen: `lit/divi-design-detach` mit `variable` setzt auf Seiten und in
  Presets den aktuellen Wert ein.

Der Probelauf beider Werkzeuge nennt die betroffenen Seiten und `warnings`. Die Rechte stehen unter „Rechte im
Überblick“.

## Theme-Optionen: Schriften, Größen und Layout

`lit/divi-theme-options-get` liefert je Theme-Option den gespeicherten Wert (`null` = nicht gesetzt), den
wirksamen Wert, den Divi-Standard, Typ und Bereich bzw. Auswahl und die `design_rev`; `keys` grenzt auf einzelne
Optionen ein. `lit/divi-theme-options-update` ändert sie mit `set` (`{"Schlüssel": Wert}`, Probelauf und
`confirm` wie oben); `null` setzt eine Option auf den Divi-Standard zurück. Die 20 Schlüssel:

- Schriften: `heading_font`, `body_font`, `primary_nav_font`, `secondary_nav_font` – der exakte Name einer
  Schrift, die Divi kennt (Google-Schrift, Web-Safe-Schrift oder hochgeladene Schrift); bei unbekannten Namen
  antwortet das Werkzeug mit `validation_failed` und bis zu fünf Vorschlägen. Stärken: `heading_font_weight`,
  `body_font_weight` (100–900).
- Größen: `body_font_size` (10–32), `body_header_size` (22–72) und je eine Tablet- und Smartphone-Größe
  (`tablet_body_font_size`, `phone_body_font_size`, `tablet_header_font_size`, `phone_header_font_size`); sie
  erben vom nächstgrößeren Wert, solange sie nicht gesetzt sind.
- Text: `body_font_height`, `body_header_height` (Zeilenhöhe, 0,8–3), `body_header_spacing` (Buchstabenabstand,
  −2 bis 10), `body_header_style` (`italic`, `uppercase` oder `underline`; Divi kennt hier nur einen Wert).
- Layout: `content_width` (960–1920), `gutter_width` (1–4), `section_padding`, `row_padding` (je 0–10).

Die Hauptfarben gehören nicht hierher, sondern in `lit/divi-colors-set`. Theme-Optionen wirken auf die gesamte
Website: den Nutzer ausdrücklich fragen. Jede Änderung legt einen Snapshot an.

## Eigene Schriften hochladen

`lit/divi-fonts-upload` lädt eine Schrift von öffentlichen Adressen in Divis hochgeladene Schriften:

- `files`: je Format eine Datei (`woff2`, `woff`, `ttf`, `otf`) als öffentliche http(s)-Adresse, höchstens 5 MB je
  Datei; `name`: 1–100 Zeichen aus A–Z, a–z, 0–9, Leerzeichen, `_` und `-` (keine Umlaute), kein Name einer
  Google-, Web-Safe- oder schon hochgeladenen Schrift (auch nicht in anderer Schreibweise); `weights`: `"all"`
  oder eine Liste aus 100 bis 900; `generic_family`: `serif`, `sans-serif` (Standard), `monospace`, `cursive` oder
  `fantasy`.
- Ablauf: Der Probelauf (ohne `confirm`) lädt die Dateien wirklich, prüft Inhalt und Format und nennt je Datei
  Format, Größe und SHA-256 – installiert aber nichts. Dem Nutzer zeigen, nach seiner Antwort denselben Aufruf mit
  `confirm: true`; er lädt die Dateien erneut.
- Fehler: `font_fetch_failed` (eine Adresse ist nicht erreichbar), `font_invalid` (die Datei passt nicht zum
  Format, etwa ein Bild als `woff2`), `conflict` (der Name ist vergeben), `validation_failed` (Name oder Angaben
  ungültig). Nichts wird gespeichert.
- Es gibt **keinen Snapshot** und keine Rücknahme per Werkzeug: Eine hochgeladene Schrift entfernt nur der
  Nutzer im Divi-Builder.
- Datenschutz: Hochgeladene Schriften liefert der eigene Server aus, nicht Google. Die Lizenz der Schrift klärt der
  Nutzer.
- Danach ist die Schrift ein gültiger Name für `lit/divi-theme-options-update` (z. B. `body_font`) und für
  Schrift-Variablen (`lit/divi-variables-set` mit `type: fonts`).

## Snapshots und Wiederherstellen

Vor jeder Änderung an Presets, Farben, Variablen und Theme-Optionen sichert LIT MCP den Bereich (je Bereich bleiben
die letzten 20 Snapshots); die Antwort nennt die `snapshot_id`. Wäre der Bereich zu groß für eine Sicherung (über
2 MB), antwortet das Werkzeug mit `snapshot_too_large` und schreibt nichts.

- `lit/divi-design-snapshots` mit `area` (`presets`, `colors`, `variables` oder `theme_options`; Standard
  `presets`) listet die Snapshots eines Bereichs, neueste zuerst, mit Zeitpunkt, Werkzeug und Protokolleintrag.
- `lit/divi-design-restore` mit `snapshot_id`: Der Probelauf zeigt den Unterschied zum aktuellen Stand (`diff`,
  `design_rev`); nach Rückfrage `confirm: true`. Vorher sichert LIT MCP den aktuellen Stand selbst – auch eine
  Wiederherstellung lässt sich zurücknehmen. Ist die ID unbekannt oder verdrängt, kommt `snapshot_not_found`.
- Was ein Restore macht, hängt vom Bereich ab: **Farben**, die es im Snapshot nicht gibt, entfallen. **Variablen**,
  die erst nach dem Snapshot entstanden, bleiben archiviert erhalten (Divi löscht keine). **Theme-Optionen**, die
  im Snapshot nicht gesetzt waren, kehren zum Divi-Standard zurück. **Presets**: Rechte-Regel `unfiltered_html`
  wie oben.
- Nicht gesichert und nicht wiederherstellbar: Seiteninhalt (dort gibt es die WordPress-Revision, die
  `revision_id` der Antwort) und hochgeladene Schriften.

## Rechte im Überblick

Fehlt ein Recht, antwortet das Werkzeug mit `forbidden`; die Meldung nennt das Recht, den fehlenden Divi-Rollenwert
bitte ein Administrator unter Divi → Rollen-Editor freigeben.

- Presets und Farben: `edit_theme_options` und in Divis Rollen-Editor „Visual Builder verwenden“.
- Variablen: dasselbe und der Variablen-Manager.
- Theme-Optionen: `edit_theme_options` und die Theme-Anpassung (Divis Customizer, kein Visual Builder nötig).
- Schriften hochladen: `upload_files`, `edit_theme_options` und „Eigene Schriften verwalten“.
- Snapshots listen: `edit_theme_options`; wiederherstellen: die Rechte des jeweiligen Bereichs.
- Seiten umschreiben (`lit/divi-presets-assign`, `lit/divi-design-detach`): zusätzlich `edit_post` je Seite.

docs/guides/divi-placeholders.md

Änderung 5 (Anker, alt):

| `{{link:<ID>}}` | Link auf einen anderen Eintrag

Neu:

| `{{loop:<Option>}}`, z. B. `{{loop:post_date}}` | Inhalt des aktuellen Treffers einer Divi-Schleife (Option ohne `loop_`); nur in Modulen innerhalb einer Schleife, siehe „Schleifen-Inhalte“ |
| `{{link:<ID>}}` | Link auf einen anderen Eintrag

Änderung 6 (Anker, alt):

| `{{color:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) |
| `{{var:<Name>}}` | globale Variable (Zahl, Text, Bild, Link) |

Neu:

| `{{color:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`); neue Farben legt `lit/divi-colors-set` an und sie gelten sofort als Platzhalter |
| `{{var:<Name>}}` | globale Variable (Zahl, Text, Bild, Link, Schrift); neue legt `lit/divi-variables-set` an |

Änderung 7 (Anker, alt):

| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext |

Neu:

| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext (die Theme-Optionen `heading_font` und `body_font`, siehe `divi-design-system`) |

Änderung 8 (Anker, alt):

zeigt Divi den gespeicherten Wert, nicht die Beschriftung.

## `$variable`-Werte

Neu:

zeigt Divi den gespeicherten Wert, nicht die Beschriftung.

## Schleifen-Inhalte

Ein Element mit eingeschalteter Divi-Schleife (Abschnitt, Zeile, Spalte, Gruppe oder Modul; im Divi-Builder unter
„Schleife“) wiederholt sich je Treffer der Abfrage. Module darin zeigen mit `loop_*`-Inhalten Daten des jeweiligen
Treffers, z. B. das Datum des Beitrags. LIT MCP legt keine Schleifen an und stellt ihre Abfrage nicht ein: das macht
der Nutzer im Divi-Builder. Danach setzt der KI-Assistent die Inhalte:

```json
{ "type": "text", "content": "{{loop:post_date}}" }
```

`{{loop:<Option>}}` nimmt den Namen der Option ohne `loop_` (`{{loop:post_date}}` schreibt `loop_post_date`) und steht
wie jeder Platzhalter allein im Wert. Einstellungen wie ein Datumsformat gibt es nur in der Vollform
(`$variable({…})$` mit `settings`, z. B. `"date_format": "custom", "custom_date_format": "d.m.Y"` – Divis Auswahl hat
kein deutsches Format). Welche Optionen ein Element anbietet, liefert `lit/divi-dynamic-sources` mit `post_id` und
`node` (siehe unten).

Die Art der Schleife bestimmt die Optionen:

| Schleife (Abfrage) | Optionen |
|---|---|
| Beiträge (Beitragstypen, aktuelle Seite) | `loop_post_*`, z. B. Titel, Auszug, Datum, Autor, Beitragsbild, Link, Kategorien oder Schlagwörter (`loop_post_terms`); Feld eines Beitrags: `loop_post_meta_key_manual_custom_field` |
| Terme (Taxonomien) | `loop_term_*`, z. B. Name, Beschreibung, Anzahl, Link; Feld: `loop_term_meta_key_manual_custom_field` |
| Benutzer (Rollen) | `loop_user_*`, z. B. Name, E-Mail, Avatar, Beschreibung; Feld: `loop_user_meta_key_manual_custom_field` |
| Menü | `loop_menu_*`, z. B. Text, Link, Beschreibung |

Regeln und Fehlermeldungen:

1. Der Schleifen-Kontext ergibt sich aus der Seite: das Modul selbst oder das nächste Element über ihm, dessen
   Schleife eingeschaltet ist (Desktop-Einstellung „Schleife ein“). Bei verschachtelten Schleifen gilt die innerste
   für alles darunter, egal welcher Art.
2. Ohne Schleife lehnt das Werkzeug jeden `loop_*`-Inhalt ab: `Schleifen-Inhalt "loop_post_date" braucht eine
   Divi-Schleife in diesem Modul oder einem übergeordneten Element (Abschnitt, Zeile, Spalte, Gruppe).` Dann das
   Modul in das Schleifen-Element setzen (`parent` = die Spalte bzw. das Element mit der Schleife) oder den
   Nutzer bitten, die Schleife im Divi-Builder einzuschalten.
3. Passt die Option nicht zur Art der Schleife, steht in der Meldung: `… passt nicht zur Schleife dieses Elements
   (Typ: Terme).` (Typ: Beiträge, Terme, Benutzer oder Menü). Eine Option der passenden Art wählen. Das ist eine
   Sicherheitsgrenze, kein Schönheitsfehler: Divi liest die Feld-Optionen in einer Term-, Benutzer- oder
   Menü-Schleife sonst bei dem Objekt mit derselben ID und gäbe fremde Daten aus. Nie umgehen.
4. Die Feld-Optionen (`loop_*_meta_key_manual_custom_field`) gibt es nur in der Vollform; für den Schlüssel gelten
   dieselben Regeln wie bei `{{field:<meta_key>}}` (kein Schlüssel mit `_` am Anfang).
5. Vor dem Setzen lesen: `lit/divi-dynamic-sources` mit `post_type`, `post_id` und `node` (Pfad-ID des Moduls aus
   `lit-divi-outline`) nennt zusätzlich die `loop_*`-Optionen, die zur Schleife dieses Knotens passen; liegt der
   Knoten in keiner Schleife, kommt stattdessen der Hinweis „kein Schleifen-Kontext“. Ohne `post_id` und `node`
   liefert das Werkzeug wie bisher keine Schleifen-Optionen.
6. `lit-divi-move` erlaubt das Verschieben von Schleifen-Inhalt aus der Schleife heraus oder in eine Schleife
   anderer Art (Divi lässt es zu), antwortet aber mit einer Warnung: der Inhalt zeigt dort nichts oder falsche
   Daten. Warnung dem Nutzer nennen, Modul zurückschieben oder den Inhalt neu setzen.

Grenzen (bewusst nicht gelöst; dem Nutzer sagen, statt zu umgehen):

- Die Prüfung liest die Schleife aus den Attributen des Moduls und seiner Vorfahren. Eine Schleife, die nur über ein
  **Preset** eingeschaltet ist, erkennt sie nicht; dann Schleifen-Inhalte abgelehnt – der Nutzer schaltet die
  Schleife am Element selbst ein.
- **Globale Module** (`[global <ID>]`) haben keinen Schleifen-Kontext; `loop_*` dort ist abgelehnt.
- `lit-content-create` und Theme-Builder-Vorlagen kennen keinen Schleifen-Kontext: erst die Seite mit Schleife
  anlegen (Nutzer im Divi-Builder), dann die Inhalte mit `lit-divi-insert` setzen.
- Beim Kopieren von Schleifen-Containern samt Inhalt (`lit/divi-node-copy`, Bibliothek) bleiben Schleifen-Inhalte
  für Konten ohne `unfiltered_html` abgelehnt; ein Administrator oder der Divi-Builder erledigt das.
- Schleifen über ACF-Wiederholungsfelder (`repeater_…`) unterstützt LIT MCP nicht.
- Das Attribut `module.advanced.loop` lässt sich über `attrs` schreiben; Schleifen ein- oder auszuschalten ändert den
  Kontext aller Module darunter und geschieht nur auf ausdrücklichen Wunsch. Abfrage und Anzahl gehören in den
  Divi-Builder.

## `$variable`-Werte

docs/guides/divi-shortform.md

Änderung 9 (Anker, alt):

Dasselbe Preset auf vielen Seiten oder Modulen setzen: `lit/divi-presets-assign` (siehe `divi-design-system`).

Neu:

Dasselbe Preset auf vielen Seiten oder Modulen setzen: `lit/divi-presets-assign` (siehe `divi-design-system`).

## Schleifen-Inhalte

Module in einem Element mit eingeschalteter Divi-Schleife zeigen Daten des jeweiligen Treffers, z. B.
`{"type": "text", "content": "{{loop:post_date}}"}` mit `parent` = die Spalte (oder das Element) der Schleife.
Das gilt in `lit-divi-insert` und `lit-divi-update` (`set`); außerhalb einer Schleife und in einer Schleife anderer
Art lehnt das Werkzeug `loop_*`-Inhalte ab (Meldungen, Optionen und Grenzen: `divi-placeholders`, Abschnitt
„Schleifen-Inhalte“). `lit-divi-move` warnt, wenn ein Modul mit Schleifen-Inhalt die Schleife verlässt.
`lit-content-create` kennt keinen Schleifen-Kontext.

docs/guides/workflows.md

Änderung 10 (Anker, alt):

1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module.

Neu:

1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module. Fehlt eine
   Farbe, Variable oder ein Preset, dem Nutzer vorschlagen, sie anzulegen (`lit/divi-colors-set`,
   `lit/divi-variables-set`, `lit/divi-presets-set`; Ablauf in `divi-design-system`), statt feste Werte zu setzen.

Änderung 11 (Anker, alt):

Ein Preset soll auf vielen Seiten gelten oder ein altes Preset verschwinden (Presets anlegen: `divi-design-system`):

Neu:

Ein Preset soll auf vielen Seiten gelten oder ein altes Preset verschwinden (Presets anlegen: `divi-design-system`;
ein ganzes Corporate Design: „Corporate Design übernehmen“):

Änderung 12 (Anker, alt):

5. Stichprobe: `lit-divi-outline` einer betroffenen Seite (zeigt Preset und Gruppen-Presets je Modul) und
   `lit-page-render-summary`; dem Nutzer die Vorschau nennen.

Neu:

5. Stichprobe: `lit-divi-outline` einer betroffenen Seite (zeigt Preset und Gruppen-Presets je Modul) und
   `lit-page-render-summary`; dem Nutzer die Vorschau nennen.

## Corporate Design übernehmen

Farben, Schriften und Abstände des Corporate Designs ins Designsystem von Divi übertragen. Die Werte nennt der
Nutzer (Styleguide, Logo-Farben, Schriftdateien) – nichts erfinden. Jede Änderung: Probelauf, Rückfrage,
`confirm: true` mit `design_rev` (Ablauf: `divi-design-system`, „Ablauf jeder Änderung“); alles wirkt auf die
ganze Website.

1. Ist-Stand lesen: `lit-divi-design-system` (Farben, Variablen, Presets; bei großen Websites mit `only` und
   `module` eingrenzen) und `lit/divi-theme-options-get` (Schriften, Größen, Layout). Dem Nutzer sagen, was schon
   da ist, und die Quelle der neuen Werte klären.
2. Schriften: Gibt es die Schrift schon in Divi (Google-Schrift, Web-Safe-Schrift, hochgeladene)? Dann genügt der
   exakte Name in Schritt 5. Sonst `lit/divi-fonts-upload` (der Probelauf lädt die Dateien und nennt Format, Größe
   und SHA-256; nach Rückfrage `confirm: true`). Es gibt keinen Snapshot, entfernen kann die Schrift nur der Nutzer
   im Divi-Builder; den Datenschutz-Hinweis (eigener Server statt Google) nennen.
3. Farben: `lit/divi-colors-set` – die Hauptfarben (`primary`, `secondary`, `heading`, `body`, `link`) ändern, weitere
   Farben mit `label` und `color` anlegen, abgeleitete mit `{{color:<Name>}}` und `relative`. Der Probelauf nennt
   betroffene Seiten und Warnungen; dem Nutzer zeigen.
4. Variablen: `lit/divi-variables-set` für Abstände, Zahlen, Texte, Bilder, Links und Schriften. Eine Variable,
   die nicht mehr gebraucht wird, erst mit `lit/divi-design-detach` (`variable`) aus Seiten und Presets lösen, dann
   mit `status: archived` archivieren.
5. Theme-Optionen: `lit/divi-theme-options-update` (`heading_font`, `body_font`, Größen, Zeilenhöhen,
   `content_width`, `gutter_width`, Abstände; `null` = Divi-Standard). Das ändert jede Seite der Website: den Nutzer
   ausdrücklich fragen.
6. Presets: `lit/divi-presets-set` (aus einem gestalteten Modul mit `from_node` oder mit `attrs` und Platzhaltern wie
   `{{color:primary}}`; `make_default: true` für das Standard-Preset), kontrollieren mit `lit/divi-preset-get`.
   Auf Seiten ausrollen: „Design auf Seiten ausrollen“; alte Presets mit `lit/divi-design-detach` lösen und mit
   `lit/divi-presets-delete` löschen.
7. Kontrolle: eine Beispielseite mit `lit-page-render-summary` und `lit-divi-outline` prüfen; dem Nutzer
   `preview_url` und `edit_url` nennen.
8. Zurücknehmen: `lit/divi-design-snapshots` (`area` = `presets`, `colors`, `variables` oder `theme_options`), dann
   `lit/divi-design-restore` (Probelauf, `confirm: true`); die `snapshot_id` steht auch in jeder Änderungsantwort.
   Für Seiteninhalt gibt es nur die WordPress-Revisionen, für hochgeladene Schriften kein Restore.
9. Fehler: `conflict` (andere Änderung läuft, bei Schriften: Name vergeben) – warten und den Probelauf wiederholen
   bzw. anderen Namen wählen; `rev_mismatch` (Bestand inzwischen geändert) – neu lesen; `forbidden` (Recht fehlt, die
   Meldung nennt es) – Administrator bitten; `in_use` (Preset wird noch verwendet) – erst `lit/divi-design-detach`;
   `divi_untested`/`divi_incompatible` – siehe „Ein Divi-Werkzeug lehnt ab“.

## Inhalte einer Divi-Schleife setzen

Beispiel: Datum des Beitrags in einer Beitragsliste. Details, Optionen und Grenzen: `divi-placeholders`,
Abschnitt „Schleifen-Inhalte“.

1. Der Nutzer schaltet im Divi-Builder die Schleife an einem Element ein (Abfrage: Beiträge, Terme, Benutzer oder
   Menü); LIT MCP legt Schleifen nicht an. Das Element (Pfad-ID, z. B. `s0.r1.c0`) mit dem Nutzer klären;
   `lit-divi-node-get` zeigt seine Attribute (`module.advanced.loop`).
2. `lit/divi-dynamic-sources` mit `post_type`, `post_id` und `node` (Pfad-ID eines Moduls in der Schleife) nennt die
   passenden `loop_*`-Optionen mit Platzhalter.
3. `lit-divi-insert` mit `parent` = das Schleifen-Element, z. B.
   `{"type": "text", "content": "{{loop:post_date}}"}`; vorhandene Module mit `lit-divi-update`.
4. Lehnt das Werkzeug ab („braucht eine Divi-Schleife“ bzw. „passt nicht zur Schleife“), die Meldung ernst nehmen:
   das Modul in die Schleife setzen oder eine Option der passenden Art wählen, nie die Prüfung umgehen.
5. `lit-page-render-summary` prüfen und dem Nutzer die Vorschau nennen; ohne Treffer der Abfrage bleibt der Inhalt
   leer.

docs/guides/overview.md

Änderung 13 (Anker, alt):

4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools
   verlangen den `rev` des letzten Lesevorgangs.

Neu:

4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools
   verlangen den `rev` des letzten Lesevorgangs, Divi-Seiten die `tree_rev`, Änderungen am Designsystem
   die `design_rev` aus dem Probelauf.

Änderung 14 (Anker, alt):

`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek

Neu:

`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Designsystem (Presets: `lit/divi-preset-get`,
`lit/divi-presets-set`, `lit/divi-presets-delete`, `lit/divi-presets-assign`, `lit/divi-design-detach`; Farben und
Variablen: `lit/divi-colors-set`, `lit/divi-variables-set`; Theme-Optionen: `lit/divi-theme-options-get`,
`lit/divi-theme-options-update`; Schriften: `lit/divi-fonts-upload`; Snapshots: `lit/divi-design-snapshots`,
`lit/divi-design-restore`; Ablauf: `lit-guide` topic `divi-design-system`), Divi-Bibliothek

Änderung 15 (Anker, alt):

- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
  `message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.

Neu:

- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
  `message`, `hint` (und ggf. `fields`). Dem `hint` folgen. Weitere Codes: `forbidden` (ein Recht fehlt, die
  Meldung nennt es), `in_use` (ein Preset oder Eintrag wird noch verwendet; `usages` nennt die Fundstellen),
  `conflict` (eine andere Änderung am Designsystem läuft noch, oder der Name einer Schrift ist vergeben),
  `snapshot_not_found`, `snapshot_too_large`, `font_fetch_failed` (Schriftdatei nicht ladbar) und `font_invalid`
  (Datei passt nicht zum Format).
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen. Beim Designsystem enthält die Antwort
  die `design_rev`, die mit `confirm: true` zurückgegeben wird; `rev_mismatch` heißt dort: der Bestand hat sich
  seit dem Probelauf geändert, den Probelauf wiederholen.

Änderung 16 (Anker, alt):

LIT MCP gibt jede Divi-Fähigkeit einzeln frei, z. B. „Seiten lesen“, „Seiten schreiben“, „Presets schreiben“ oder
„Theme Builder lesen“.

Neu:

LIT MCP gibt jede Divi-Fähigkeit einzeln frei, z. B. „Seiten lesen“, „Seiten schreiben“, „Presets schreiben“,
„Farben schreiben“, „Variablen schreiben“, „Theme-Optionen schreiben“, „Schriften hochladen“ oder
„Theme Builder lesen“.
  • Step 3: Abgleich mit Task 1–5 (ABWEICHUNGEN lesen)

Die Guides beschreiben das Verhalten laut Rulings. Vor dem Test-Lauf jede Aussage gegen den fertigen Code und die ## ABWEICHUNGEN der Tasks 1–5 prüfen und den Guide-Text (nie den Code) berichtigen, wo sie abweichen:

grep -rn "braucht eine Divi-Schleife\|passt nicht zur Schleife\|kein Schleifen-Kontext" src | head          # Wortlaut X5/X6
grep -n "'term_name'\|'post_date'\|LOOP" src/Integrations/Divi/Compose/Placeholders.php | head          # Beispiele {{loop:post_date}}, {{loop:term_name}}
grep -n "post_id\|'node'" src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php | head               # Eingabe von dynamic-sources
grep -n "repeater" src/Integrations/Divi/Compose/LoopScope.php src/Integrations/Divi/Compose/LoopContext.php  # X2: Entscheidung zu repeater_*
grep -n "warn" src/Integrations/Divi/Pages/DiviMoveAbility.php | head                                      # Move-Warnung statt Ablehnung
grep -rn "StoredNodeGuard" src/Integrations/Divi/Pages/DiviNodeCopyAbility.php | head -3                   # Kopieren: Aussage über Konten ohne unfiltered_html

Zu klären und im Guide festzuhalten: (a) Wortlaut der drei Phrasen identisch zum Code; (b) die Beispiele {{loop:post_date}}/{{loop:term_name}} sind in der LOOP-Tabelle; (c) lit/divi-dynamic-sources: ist post_type mit post_id + node noch Pflicht (sonst „mit post_type, post_id und node“ in divi-placeholders.md und workflows.md anpassen); (d) hat ein Task repeater_* doch unterstützt, den Grenzen-Punkt zu ACF streichen und die Art in die Tabelle aufnehmen; (e) Move-Warnung: sagt sie „Schleife“ (das Smoke prüft das); (f) die Grenze „Kopieren … ohne unfiltered_html“ stimmt nur, wenn Task 4/die Aufräumschritte das Verhalten so lassen – sonst streichen oder anpassen; (g) hat Task 2 eine eigene Meldung für Feld-Optionen (Meta-Schlüssel) in der Vollform, den Punkt 4 der Regeln darauf abstimmen.

  • Step 4: Tests und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit --filter "ToolNamingTest|LoopGuideTest|FeatureTextsTest|ModuleCatalogTest|GuideRepositoryTest|InstructionsBuilderTest" Expected: PASS.

Run: composer test && composer lint Expected: grün und sauber.

Sichtprüfung: grep -nE "lit_[a-z]+" docs/guides/*.md zeigt keinen Code mit lit_-Präfix (außer lit_mcp…); in den neuen Texten steht jedes Design-Tool als lit/divi-…, jedes direkte als lit-divi-…; docs/guides/overview.md „Weitere Themen“ unverändert. Die Länge: divi-design-system.md wächst von 132 auf rund 260 Zeilen (die übrigen Guides bleiben unter 190) – das ist gewollt, weil G11/X8 keine neue Datei erlauben; wird sie beim Lesen zu lang, ist ein eigener Guide (divi-design-colors…) eine spätere Entscheidung (dann „Weitere Themen“ und den Test anpassen).

  • Step 5: Commit
git add docs/guides/divi-design-system.md docs/guides/divi-placeholders.md docs/guides/divi-shortform.md docs/guides/workflows.md docs/guides/overview.md tests/Unit/Guides/ToolNamingTest.php tests/Unit/Guides/LoopGuideTest.php
git commit -m "docs: Guides für Farben, Variablen, Theme-Optionen, Schriften, Snapshots und Schleifen-Inhalte" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 8: Security-Audit Etappe 4 mit Fix-Welle

Files:

  • Create: docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md
  • Modify (nur durch die Fix-Welle, je Befund): die betroffenen Klassen unter src/ und deren Tests
  • Lies als Vorlage (Format): docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md, …-3b-security-audit.md

Interfaces:

  • Consumes: alles aus Etappe 4 (Tasks 1–7 dieses Plans und 4a–4d).
  • Produces: eine Audit-Notiz im Format der Audits 3a/3b (Scope, Environment, Method, Result, Findings nach Schwere, Info/hardening, Deferred items re-checked, Areas checked and found sound, Status nach der Fix-Welle) und eine Fix-Welle für alle Critical/High-Befunde (Medium nach Urteil, siehe Step 3).

Dieser Task ist ein Review-Task (Opus als Auditor), kein TDD-Task. Der Auditor ändert keinen Produktcode; Fixes macht eine eigene Fix-Welle.

  • Step 1: Auditor beauftragen (Opus, unabhängig, schreibend nur in die Notiz)

Der Auditor bekommt den vollen Auftrag aus diesem Step als Prompt. Pflichtinhalt des Prompts:

  1. Scope (Spec §12): (a) Preset-attrs/from_node: XSS über Style-/Render-Attribute, unfiltered_html, Inhalte in Presets; (b) Font-Upload: SSRF, Dateityp/Magic Bytes, Pfad/Dateiname, Größen-/DoS-Grenzen, Rollback, Rechte; (c) Massen-Schreibrechte: PageRewriter (lit/divi-presets-assign, lit/divi-design-detach inkl. Variablen), edit_post je Live-Fundstelle (P8/P8a), globale Instanzen, Bibliothek/Theme Builder, HARD_CAP/50-Seiten-Grenze; (d) Schleifen-Optionen: Typabgleich als Sicherheitsgrenze (Post-Meta über Term-/Benutzer-ID, siehe Global Constraints), Meta-Schlüssel-Regeln S29/S30 für loop_*_meta_key_, Kontext-Bestimmung (X1), StoredNodeGuard, Move-Warnung; (e) Snapshot-Restore: Rechte je Bereich, Größe (MAX_BYTES), FIFO, Sperre (DesignLock), Wiederherstellen fremder/manipulierter Snapshot-Daten (werden die Rohdaten beim Restore erneut geprüft? Divi-Sanitizer?), Snapshot-ID-Parsing; (f) alles Weitere aus Etappe 4: Theme-Optionen-Whitelist/Sanitizer, et_update_option-Ganz-Array-Schreiben, Farben/Variablen-Adapter, ItemKeys/Gruppenschlüssel (numerische Namen), Rechte in permission() UND execute() aller neuen Tools, Release-Gate je Tool, Fehlertexte/Orakel.
  2. Einstiegspunkte (zu lesen): alle Klassen in src/Integrations/Divi/Design/ (u. a. DesignRights, DesignChange, DesignLock, SnapshotStore, PresetComposer, PresetAssignment, PresetBook, PageRewriter, TreeRewrite, ReferenceUsageQuery, VariableDetachRewrite, FontInstaller, FontNameRules, FontSignature, FontCatalog, DiviFontsUploadAbility, ThemeOption*, Divi*SetAbility, Divi*Restore*, *RestorePlanner, ItemKeys, GroupKeys), src/Integrations/Media/RemoteFileFetcher.php, MediaImporter.php, src/Integrations/Divi/Compose/{DynamicContentRules,VariableRef,Placeholders,LoopScope,LoopContext,StoredNodeGuard,ValueCleaner}.php, src/Integrations/Divi/Pages/{DynamicContentMap,DiviDynamicSourcesAbility,DiviInsertAbility,DiviUpdateAbility,DiviMoveAbility}.php, die Adapter Design/{GlobalColorsAdapter,GlobalVariablesAdapter,ThemeOptionsAdapter,FontCatalog,FontInstaller,DesignSystemWriter}.php samt Manifesten, src/Abilities/AbilityRegistrar.php (Rechte-/Gate-Reihenfolge).
  3. Bekannte, bewusst belassene Restrisiken (nachprüfen, nicht neu melden, aber bewerten): W13 (Lost Update von et_update_option, body_header_style, fehlende Bereichsprüfung in Divi, font_add-Teilerfolg, remove_static_resources nur mit angemeldetem Benutzer, Tablet/Phone-14/30-Quirk, DNS-Rebinding-Fenster zwischen allows() und cURL, Schema-Fehler ohne lit-Feldpfade), Ruling R9 (Umschreiber ohne unfiltered_html-Zwang), P8a mit post_ids, SnapshotStore::attachLog ohne Fehlerprüfung und RMW ohne Lock, DesignLock-Übernahmerennen, Rulings X7(a)–(e) (Schleifen können über attrs ein-/ausgeschaltet werden; globale Module ohne Kontext; Kopieren fail closed; Presets mit Schleife; ACF-Wiederholungsfelder), Mirror customizer-typography.
  4. Methode (wie 3a/3b): alle neuen/geänderten src/-Dateien lesen; jede Schreib-Route bis zu den Prüfungen verfolgen; Divi-Code lesen, wo Divi-Verhalten behauptet wird; selbstreinigende wp eval-file-Probes in DDEV (Projekt ~/Projekte/WordPress, Plugins postlos und asynclistry deaktiviert, danach Zustand wiederherstellen; jede Probe stellt et_divi/et_uploaded_fonts/Variablen/Seiten wieder her und wird nach dem Lauf per md5-Vergleich verifiziert); simulierte Rollen (In-Memory-WP_User) für die Rechte-Matrix: Editor ohne edit_theme_options, Admin ohne unfiltered_html (DISALLOW_UNFILTERED_HTML), ohne upload_files, ohne custom_fonts_management/variables_manager/theme_customizer; Testlauf vendor/bin/phpunit (Zahl notieren).
  5. Ausgabe: docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md im Format der Audits 3a/3b: Kopf (Scope inkl. Commit-Range d73144a^..HEAD oder die tatsächliche Etappe-4-Range, Environment, Method, Result: Anzahl je Schwere), ## Findings (most severe first) je Befund ### <Kürzel><n> – <Schwere> (<Kategorie>): Titel mit Where (Datei:Zeile), Scenario, Fix; ## Info / hardening; ## Deferred items re-checked (jedes bekannte Restrisiko aus Punkt 3 mit Urteil: akzeptabel / zu beheben); ## Areas checked and found sound; Schweregrade Critical/High/Medium/Low/Info. Keine Produktänderungen, keine Commits.
  6. Antwort des Auditors: höchstens 15 Zeilen (Zahl Befunde je Schwere, die zwei wichtigsten, Ort der Notiz).

Run (Controller): Dispatch mit Modell Opus; den Prompt aus den Punkten 1–6 zusammenstellen und den Notiz-Pfad nennen. Expected: Notiz liegt im Arbeitsverzeichnis (ungestaged), Befundzahlen in der Antwort.

  • Step 2: Notiz sichten und Befunde einordnen

Der Controller liest die Notiz, ordnet jeden Befund ein und trägt Rulings ins Ledger:

  • Critical/High → Fix-Welle (Step 3), verbindlich.
  • Medium → beheben, wenn der Fix klein ist und kein Verhalten der Tools ändert; sonst als Ruling: mit Begründung und Aufnahme in „Status nach der Fix-Welle“ als „bewusst belassen“.
  • Low/Info → bleiben in der Notiz; bei einem Fix mit weniger als etwa 10 Zeilen in die Fix-Welle aufnehmen.
  • Befunde, die eine Produktentscheidung verlangen (z. B. Schema-Fehlerform projektweit, Aufspalten von Rechten), werden nicht still entschieden, sondern mit Optionen und Empfehlung im Abschlussbericht dem Nutzer vorgelegt (das ist eine der vier Stopp-Klassen nur, wenn der Befund Critical/High ist und jeder Fix eine Verhaltensänderung für Nutzer wäre).

Run: git status --short Expected: nur die Notiz ist neu.

  • Step 3: Fix-Welle (Sonnet, EIN Dispatch)

Prompt-Inhalt: alle zu behebenden Befunde der Notiz wörtlich (ID, Where, Scenario, Fix), TDD für Verhaltensänderungen (Test zuerst, RED, Fix, GREEN), je Befund oder logische Gruppe ein Commit (Nachricht nennt die Befund-ID, z. B. fix(audit H1): …), composer test und composer lint vor dem letzten Commit; falls ein Befund einen Smoke berührt (Font-Upload, Seiten-Umschreiber, Schleifen), den passenden Smoke in DDEV neu laufen lassen (divi-theme-fonts-smoke.sh, divi-presets-assign-smoke.sh, divi-loop-smoke.sh; Pluginzustand notieren und wiederherstellen; md5 der berührten Optionen vorher/nachher). Keine anderen Änderungen. Danach ein scoped Re-Review (Sonnet, nur die Befunde und das Fix-Diff).

Run: composer test && composer lint Expected: grün (Zahl größer als vor der Fix-Welle bei neuen Tests).

  • Step 4: Notiz abschließen

Der Controller (oder ein kleiner Docs-Dispatch) ergänzt ## Status nach der Fix-Welle (2026-10-19) in deutscher Sprache: je Befund „behoben in “ oder „bewusst belassen: <Begründung>“, dazu „Scoped Re-Review: Approved; N Tests grün, Smokes grün“. Die Rest-Risiken aus ## Deferred items re-checked stehen mit Urteil darin.

  • Step 5: Commit
git add docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md
git commit -m "docs: Security-Audit Etappe 4 mit Status nach der Fix-Welle" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 9: Version 0.7.0, Spec- und Notiz-Nachträge

Files:

  • Modify: wp-lit-mcp.php (Zeile 6, Plugin-Header Version:), src/Plugin.php (Zeile 48, Versions-Konstante), readme.txt (Zeile 7 Stable tag/Version plus neuer Changelog-Abschnitt)
  • Create: docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md (Kopie der Divi-Schleifen-Recherche, redaktionell auf X1–X10 abgestimmt)
  • Modify: docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md (Status, §9: Version 5.15.1 statt 5.14.0, Kontextregel/Arten/Meldungen wie gebaut), docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md (Abschnitt „Etappe 4e“ mit X1–X10 und Restpunkten), docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md (Nachtrag „Schleifen (Etappe 4e)“ mit den Divi-Fakten), docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md nur, falls dort noch ein 4e-Hinweis steht

Interfaces:

  • Consumes: Zustand nach Task 8 (alle Fixes).

  • Produces: Version 0.7.0, nachgeführte Spec und Notizen.

  • Step 1: Aktuelle Versionsstellen lesen

Run: grep -n "0\.6\.1" wp-lit-mcp.php src/Plugin.php readme.txt composer.json; grep -rn "0\.6\.1" tests src docs --include=*.php --include=*.md --include=*.txt | head Expected: Treffer genau in wp-lit-mcp.php:6, src/Plugin.php:48, readme.txt:7; kein Test pinnt die Nummer (die Recherche hat das geprüft; weicht der Befund ab, die Stelle im Bericht nennen und mit ändern).

  • Step 2: Version erhöhen

In den drei Dateien jeweils nur 0.6.1 → 0.7.0 ersetzen (Zeilen wie oben, keine weiteren Änderungen an diesen Zeilen). In readme.txt unter == Changelog == (Format der vorhandenen Einträge übernehmen, Sprache wie dort) einen neuen Abschnitt = 0.7.0 = voranstellen mit diesen Punkten (an den Stil der Datei anpassen, keine Zeilen über 120 Zeichen erzwingen):

  • Designsystem schreiben (Etappe 4): Presets lesen, anlegen, ändern, löschen, einzelnen Modulen und ganzen Seiten zuweisen und Verweise überall entfernen (Probelauf, Bestätigung, Snapshot, Wiederherstellen).
  • Globale Farben und Variablen anlegen und ändern; Variablen werden archiviert, nicht gelöscht; Verweise auf Variablen durch ihren Wert ersetzen.
  • Divi-Theme-Optionen (Schriften, Größen, Layout) lesen und ändern; eigene Schriften von einer URL hochladen; Schriftnamen mit Vorschlägen prüfen.
  • Schleifen-Inhalte (loop_*, {{loop:…}}) in Modulen innerhalb einer Divi-Schleife; lit/divi-dynamic-sources zeigt sie je Knoten.
  • Divi-Freigabe je Fähigkeit statt pauschaler Sperre; neue Guides; Security-Audit der Etappe.

Run: composer test && composer lint Expected: grün (keine Versions-Tests).

  • Step 3: Recherche, Spec und Notizen nachführen
  1. …/scratchpad/research-4e-loop-divi.md als docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md ablegen (nur redaktionell glätten, alle Dateizeilen behalten; Fußnote „5.15.1“).
  2. Spec: im Kopf „Status“ von „Entwurf zur Freigabe“ auf „umgesetzt (Etappe 4a–4e, Version 0.7.0)“ ändern; §9 – „aus Divi 5.14.0 abgeleitet“ → „5.15.1“, die Absätze Kontext/Optionen/Meldungen/Kurzform/dynamic-sources/Move so formulieren, wie gebaut (X1–X7, Arten, Typabgleich als Sicherheitsgrenze); S36 als abgelöst vermerken.
  3. Entscheidungsnotiz: Abschnitt „Etappe 4e (Plan 2026-10-19-etappe-4e-schleifen-guides-audit.md)“ mit X1–X10 in je einem Satz und „Offene Kleinpunkte aus 4e“ (aus dem Ledger, soweit nicht behoben) sowie die aus 4d übernommenen offenen Punkte als Liste „Folgearbeit nach Etappe 4“ (Schema-Fehler-Form projektweit, Schriftnamen in Preset-/Modul-Attributen, Rev-Helfer der Book-Klassen, dryRun()/installed() aus DiviFontsUploadAbility::upload(), sha256-Pin beim Upload-confirm, weitere Minor-Punkte).
  4. Formate-Nachtrag „Divi 5.15.1 – Schleifen (Etappe 4e)“: Speicherort/Schlüssel der Schleife, queryType-Werte, Kontextregel (desktop.value.enable === "on"), loop_*-Optionen und Arten, Gefahr des Fehlabgleichs (Post-Meta über Term-/Benutzer-ID), Rendering-Verhalten außerhalb einer Schleife, Grenzen (Presets, globale Module).

Run: grep -n "Entwurf zur Freigabe\|5\.14\.0" docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md Expected: keine Treffer mehr für den Status; „5.14.0“ nur noch dort, wo es historisch die Formate-Basis meint (dann mit Zusatz „Formate aus 5.2.1/5.14.0 geprüft, 5.15.1 maßgeblich“).

  • Step 4: Commit
git add wp-lit-mcp.php src/Plugin.php readme.txt docs/superpowers
git commit -m "chore: Version 0.7.0 (Etappe 4); Spec und Notizen nachgeführt" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 10: Gesamtverifikation, Gesamtreview und Abschluss von Etappe 4

Files:

  • Modify: Memory etappe-4-stand und MEMORY.md (außerhalb des Repos, /home/janniclas/.claude/projects/-home-janniclas-Projekte-WordPress-wp-content-plugins-WP-LIT-MCP/memory/)

Interfaces:

  • Consumes: alles aus den Tasks 1–9.

  • Produces: grüne Suite, grüne Smokes, Gesamtreview ohne offene wichtige Befunde, aktualisierter Stand; Etappe 4 abgeschlossen.

  • Step 1: Suite, Lint, Divi-Prüfung

Run: composer test && composer lint Expected: alle Tests grün (3368 + neue), phpcs ohne Befund.

Run: cd /home/janniclas/Projekte/WordPress && ddev exec wp --user=<Admin> lit-mcp divi-check Expected: Exit-Code 0; alle Fähigkeiten nutzbar.

  • Step 2: Alle Smokes

Pluginzustand notieren (ddev exec wp plugin list), postlos und asynclistry deaktivieren, nacheinander ausführen (jeder muss mit ALL CHECKS PASSED enden), Zustand wiederherstellen:

cd /home/janniclas/Projekte/WordPress
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-loop-smoke.sh
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-theme-fonts-smoke.sh
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-colors-variables-smoke.sh
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh

Expected: sechsmal ALL CHECKS PASSED; md5 von et_divi, et_uploaded_fonts, et_divi_global_variables vor und nach allen Läufen identisch; keine Testseiten/-Schriften übrig.

  • Step 3: Gesamtreview (zwei unabhängige Opus-Reviews parallel)

Dispatch (zwei Subagents, Modell Opus; Paket scripts/review-package PLAN BASE HEAD über die Etappe-4e-Commits; Ledger-Datei mit Deferred-Items und Rulings mitgeben):

  1. Fachreview: Korrektheit gegen Plan und Spec (§9), Sicherheit des Schleifen-Pfads (Typabgleich, Kontextregel, Move-Warnung, Update mit mehreren Scopes, StoredNodeGuard), Konsistenz der Guides mit dem Code, Audit-Notiz vs. tatsächliche Fixes, Version; Triage aller Ledger-Einträge (fix-before-merge | follow-up | wontfix).
  2. anthropic-skills:clean-code-review (Quick) über die neuen/geänderten Klassen (LoopScope, LoopContext, DynamicContentRules, VariableRef, Placeholders, DiviUpdateAbility, DiviMoveAbility, DiviDynamicSourcesAbility).

Wichtige Befunde in EINER Fix-Welle (Sonnet) beheben, danach EIN scoped Re-Review (Sonnet); Kleinigkeiten in die Folgearbeit-Liste.

  • Step 4: Memory nachführen

etappe-4-stand.md und MEMORY.md: Etappe 4 fertig (4a–4e, Version 0.7.0, HEAD, Testzahl, Datum), nichts gepusht, Folgearbeit nach Etappe 4 (Liste aus der Entscheidungsnotiz); Eintrag zur nächsten Etappe (Gesamt-Spec docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md §13: Etappe 5) – vorher dort lesen, was als Nächstes dran ist.

  • Step 5: Abschluss

git status sauber; nichts pushen oder mergen ohne Auftrag. Dem Nutzer berichten: Testzahl, Smoke-Ergebnisse, Audit-Ergebnis (Zahl je Schwere, was behoben), Rulings (inkl. X1–X10 und Audit-Rulings), offene Folgearbeit, und fragen, ob gemergt/gepusht werden soll.


Anhang: Abweichungen der Task-Autoren (geprüft, im Plan berücksichtigt)

Die Autoren haben ihre Tasks in Kopien des Repos mit Stand-ins für fremde Tasks ausgeführt. Verbindlich bleibt der Taskinhalt; bei Zählungen (Tests) gilt beim Umsetzen der echte Stand.

Vom Controller aufgelöst: DynamicContentMap::loopFields hat die Signatur loopFields(string $kind, int $postId = 0) (Task 2); die Aufrufe in den Tasks 4 und 5 sind darauf angepasst (A1 der Tasks 3–5 ist damit gegenstandslos).

Tasks 1-2

  • A1 (Ergänzung LoopScope): zusätzlich public function fits(string $option): bool und public static function kindOfOption(string $option): ?string. Grund: die Regeln brauchen die Art der Option; die Map liefert sie nach Gruppe (X2), die Sicherheitsprüfung nimmt sie wie Divi selbst aus dem Namen (Meta-Typ aus dem Namen, get_meta_type_from_name). Beide Quellen müssen übereinstimmen (Map-Eintrag der Art und Name der Art), sonst Ablehnung – fail closed bei Filtern, die Optionen in fremde Gruppen hängen. Task 4/5 dürfen fits() für die Move-Warnung nutzen.
  • A2 (loopFields): Signatur loopFields(string $kind, int $postId = 0) statt loopFields(string $kind); der Vertragsaufruf funktioniert unverändert (Post 0), Task 4/5 sollten die Post-ID mitgeben (dieselbe Registry-Abfrage wie forPost($postId); die Felder der Loop-Optionen hängen in 5.15.1 nicht vom Beitrag ab, nur Woo/ACF von Plugins).
  • A3 (Standardverhalten): „ohne Kontext wie heute“ gilt für alles außer loop_*: ein loop_*-Name in einer eingespeisten Map war bisher kontextfrei erlaubt (nur in Tests, VariableRefTest:209/222), jetzt abgelehnt – das verlangt die Sicherheitsgrenze der Global Constraints. Der Test wird entsprechend umgestellt.
  • A4 (label()/active() bei nicht unterstützter Schleife): ofQueryType('repeater_x') u. ä. liefert active() === false, kind() === null, queryType() === 'repeater_x', label() === 'repeater_x'; label() von none() ist "". Die Regeln unterscheiden „keine Schleife“ (Meldung 1) von „Schleife ohne unterstützte Art“ (Meldung 2 mit dem rohen queryType) über queryType() === null. Task 5 („kein Schleifen-Kontext“) sollte bei queryType() !== null && ! active() besser „Schleife vom Typ … ohne unterstützte Optionen“ melden.
  • A5 (Strenge gegenüber Divi): queryType wird nicht wie bei Divi mit sanitize_key() normalisiert – "Post_Types" oder "post_types " sind für die Werkzeuge keine Beitrags-Schleife (Divi rendert sie als solche). Fail closed; der Visual Builder schreibt nur die exakten Werte.

Tasks 3-5

  • A1 – loopFields() braucht die Post-ID. Der Vertrag nennt DynamicContentMap::loopFields(string $kind), aber Divis Registry ist je Eintrag (raw(int $postId), get_options($postId, 'edit')). Tasks 3–5 rufen loopFields(string $kind, int $postId = 0): array<string, array<string, string>> (Option ⇒ Einstellung ⇒ Feldtyp, dieselbe type()-Abbildung wie fromOptions(), [] ohne Registry). Behält Task 2 loopFields(string $kind) (z. B. auf einem vorher gelesenen Stand), ändern sich genau zwei Aufrufe: DiviComposition::loopFields() und DiviDynamicSourcesAbility::withLoop().
  • A2 – Wer die Loop-Optionen in die Map bringt. Der Vertrag lässt offen, wie die passenden loop_*-Optionen in DynamicContentRules kommen. Diese Tasks setzen voraus: forPost() liefert weiter keine loop_* (Kontext-frei); DiviComposition::dynamicContentMap() hängt loopFields($scope->kind(), $postId) an ($map + …), und DynamicContentRules erlaubt einen loop_*-Namen nur, wenn der Kontext aktiv ist und die Map ihn nennt; sonst X5-Meldung 1 (kein aktiver Kontext) bzw. X5-Meldung 2 mit LoopScope::label() (aktiv, aber nicht in der Map). Lässt Task 2 forPost() die loop_* behalten und prüft die Art selbst (Name/Gruppe), ist $map + loopFields() ein harmloser Doppel-Eintrag – aber dann muss Task 2 sicherstellen, dass ein loop_* aus der Map ohne Kontext abgelehnt wird (der bisherige VariableRefTest::testInjectedDynamicContentMap akzeptiert loop_post_title ohne Kontext). Die Tests hier prüfen die Meldungsteile „braucht eine Divi-Schleife“, „passt nicht zur Schleife dieses Elements (Typ: Terme)“ und „Typ: Beiträge“ wörtlich nach X5.
  • A3 – inLoop() liefert static (wie forPost()), nicht self; für Aufrufer gleich.
  • A4 – Insert fail closed statt Kontext-Umschaltung (X3 erlaubt das). Begründung im Task-4-Abschnitt; die Ablehnung ist bewusst grob (eine neue Schleife irgendwo im Aufruf plus Schleifen-Inhalt irgendwo im Aufruf). Gleiches gilt ohne eigenen Code für lit-content-create/Theme-Builder-Anlage (dort mit Meldung 1, nicht mit dem Insert-Hinweis).
  • A5 – Verdrahtung schon in Task 5. X6 sagt „Task 9 verdrahtet“; der Auftrag dieses Abschnitts verlangt die minimale DiviIntegration-Änderung (ein benanntes Argument). Task 9 muss dafür nichts mehr tun.
  • A6 – Zwei Registry-Lesungen je Komposition mit Schleife (forPost() und loopFields(); DynamicContentMap::raw() cacht nicht). Divi baut die Liste dabei zweimal; ohne Schleife bleibt es bei einer Lesung. Kann Task 2 billig je Post merken, entfällt das.
  • A7 – Fähigkeiten von lit/divi-dynamic-sources. X6: „die Tool-Fähigkeiten ändern sich nicht“ – gelesen als: der Grundsatz ohne Eingabe bleibt [dynamic-sources, roles]; mit post_id kommt page-read (+ ofEntry) dazu, weil der Loader ohne gesperrtes PageRead nicht laufen darf.
  • A8 – Abhängigkeit von Task 1: LoopScope::ofQueryType() für repeater_<acf> (X2) – der Code hier nutzt nur active(), kind(), queryType(), label() und vergleicht Kontexte über queryType() (Update-Cache) bzw. kind() (Move-Warnung); jede Task-1-Entscheidung für repeater_* funktioniert, solange kind() ohne Schleife null ist.
  • A9 – Testhilfe: DiviWriterFixture::writer() bekommt einen letzten Parameter ?DynamicContentMap $registry (bestehende Aufrufe unverändert).
  • A10 – Nachweis: Alle RED/GREEN-Zahlen stammen aus Läufen in einer Kopie des Repos (Stand 6203dbb) mit Platzhaltern für Task 1/2 nach dem Vertrag (A1, A2); die drei VariableRefTest-Fälle, die Task 2 umschreibt (testInjectedDynamicContentMap, testDerivedMetaKeysThatAreFineStayAccepted, testMetaKeysObeyTheRuleEvenFromTheMap), waren dabei übersprungen. Gesamtläufe: 3368 → 3374 (Task 3) → 3383 (Task 4) → 3389 (Task 5), composer lint ohne Meldung; nach dem echten Task 2 verschiebt sich die Basis.

Tasks 6-7

Tasks 8-10