- tests/Support/FakeChangeLog extends ChangeLog: records record()/recordPartial()
calls, increasing ids, optional failure; new FakeChangeLog(id) / partialsOnly() /
any(id) / forbidden() mirror what the replaced mocks allowed. assertRecorded() /
assertPartials() check count and the named arguments. Calls a fake does not allow
throw and are also verified in TestCase::tearDown (one assertion per test that
uses a fake), so swallowed change-log calls still fail like a never() expectation.
- All ChangeLog mocks of ability/integration tests replaced (the admin tests that
stub query/purge/clear keep their mocks); expectException tests that checked the
partial log now catch and assert.
- SchemaFixture::managed(?divi) replaces the 21 isManaged-only ContentSchema mocks;
DiviFixtures::schema() uses it.
Tests 1397 -> 1404 (FakeChangeLogTest); no test has fewer assertions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- DiviFixtures: markup(), givenPost(), schema(), designSystem(), catalog(),
dynamicContentRegistry(), maliciousRefs() - moved verbatim out of
DiviPageLoaderTest, DesignSystemTest, ModuleCatalogTest, DynamicContentMapTest
and VariableRefTest (its malicious() provider now returns maliciousRefs()).
- ComposeFixture moves to tests/Support.
- DiviWriterFixture: modules(), designReader(), composition(), a recording saver(),
log() and writer(&$saved, &$failures, unfiltered, loader) for the five tests that
wired the Divi write stack by hand.
No test cases or assertions changed (1397 / 5834).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NewDraft owns insert (partial write on a throwing hook), refuse and discard (via
Rollback::attempt and WriteFailed::*) and the partial logging; ContentCreateAbility
keeps checkDivi/prepare/combined (Errors::fieldsOf) and the result. Both discard
callers still pass REASON_EXTRAS. Behaviour unchanged; NewDraftTest adds the
throwing-delete branch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- PostRow::update(id, columns, failure) / ::save(): the slashed wp_update_post of
internal row writes, refusals as RuntimeException '<failure>: <why>'. Used by
ContentUpdate::restoreRow, PostCloner::updateRow, MediaMetaWriter apply/restore.
- PostGuard::assertNoMissedSchedule(postId, message) replaces the re-read + check in
ContentUpdate::restoreRow, PostCloner and DiviSaver::restore (DiviSaver keeps its
own kses window; M4).
- PostCloner::restore uses Rollback::attempt.
Log messages unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- WriteFailed::rolledBack()/partial() replace the 11-parameter constructor (now
private); the hint/state of either error are plain $hint/$state.
- Rollback::attempt(...steps) runs every undo step and collects failure messages;
used by ContentCreate::discard, ContentUpdate::rollBack, MediaUpdate::rollBack and
DiviSaver::restore.
- Errors::clientMessage/codeOf/fieldsOf replace DiviReplaceTextAbility::reason,
the JSON digging in MediaUpdateAbility::failure and ContentCreateAbility::fields.
- Errors docblock documents the exception/WP_Error split.
- ContentCreateAbility::discard takes the client reason as a parameter (both callers
still pass REASON_EXTRAS; changing the Divi text needs a ruling).
Error payloads unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The three lazy db() methods use the $this->db ?? $GLOBALS['wpdb'] one-liner that
SampledMetaSource, MediaUsageFinder and ConnectionRepository already use.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Log::failure(context, detail) keeps the '[wp-lit-mcp] <context> failed: <detail>'
format of the nine former error_log() sites; Log::line() covers the change log line.
One phpcs ignore instead of eleven.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Hints (PostPresenter::DIVI_NOTE, outline, catalog) and server instructions (tree_rev rule)
name the Divi write tools; guides divi-shortform and divi-placeholders, additions to
divi-structure, divi-attrs, overview (tool groups, 'nur lesen'), workflows, content-types.
- D7 wording and the S7 exception in readme, guides and spec 1.3/5.2; readme 0.3.0.
- ToolNamingTest: every referenced guide topic exists; overview lists exactly the topics.
- tests/Integration/divi-write-smoke.sh: all write tools via OAuth, editor without
unfiltered_html with benign probes (S6), Divi's own save filters on the new fixture
synthetic-divi-filters.html (S7); admin-render.php accepts the new wrap class.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
- Art column: only lit/media-delete and lit/divi-delete say "löscht",
lit/post-trash "Papierkorb", other destructive tools "überschreibt".
- ConnectionUsage records only responses with status < 400 (mcp-oauth answers
auth failures with WP_REST_Response 401, not WP_Error).
- Log result column: translated "unbekannt" instead of the raw value.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs,
aria-current on the active tab; own CSS/JS enqueued only on this screen.
- Verbindung: ordered "So verbindest du Claude" steps, copy button (core
clipboard.js + wp.a11y.speak), Site-Health-style status list, German details.
- Zugriff: roles as form-table fieldset, connections with site date format,
empty-state row, confirm on revoke; ConnectionUsage records last use via
WP_Application_Passwords::record_application_password_usage (once a day).
- Tools: plain-language legend, counter as inline notice, groups as core
postboxes (postbox.js toggles, per-user closed state, summary when closed),
fixed column widths with new "Art" column, submit above and below.
- Protokoll: site-timezone times with UTC tooltip, result icons, filters with
labels in the list table nav, retention link to Limits.
- Limits: short labels with units and descriptions; snapshot count hidden but
preserved.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New input divi (1-30 sections, short form like lit-divi-insert) builds a Divi 5
layout in one divi/placeholder. The whole description is checked before the
insert (Divi's map for "no entry yet"); the draft is inserted with its normal
WordPress fields only, then DiviContentComposer composes again bound to the new
entry and stores it via DiviSaver::saveNew (same kses window as save(), strict
DiviFlags). A refusal or failure deletes the draft. Result adds tree_rev and the
outline.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tools tab shows one block per integration with a group mode (inherited by its
tools) and a read-only switch that disables all non-readonly abilities of the
group. ExposurePolicy resolves tool override > group override > tool default;
lit/guide keeps its default, direct-only tools fall back to direct under a
group "indirekt". Group entries live in lit_mcp_exposure under "@group:<id>"
and "@readonly:<id>"; the old flat format stays valid. The ability->group map
comes lazily from IntegrationRegistry (Divi is not loaded at plugins_loaded).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TextReplacer replaces only in plain-text segments (S8 split), outside
script/style bodies, inserts the replacement cleaned per field kind and
refuses any result whose tags, entities or references differ from the
original plus the inserted text. Unchanged strings are never cleaned.
Per page all or nothing with the tree_rev lock; Throwables are reported
per page (S10).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4),
WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live
registry (S22), S31 wiring, S35 control strip at apply() entry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TreeEditor (position/insert/move/remove/duplicate on the BlockTree) and
Warnings (post-write quality checks), plus S18's outline breadth limit
on the 2a Outline/DiviOutlineAbility.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
Labels, background_color and preset ids go through the choke point (S13);
ambiguous preset names fail with their ids (S13a); fullwidth + rows is an
error. Carry-ins: control characters stripped before the marker check (S32),
kind override may only tighten, reference alt texts dropped, color check via
VariableRef::typeOf (S33).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
ShortformWriter routes every short-form string through AttrWriter::cleanValue()
with the curated field kind (new optional kind override), exposes the color
check for background_color and guards builder-only paths on removal too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
Critical-1/S28: sanitizers that strip <x>, NUL or %XX could rebuild the
$variable( marker after the raw check; every cleaned string (AttrWriter
at every depth, VariableRef settings) is checked again. S29: custom_meta_
map names and *meta_key setting values obey META_KEY. Minor-2: malformed
dynamic content maps fail with InvalidArgumentException.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
S26: every css.* path is gated like S7 for users without unfiltered_html.
S27: Divi ignores settings on global variable and theme font references,
so any settings there are rejected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
I-1: reject )$ in every decoded string setting and in the re-serialized
JSON. S21: string settings cleaned and re-serialized. S22: injectable
dynamic-content allow map (name => setting => field type), loop_* only via
the map. S23: unknown content.innerContent is rich text. S24: keys below
scalar kinds are unknown, ...Url path segments are URLs. S25: isVariable
removed. M-1: real \u escapes in tests, malicious suite as editor, fixture
html() raw for unfiltered_html. M-2: S7 gate at any depth plus
css.advanced.customCss.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
VariableRef parses, validates and re-serializes every $variable(...)$ value
(S1/S2/S12); AttrWriter routes strings by catalog kind incl. per-subName
composite kinds and key-name URL fallback (S3/S3a), rejects odd value keys
(S13) and Divi HTML/attribute fields without unfiltered_html (S7);
ambiguous design-system labels fail with their ids (S13a); R2 finding
recorded (S5).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
node-get's "type" is now the short module name ("heading"), matching
lit-divi-outline, instead of the namespaced "divi/heading"; the smoke test's
own hardcoded expectation is updated to match, and now also checks the new
post-level rev is present.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- lit/divi-node-get now also returns the post-level rev (Rev::ofPost, same
optimistic lock lit-content-update needs) next to tree_rev, and reports a
node's type as the short name ("button"), matching lit-divi-outline instead
of the namespaced "divi/button". description() notes that $variable(...)$
values shown are re-validated when written, never taken over unchecked.
- lit/divi-outline validates its optional `id` input against the same
path-ID pattern lit-divi-node-get uses (a malformed id is now a validation
error, not a "not found"), and its outputSchema's per-node `required` list
now includes info/presets/children, which are always present.
- NodeSummary::label() (the admin label shown in outlines and node-get)
whitespace-normalizes and bounds its result via Text::shorten(), like every
other summary string, instead of returning a raw, unbounded value.
- docs/superpowers/notes/.../etappe-2a-abnahme.md corrected: only a global
module is "nur lesbar" - a specialty section is a normal, editable
divi/section block; the outline just marks it "[specialty]".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lit/divi-catalog's path_kinds previously named one kind per whole attribute
path, built from Divi's metadata component name alone. For a composite path
(Divi groups several named sub-fields, "subName", under one path – e.g.
buttonOne.innerContent holds "text" and "linkUrl") that was wrong: the whole
path got one guessed kind (or none), even though the sub-fields can differ
(fullwidth-header's buttonOne/buttonTwo.innerContent -> "text", though it
also carries a "linkUrl"; image.innerContent -> "image", though the value
also carries alt/id/titleText the metadata never names on its own).
AttrPathIndex::kinds() now resolves a composite path to a map
subName => kind instead, preferring Divi's features.dynamicContent.type over
the (often shared/generic) field component for a named sub-field – this is
what tells a button's plain text sub-field apart from its linkUrl sub-field,
both of which use the same generic "divi/text" component. A plain (non-
composite) path is unaffected. Any subName the metadata never names (or that
disagrees between candidates) is therefore simply absent from the map, and
ModuleCatalog::pathKind() defaults it, like every unresolved path, to
"unknown" - the strictest routing for 2b's sanitizer, never a guess.
ModuleCatalog::pathKind() gains an optional $subName parameter to read a
composite path's map. docs/guides/divi-attrs.md documents the new shape and
that lit-divi-catalog without `module` lists all modules.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Final fix wave for stage 2a, load-bearing for 2b's writers:
- Block::setAttrs()/Container::insertChild()/removeChild() refuse on an opaque
block (undecodable attrs or a global module) instead of silently corrupting it.
- Container::append() is now @internal (Parser only) and refuses on a void
block, so a child can no longer be silently dropped from the serialized output.
- Block::create() validates the block name against namespace/block-name.
- Json::decodeObject() returns null (-> opaque) for a top-level object whose
keys are a plain list ("0","1",...) rather than returning [] and letting the
Serializer re-encode it as a corrupting JSON array.
- AttrMap::set() rejects empty path segments and keeps an existing stdClass
value an object when rewrapping it (no silent object->list flip); AttrMap::get()
is updated in step so it can still read back through such an object.
- Block::copy() deep-clones stdClass attribute values instead of sharing them.
- Json::encode()'s existing JsonException-on-invalid-UTF-8 behaviour is documented.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review fix round 1 for lit/page-render-summary: extract shared Text::shorten()
(reused by NodeSummary), replace data: URIs with a short marker, prefer
data-src/data-lazy-src over placeholder src, dedup nested p/li/blockquote
text and ignore script/style/noscript content, restore every setup_postdata
global (not just $post), drain output buffers robustly around the_content,
and disclose that rendering can trigger oEmbed/network requests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
Review fix round 1 on the lit/divi-catalog task:
- AttrPathIndex::collectKinds() no longer records a kind from a
component.type === "group" node (a composite of several sub-kinds,
e.g. button.innerContent = text + link + target), and kinds() now
drops any attrName with more than one distinct candidate kind
instead of last-write-wins (e.g. imageIcon.innerContent had onoff +
icon + image sub-fields sharing one attrName). Both previously
produced a wrong-but-present kind, which is more dangerous for 2b's
sanitization routing than "unknown". Verified against the committed
blurb/button/fullwidth-header fixture entries and live against the
full 100-module catalog.
- AttrPathIndex::fromDefaults() now builds on AttrMap::flatten()
instead of a parallel walker.
- ModuleCatalog::shortName() replaces the duplicated
substr($name, strlen('divi/')) in DiviCatalogAbility.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
Adds per-path attribute kind (text/richtext/code/url/image/color/enum/
onoff/number/list/icon), derived from Divi's field component metadata
where an attribute path names one unambiguously (module.paths() itself
is unchanged; kinds() is a new, separate lookup exposed as path_kinds
on lit/divi-catalog's module detail). This is not in the task-4 brief;
it is added because 2b's sanitization routing (ruling R3) needs a
metadata-derived field kind per attribute path. Verified live against
the full 100-module catalog (564/4358 paths resolve a kind; the rest
default to "unknown" and fall back to 2b's key-name routing).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7