Skip to content

Commit 1611dfe

Browse files
ifahimrezaclaude
andauthored
Phase 4: builder taxonomy registry, tree profile contract, unified walkers (#69)
* fix: audit-log failed confirmed-destructive calls; warn on wrapper collisions - Saddle_Approval::gate now logs a confirmed destructive execution even when the executor returns WP_Error — a partial mutation with no audit trail is worse than a noisy one. The entry carries a "FAILED after confirmation" marker plus the error message. - Saddle_Integrations::wrap surfaces genuine wrapper-name collisions via _doing_it_wrong instead of silently dropping the partner tool; its own wrappers are tracked so idempotent re-runs stay silent. Part of Phase 1 (correctness) of the 2026-07 architecture review (plugpressco/saddle-pro#39). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * feat: security hardening — token user-binding, credential marker, split log caps, domain enforcement - Approval tokens are bound to the previewing user: with several agents on one site, agent A's preview can no longer be confirmed by agent B (saddle_token_user_mismatch). - Saddle-issued app passwords are recorded by UUID in user meta (saddle_issued_credentials); credential scoping, the XML-RPC block, and client revoke/rotate/list key on that immutable marker instead of the user-editable display name. Legacy prefix-named keys migrate on sight. - Saddle_Log GC caps denials (300) and executed mutations (1000) as separate filterable buckets, batch raised to 500, so denial noise can never evict change history. - Opt-in domain-drift enforcement (saddle_enforce_tier_domain, default off): write/admin abilities refuse on a migrated/cloned domain until the owner re-confirms the tier; exposed via GET/POST /settings. Phase 2 of the 2026-07 architecture review (#63). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * refactor: shared integration engine, accessor resolver, gate registry, small dedupes - New Saddle_Integration_Engine: the wrap/executor safety logic that existed as ~280 near-identical lines in free and Pro now lives once in free. Saddle_Integrations is catalog + filter names + context shape. The engine also lands the Phase-2 hardening deferred to it: full-args `bind` on wrapped destructive tools (a confirm can no longer change arguments after preview), merged target key list, and a force_destructive catalog override for partners that forget the destructive annotation. - New Saddle_Accessors: one build→filter→instanceof→409 resolution for lint-page / render-node / verify-page (was three hand-synced copies); verify's magic count(skipped)>=3 heuristic replaced with the explicit resolver verdict. Error codes and message strings unchanged. - Saddle_Capabilities records each ability's enforced (level, cap) at permission() build time; denial_reason() now mirrors the closure's exact order, gains the previously-missing capability reason, and reads the enforced tier instead of re-deriving from meta. - Saddle_Abilities::require_readable_post() replaces the copy-pasted read preamble; the two theme.json preset resolvers in the Gutenberg lint accessor merge into one preset_map(). Phase 3 of the 2026-07 architecture review (#64). record()/record_action() was reviewed and intentionally left split: record() is the sole entry carrying `type`, which denial logging needs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * refactor: builder taxonomy registry, enforced tree profile contract, unified node walkers - New Saddle_Builders: one table (detection order, per-post signals, site-wide product signals, owned block namespaces) behind builder_signature(), the context builder warnings, and the native-tree namespace guard — three previously drifting lists ("Divi 5" vs "Divi" vs ['divi']) now share a source of truth. Filter-emitted labels are byte-identical to before. - Saddle_Tree is now abstract with an abstract validate(): the validation profile is an enforced contract (same philosophy as the lint/render accessor interfaces) instead of a convention. Both existing profiles already conform — declaration only. - The flat-node-list shape helpers (children_of, descendants_of, is_descendant, sections) live once on Saddle_Lint; Saddle_Lint_Rule and Saddle_Render delegate instead of carrying verbatim copies. - Version 1.1.0 (new public API: integration engine, accessors resolver, builders registry). Phase 4 of the 2026-07 architecture review (#65). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * chore: WordPress.org release prep for 1.1.0 From the wp.org submission audit (no blockers found): - readme.txt gains the 1.1.0 changelog entry. - languages/saddle.pot regenerated at 1.1.0 (was stale at 1.0.0). - The parked class-saddle-ecosystem.php no longer ships (Gruntfile exclude) — reviewers ask about unreferenced code. - package.json version synced to 1.1.0. - phpcs: Yoda condition in the token user check, engine constructor docblock summary, phpcbf alignment in the admin REST class. Tree is now 0 errors / 1 accepted warning ($include param name). - admin bundle rebuilt from current admin/src. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * fix: Plugin Check compliance for the wp.org submission Ran the official Plugin Check plugin (via wp-cli on divi-dev) against the 1.0.0 tree; three real findings, all fixed: - readme title drops the word "WordPress" ("Saddle – Control Your Site with AI (MCP Server)") — the trademark rule forbids it anywhere in a plugin name. - The bundled wp-mcp library's translatable strings now use the 'saddle' text domain (152 i18n calls; identifiers like the mcp-adapter/* ability ids, the ability category, and the WP-CLI command name untouched) — clears 147 TextDomainMismatch errors and makes those strings actually translatable through the plugin's language pack. - uninstall.php's bare $timestamp global renamed to $saddle_gc_timestamp. Remaining Plugin Check output is dev-tree-only files the release zip already excludes (dotfiles, internal .md docs, phpcs/phpunit configs) plus the cosmetic readme-vs-header name-length mismatch warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * chore: merge Plugin Check fixes; uninstall cleans the 1.1.0 option and marker meta Merges fix/wporg-plugin-check into the 1.1.0 stack and adds the two cleanup entries 1.1.0 introduced: the saddle_enforce_tier_domain option and the saddle_issued_credentials user meta. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fb67a4b commit 1611dfe

14 files changed

Lines changed: 331 additions & 132 deletions

Gruntfile.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ module.exports = function ( grunt ) {
136136
'!.wordpress.org/**',
137137
// Lint config — dev-only.
138138
'!phpcs.xml.dist',
139+
// Parked Phase-3 scope — intentionally never loaded
140+
// (see the note in saddle.php); don't ship dead code.
141+
'!includes/class-saddle-ecosystem.php',
139142
// Dev caches.
140143
'!.phpunit.result.cache',
141144
// Self-hosted updater — see the `channel` task below.

includes/abilities/core-content.php

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1791,34 +1791,17 @@ public static function require_readable_post( array $input ) {
17911791
* @return string|null Builder name, or null for ordinary content.
17921792
*/
17931793
public static function builder_signature( $post ) {
1794-
$content = (string) $post->post_content;
1795-
$builder = null;
1796-
1797-
if ( false !== strpos( $content, '<!-- wp:divi/' ) ) {
1798-
$builder = 'Divi 5';
1799-
} elseif ( 'on' === get_post_meta( $post->ID, '_et_pb_use_builder', true ) || false !== strpos( $content, '[et_pb_section' ) ) {
1800-
$builder = 'Divi (classic)';
1801-
} elseif ( 'builder' === get_post_meta( $post->ID, '_elementor_edit_mode', true ) ) {
1802-
$builder = 'Elementor';
1803-
} elseif ( get_post_meta( $post->ID, '_fl_builder_enabled', true ) ) {
1804-
$builder = 'Beaver Builder';
1805-
} elseif ( get_post_meta( $post->ID, '_bricks_page_content_2', true ) ) {
1806-
$builder = 'Bricks';
1807-
} elseif ( false !== strpos( $content, '[vc_row' ) ) {
1808-
$builder = 'WPBakery';
1809-
} elseif ( get_post_meta( $post->ID, 'ct_builder_shortcodes', true ) ) {
1810-
$builder = 'Oxygen';
1811-
} elseif ( get_post_meta( $post->ID, 'breakdance_data', true ) ) {
1812-
$builder = 'Breakdance';
1813-
}
1814-
18151794
/**
18161795
* Filter the detected page builder for a post (null = plain content).
18171796
*
1797+
* The detection table itself lives in Saddle_Builders — one row per
1798+
* builder, shared with the context warnings and the native-tree
1799+
* namespace guard, so the labels can never drift between them.
1800+
*
18181801
* @param string|null $builder Builder name.
18191802
* @param WP_Post $post The post.
18201803
*/
1821-
return apply_filters( 'saddle_builder_signature', $builder, $post );
1804+
return apply_filters( 'saddle_builder_signature', Saddle_Builders::detect( $post ), $post );
18221805
}
18231806

18241807
/**

includes/class-saddle-blocks-tree.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@ class Saddle_Blocks_Tree extends Saddle_Tree {
3939
public static function builder_namespaces() {
4040
/**
4141
* Filter the block namespaces rejected inside a native Gutenberg tree.
42+
* Defaults come from the shared builder taxonomy.
4243
*
4344
* @param string[] $namespaces Namespace prefixes (before the slash).
4445
*/
45-
return (array) apply_filters( 'saddle_blocks_builder_namespaces', array( 'divi' ) );
46+
return (array) apply_filters( 'saddle_blocks_builder_namespaces', Saddle_Builders::namespaces() );
4647
}
4748

4849
/**

includes/class-saddle-builders.php

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
/**
3+
* The builder taxonomy — one table describing every page builder Saddle
4+
* knows about.
5+
*
6+
* @package Saddle
7+
*/
8+
9+
defined( 'ABSPATH' ) || exit;
10+
11+
/**
12+
* Single source of truth for builder knowledge that used to live in three
13+
* unrelated lists: per-post detection (Saddle_Abilities::builder_signature),
14+
* site-wide plugin signals (Saddle_Context::builder_signals), and the block
15+
* namespaces builders own (Saddle_Blocks_Tree::builder_namespaces). Keeping
16+
* them here means a new builder is added in one row — and the labels the
17+
* accessor filters receive can never drift from the labels the context
18+
* warnings use.
19+
*
20+
* Two granularities on purpose: per-post rows are FORMATS ("Divi 5" vs
21+
* "Divi (classic)" — different storage, different tooling), while site
22+
* signals are keyed by PRODUCT ("Divi" — one install covers both formats).
23+
*/
24+
final class Saddle_Builders {
25+
26+
/**
27+
* The taxonomy. Row order is detection order — first match wins.
28+
*
29+
* Per row:
30+
* - label Per-post format label, emitted to the accessor filters.
31+
* - product Product label used in site-wide context warnings.
32+
* - signals Constants/functions/classes that mean the product is
33+
* active site-wide (present on one row per product).
34+
* - content Substring of post_content that marks ownership.
35+
* - meta Meta key => expected value (true = any truthy value).
36+
* - namespaces Block namespaces the builder owns (invalid in a native
37+
* Gutenberg tree).
38+
*
39+
* @return array<string,array<string,mixed>>
40+
*/
41+
private static function table() {
42+
return array(
43+
'divi5' => array(
44+
'label' => 'Divi 5',
45+
'product' => 'Divi',
46+
'signals' => array( 'ET_BUILDER_VERSION', 'ET_CORE_VERSION', 'et_setup_theme' ),
47+
'content' => '<!-- wp:divi/',
48+
'namespaces' => array( 'divi' ),
49+
),
50+
'divi4' => array(
51+
'label' => 'Divi (classic)',
52+
'product' => 'Divi',
53+
'meta' => array( '_et_pb_use_builder' => 'on' ),
54+
'content' => '[et_pb_section',
55+
),
56+
'elementor' => array(
57+
'label' => 'Elementor',
58+
'product' => 'Elementor',
59+
'signals' => array( 'ELEMENTOR_VERSION', '\\Elementor\\Plugin' ),
60+
'meta' => array( '_elementor_edit_mode' => 'builder' ),
61+
),
62+
'beaver' => array(
63+
'label' => 'Beaver Builder',
64+
'product' => 'Beaver Builder',
65+
'signals' => array( 'FL_BUILDER_VERSION', 'FLBuilderModel' ),
66+
'meta' => array( '_fl_builder_enabled' => true ),
67+
),
68+
'bricks' => array(
69+
'label' => 'Bricks',
70+
'product' => 'Bricks',
71+
'signals' => array( 'BRICKS_VERSION' ),
72+
'meta' => array( '_bricks_page_content_2' => true ),
73+
),
74+
'wpbakery' => array(
75+
'label' => 'WPBakery',
76+
'product' => 'WPBakery',
77+
'signals' => array( 'WPB_VC_VERSION', 'vc_map' ),
78+
'content' => '[vc_row',
79+
),
80+
'oxygen' => array(
81+
'label' => 'Oxygen',
82+
'product' => 'Oxygen',
83+
'signals' => array( 'CT_VERSION' ),
84+
'meta' => array( 'ct_builder_shortcodes' => true ),
85+
),
86+
'breakdance' => array(
87+
'label' => 'Breakdance',
88+
'product' => 'Breakdance',
89+
'signals' => array( '__BREAKDANCE_VERSION' ),
90+
'meta' => array( 'breakdance_data' => true ),
91+
),
92+
);
93+
}
94+
95+
/**
96+
* Which builder format (if any) owns a post's content. First matching
97+
* row wins, mirroring the original if/elseif chain exactly.
98+
*
99+
* No result cache on purpose: WordPress's meta cache already makes the
100+
* repeated get_post_meta() probes O(1) per request, and a static result
101+
* cache would go stale the moment an ability (or test) flips builder
102+
* meta mid-request.
103+
*
104+
* @param WP_Post $post Post to inspect.
105+
* @return string|null Format label ("Divi 5"), or null for plain content.
106+
*/
107+
public static function detect( WP_Post $post ) {
108+
$content = (string) $post->post_content;
109+
110+
foreach ( self::table() as $row ) {
111+
if ( isset( $row['content'] ) && false !== strpos( $content, $row['content'] ) ) {
112+
return $row['label'];
113+
}
114+
if ( isset( $row['meta'] ) ) {
115+
foreach ( $row['meta'] as $key => $expected ) {
116+
$value = get_post_meta( $post->ID, $key, true );
117+
if ( true === $expected ? (bool) $value : $expected === $value ) {
118+
return $row['label'];
119+
}
120+
}
121+
}
122+
}
123+
124+
return null;
125+
}
126+
127+
/**
128+
* Site-wide detection signals, keyed by PRODUCT label — the shape the
129+
* context builder warnings consume.
130+
*
131+
* @return array<string,string[]> Product label => signal list.
132+
*/
133+
public static function site_signals() {
134+
$signals = array();
135+
foreach ( self::table() as $row ) {
136+
if ( isset( $row['signals'] ) && ! isset( $signals[ $row['product'] ] ) ) {
137+
$signals[ $row['product'] ] = $row['signals'];
138+
}
139+
}
140+
return $signals;
141+
}
142+
143+
/**
144+
* Every block namespace owned by a builder — invalid inside a native
145+
* Gutenberg tree.
146+
*
147+
* @return string[]
148+
*/
149+
public static function namespaces() {
150+
$namespaces = array();
151+
foreach ( self::table() as $row ) {
152+
if ( isset( $row['namespaces'] ) ) {
153+
$namespaces = array_merge( $namespaces, (array) $row['namespaces'] );
154+
}
155+
}
156+
return array_values( array_unique( $namespaces ) );
157+
}
158+
}

includes/class-saddle-capabilities.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,25 @@ public static function denial_reason( $ability_name ) {
506506
);
507507
}
508508

509+
if ( 'read' !== $required && '' !== $required && self::is_domain_enforced() && ! self::domain_matches_recorded() ) {
510+
return array(
511+
'code' => 'saddle_domain_drift',
512+
'message' => __( 'This site\'s domain changed since write access was granted, and the owner has domain enforcement on — write tools are refused until they re-confirm the access level (Saddle → Permissions). Do not retry; tell the user.', 'saddle' ),
513+
);
514+
}
515+
516+
if ( '' !== $required && ! self::tier_allows( $required ) ) {
517+
return array(
518+
'code' => 'saddle_tier_denied',
519+
'message' => sprintf(
520+
/* translators: 1: required access level, 2: current access level. */
521+
__( 'This tool needs the "%1$s" access level, but this site allows "%2$s". Only the site owner can raise it (Saddle → Permissions). Do not retry — ask the user to change the level if they want this done.', 'saddle' ),
522+
$required,
523+
self::get_tier()
524+
),
525+
);
526+
}
527+
509528
return null;
510529
}
511530

includes/class-saddle-context.php

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -553,20 +553,13 @@ private static function multilingual_signals() {
553553
}
554554

555555
/**
556-
* Page builders Saddle warns about, mapped to detection signals.
556+
* Page builders Saddle warns about, mapped to detection signals — read
557+
* from the shared builder taxonomy.
557558
*
558559
* @return array<string,string[]>
559560
*/
560561
private static function builder_signals() {
561-
return array(
562-
'Divi' => array( 'ET_BUILDER_VERSION', 'ET_CORE_VERSION', 'et_setup_theme' ),
563-
'Elementor' => array( 'ELEMENTOR_VERSION', '\\Elementor\\Plugin' ),
564-
'Beaver Builder' => array( 'FL_BUILDER_VERSION', 'FLBuilderModel' ),
565-
'Bricks' => array( 'BRICKS_VERSION' ),
566-
'WPBakery' => array( 'WPB_VC_VERSION', 'vc_map' ),
567-
'Oxygen' => array( 'CT_VERSION' ),
568-
'Breakdance' => array( '__BREAKDANCE_VERSION' ),
569-
);
562+
return Saddle_Builders::site_signals();
570563
}
571564

572565
/**

includes/class-saddle-tree.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,27 @@
2222
* "0" is the first root block, "0.1" its second child, "0.1.0.2" the third
2323
* block two levels below. Addresses are positional and only valid against
2424
* the tree revision they were read from.
25+
*
26+
* Abstract on purpose: the engine is only ever used through a validation
27+
* profile, and the abstract validate() below is the enforced seam — the
28+
* same contract-based philosophy as the lint/render accessor interfaces,
29+
* instead of a convention a profile author has to reverse-engineer.
2530
*/
26-
class Saddle_Tree {
31+
abstract class Saddle_Tree {
32+
33+
/**
34+
* Validate a whole tree against this profile's structural contract.
35+
*
36+
* The ONE method a profile must supply. Reject, never repair: an
37+
* invalid tree must never reach the database, and an agent that
38+
* produced bad structure needs the error, not a silent fix.
39+
*
40+
* @param array[] $tree Block tree.
41+
* @return true|WP_Error True when valid; otherwise one error whose data
42+
* lists every violation with its address.
43+
*/
44+
abstract public static function validate( array $tree );
45+
2746
/*
2847
---------------------------------------------------------------------
2948
* Parse / serialize

includes/lint/class-saddle-lint-rule.php

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,7 @@ protected function violation( $address, $severity, $message, $fix_hint ) {
6464
* @return array[]
6565
*/
6666
protected function children( array $nodes, $parent_address ) {
67-
$out = array();
68-
foreach ( $nodes as $node ) {
69-
if ( $node['parent'] === $parent_address ) {
70-
$out[] = $node;
71-
}
72-
}
73-
return $out;
67+
return Saddle_Lint::children_of( $nodes, $parent_address );
7468
}
7569

7670
/**
@@ -81,8 +75,7 @@ protected function children( array $nodes, $parent_address ) {
8175
* @return bool
8276
*/
8377
protected function is_descendant( array $node, $ancestor_address ) {
84-
return 0 === strpos( $node['address'] . '.', $ancestor_address . '.' )
85-
&& $node['address'] !== $ancestor_address;
78+
return Saddle_Lint::is_descendant( $node, $ancestor_address );
8679
}
8780

8881
/**
@@ -95,13 +88,6 @@ protected function is_descendant( array $node, $ancestor_address ) {
9588
* @return array[]
9689
*/
9790
protected function sections( array $nodes ) {
98-
$roots = $this->children( $nodes, null );
99-
if ( 1 === count( $roots ) ) {
100-
$inner = $this->children( $nodes, $roots[0]['address'] );
101-
if ( $inner ) {
102-
return $inner;
103-
}
104-
}
105-
return $roots;
91+
return Saddle_Lint::sections( $nodes );
10692
}
10793
}

0 commit comments

Comments
 (0)