From 107a3c2eb3b13c51dc1d0051cbab91ed4e592ec1 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 22 Jul 2026 21:00:35 -0400 Subject: [PATCH 1/2] test(templates): pin BLOCK-37 hybrid-theme template regression [red] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit staging.gravitykit.com's "gravitykit" theme is a hybrid: wp_is_block_theme() is false (no templates/index.html), but get_block_templates() finds real templates and parts via the templates/ and parts/ folders directly (1 template, 25 parts, verified live). Every template tool method gates on `! wp_is_block_theme()` instead of whether the id/type actually resolves, so it hides those templates from list_templates and blocks writes to parts that genuinely render on the site. Adds a "hybrid-theme" fixture (real templates/ and parts/ files, no templates/index.html or block-templates/index.html) plus: - get_templates() must list a hybrid theme's real templates/parts, no misleading "not a block theme" note - the note must stay absent for a real block theme's genuinely-empty result (regression pin — the note isn't "any empty result") - update_template()/reset_template() must succeed against a hybrid theme's resolvable part - a genuinely classic theme (nothing resolves) must keep the specific, actionable classic_theme 400 rather than regressing to a generic not_found — pins that the fix doesn't just delete the guard All 3 hybrid-theme behavioral assertions fail against current code; the classic-theme regression pin already passes (asserting current behavior we must not break). Claude-Session: https://claude.ai/code/session_013YcSbKroBJjPanX3okQrT3 --- .../tests/Templates/TemplateManagerTest.php | 49 ++++++++++++ .../Templates/TemplateManagerWriteTest.php | 80 +++++++++++++++++++ .../themes/hybrid-theme/parts/footer.html | 3 + .../fixtures/themes/hybrid-theme/style.css | 7 ++ .../themes/hybrid-theme/templates/single.html | 3 + 5 files changed, 142 insertions(+) create mode 100644 wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/parts/footer.html create mode 100644 wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/style.css create mode 100644 wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/templates/single.html diff --git a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php index 298ae22..4791ec1 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php @@ -95,6 +95,20 @@ private function find_block_theme() { return null; } + /** + * Register the fixture theme directory containing "hybrid-theme" (a + * theme with templates/ and parts/ files but no templates/index.html, + * so wp_is_block_theme() is false). A separate root from + * ensure_theme_root_resolvable()'s dummy one; by the time it's + * registered the "more than one root" workaround already applies, so + * this one just needs to contain the fixture. + * + * @return void + */ + private function register_hybrid_theme_root() { + register_theme_directory( dirname( __DIR__ ) . '/fixtures/themes' ); + } + /** * Find a formatted template row by slug. * @@ -294,4 +308,39 @@ public function test_get_template_invalid_type_returns_error() { $this->assertInstanceOf( \WP_Error::class, $result ); $this->assertSame( 'invalid_type', $result->get_error_code() ); } + + // ── get_templates(): hybrid theme ───────────────────────────────── + + /** + * A theme can have real templates and parts on disk without satisfying + * wp_is_block_theme() (which checks specifically for templates/index.html + * or block-templates/index.html). The old unconditional + * `! wp_is_block_theme()` short-circuit hid those templates/parts + * entirely and printed a note claiming none exist. + */ + public function test_get_templates_hybrid_theme_lists_real_templates_and_parts() { + $this->register_hybrid_theme_root(); + switch_theme( 'hybrid-theme' ); + $this->assertFalse( wp_is_block_theme(), 'Fixture must reproduce wp_is_block_theme() === false to exercise the hybrid case.' ); + + $templates = $this->tm->get_templates( array( 'type' => 'wp_template' ) ); + $this->assertNotNull( $this->find_by_slug( $templates['templates'], 'single' ) ); + $this->assertArrayNotHasKey( 'note', $templates ); + + $parts = $this->tm->get_templates( array( 'type' => 'wp_template_part' ) ); + $this->assertNotNull( $this->find_by_slug( $parts['templates'], 'footer' ) ); + $this->assertArrayNotHasKey( 'note', $parts ); + } + + /** + * The note is informational, not a blanket "any empty result" flag: an + * empty result on a real block theme (e.g. an area filter matching + * nothing) must not carry a "not a block theme" note that isn't true. + */ + public function test_get_templates_full_block_theme_empty_result_has_no_note() { + $result = $this->tm->get_templates( array( 'type' => 'wp_template_part', 'area' => 'no-such-area' ) ); + + $this->assertSame( array(), $result['templates'] ); + $this->assertArrayNotHasKey( 'note', $result ); + } } diff --git a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php index 19f1b9e..4cc5e0b 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php @@ -101,6 +101,16 @@ private function find_block_theme() { return null; } + /** + * Register the fixture theme directory containing "hybrid-theme". See + * TemplateManagerTest::register_hybrid_theme_root() for the rationale. + * + * @return void + */ + private function register_hybrid_theme_root() { + register_theme_directory( dirname( __DIR__ ) . '/fixtures/themes' ); + } + // ── Gate ─────────────────────────────────────────────────────────── /** @@ -473,4 +483,74 @@ public function test_update_template_rolls_back_new_override_when_area_term_assi ); $this->assertCount( 0, $matching->posts, 'A term-assignment failure must not leave an orphaned override post behind.' ); } + + // ── Hybrid theme (wp_is_block_theme() false, but a part resolves) ─── + + /** + * A hybrid theme's template part resolves via get_block_template(), + * so a gated write against it must succeed — the old unconditional + * `! wp_is_block_theme()` 400 guard blocked this even though the part + * genuinely renders on such a site. + */ + public function test_update_template_creates_override_for_hybrid_theme_template_part() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + $this->register_hybrid_theme_root(); + switch_theme( 'hybrid-theme' ); + $this->assertFalse( wp_is_block_theme(), 'Fixture must reproduce wp_is_block_theme() === false to exercise the hybrid case.' ); + + $result = $this->tm->update_template( + 'hybrid-theme//footer', + 'wp_template_part', + array( 'content' => '

Overridden footer

' ) + ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['success'] ); + + $fetched = $this->tm->get_template( 'hybrid-theme//footer', 'wp_template_part' ); + $this->assertSame( 'custom', $fetched['source'] ); + $this->assertStringContainsString( 'Overridden footer', $fetched['content'] ); + } + + /** + * reset_template must be gated the same way — resolution, not + * wp_is_block_theme() — so it can revert the override this test just + * created on a hybrid theme. + */ + public function test_reset_template_reverts_hybrid_theme_override() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + $this->register_hybrid_theme_root(); + switch_theme( 'hybrid-theme' ); + + $updated = $this->tm->update_template( + 'hybrid-theme//footer', + 'wp_template_part', + array( 'content' => '

Overridden

' ) + ); + $this->assertTrue( $updated['success'] ); + + $reset = $this->tm->reset_template( 'hybrid-theme//footer', 'wp_template_part' ); + + $this->assertIsArray( $reset ); + $this->assertTrue( $reset['success'] ); + $this->assertNull( get_post( $updated['wp_id'] ) ); + } + + /** + * Unchanged regression: a genuinely classic theme (no templates/parts + * at all) still gets the specific, actionable "classic_theme" 400 — + * proves the resolution-based gate doesn't regress into a generic + * not_found for the case that guard exists to make clearer. + */ + public function test_update_template_classic_theme_still_returns_400_when_nothing_resolves() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + switch_theme( 'default' ); + + $result = $this->tm->update_template( 'default//does-not-exist', 'wp_template', array( 'content' => '

x

' ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'classic_theme', $result->get_error_code() ); + $data = $result->get_error_data(); + $this->assertSame( 400, $data['status'] ); + } } diff --git a/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/parts/footer.html b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/parts/footer.html new file mode 100644 index 0000000..f588f70 --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/parts/footer.html @@ -0,0 +1,3 @@ + +

Hybrid Footer Part

+ diff --git a/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/style.css b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/style.css new file mode 100644 index 0000000..783bf0e --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/style.css @@ -0,0 +1,7 @@ +/* +Theme Name: Hybrid Theme +Theme URI: https://wordpress.org/ +Description: For testing purposes only — a "hybrid" theme with no templates/index.html or block-templates/index.html (so wp_is_block_theme() is false) but real files under templates/ and parts/ (so get_block_templates() still finds them). +Version: 1.0.0 +Text Domain: hybrid-theme +*/ diff --git a/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/templates/single.html b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/templates/single.html new file mode 100644 index 0000000..6491009 --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/templates/single.html @@ -0,0 +1,3 @@ + +

Hybrid Single Template

+ From 3d15bc19d1c4cfa352c4291cc6f10cd34f3b32e9 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 22 Jul 2026 21:04:37 -0400 Subject: [PATCH 2/2] fix(templates): gate on template resolution, not wp_is_block_theme() [green] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_templates() / update_template() / reset_template() all short-circuited on `! wp_is_block_theme()`, which is only true when a theme ships templates/index.html (or block-templates/index.html). A "hybrid" theme can have real, renderable templates and template parts without that specific file — staging.gravitykit.com's "gravitykit" theme is exactly this shape (1 template, 25 parts, wp_is_block_theme() false) — and the guard hid all of them from list_templates while blocking gated writes to parts that genuinely render on the site. - get_templates(): always queries get_block_templates(); the "not a block theme" note is now attached only when the result is empty AND wp_is_block_theme() is false, reworded so it no longer asserts nonexistence when the truth is "nothing matched this query." - update_template() / reset_template(): the wp_is_block_theme() 400 now fires only as a fallback when get_block_template( $id, $type ) fails to resolve at all AND the theme isn't a block theme — the primary gate is resolution, matching get_template()'s existing behavior. A genuinely classic theme (nothing resolves) keeps the same specific, actionable classic_theme 400 it always returned; a hybrid theme's resolvable part now succeeds instead of being rejected up front. Test infra: fixes a wp-phpunit gotcha the red commit's fixture tripped — search_theme_directories() memoizes its scan in a function-local static, so register_theme_directory() alone doesn't make a newly-registered root visible once anything else has already forced a scan; wp_clean_themes_cache() does. TS: list_templates' description no longer says "block theme" as if that were the qualifying condition. Claude-Session: https://claude.ai/code/session_013YcSbKroBJjPanX3okQrT3 --- src/tools/templates.ts | 2 +- .../gk-block-mcp/assets/mcp-server/index.cjs | 2 +- .../includes/abilities/tools.manifest.json | 2 +- .../includes/class-template-manager.php | 54 ++++++++++--------- .../tests/Templates/TemplateManagerTest.php | 5 ++ .../Templates/TemplateManagerWriteTest.php | 3 ++ 6 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/tools/templates.ts b/src/tools/templates.ts index 02997bd..fb36b9b 100644 --- a/src/tools/templates.ts +++ b/src/tools/templates.ts @@ -25,7 +25,7 @@ export const TEMPLATE_TOOLS = [ { name: 'list_templates', description: - 'List a block theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer"). Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. On a classic (non-block) theme, returns an empty list with a `note` explaining why.', + 'List the active theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer") — works on a theme without a full block-theme structure too, as long as it has real templates/parts. Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. Returns an empty list with a `note` only when there is truly nothing to list.', annotations: { ...READ_ANNOT, title: 'List templates' }, inputSchema: { type: 'object' as const, diff --git a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs index f74a828..e6e5a15 100755 --- a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs +++ b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs @@ -53578,7 +53578,7 @@ var TEMPLATE_SOURCE_ENUM = ["theme", "plugin", "custom"]; var TEMPLATE_TOOLS = [ { name: "list_templates", - description: 'List a block theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer"). Each row includes `wp_id` \u2014 non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. On a classic (non-block) theme, returns an empty list with a `note` explaining why.', + description: 'List the active theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer") \u2014 works on a theme without a full block-theme structure too, as long as it has real templates/parts. Each row includes `wp_id` \u2014 non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. Returns an empty list with a `note` only when there is truly nothing to list.', annotations: { ...READ_ANNOT2, title: "List templates" }, inputSchema: { type: "object", diff --git a/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json b/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json index 9e7338d..00acc5c 100644 --- a/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json +++ b/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json @@ -2104,7 +2104,7 @@ "name": "list_templates", "ability": "gk-block-mcp/list-templates", "label": "List templates", - "description": "List a block theme's templates (page layouts like \"single\", \"archive\") or template parts (reusable regions like \"header\", \"footer\"). Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. On a classic (non-block) theme, returns an empty list with a `note` explaining why.", + "description": "List the active theme's templates (page layouts like \"single\", \"archive\") or template parts (reusable regions like \"header\", \"footer\") — works on a theme without a full block-theme structure too, as long as it has real templates/parts. Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. Returns an empty list with a `note` only when there is truly nothing to list.", "input_schema": { "type": "object", "properties": { diff --git a/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php b/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php index 1c2040f..323dd4d 100644 --- a/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php +++ b/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php @@ -122,14 +122,6 @@ public function get_templates( array $args ) { return $type; } - if ( ! wp_is_block_theme() ) { - return array( - 'templates' => array(), - 'count' => 0, - 'note' => __( 'Active theme is not a block theme; no block templates exist.', 'gk-block-mcp' ), - ); - } - $query = array(); if ( 'wp_template_part' === $type && ! empty( $args['area'] ) ) { @@ -163,10 +155,20 @@ static function ( $template ) use ( $source ) { $formatted = array_map( array( $this, 'format_template_summary' ), $templates ); - return array( + $result = array( 'templates' => $formatted, 'count' => count( $formatted ), ); + + // A hybrid theme (wp_is_block_theme() false, e.g. no templates/index.html) + // can still have real templates/parts get_block_templates() finds via + // theme files or DB overrides, so an empty result alone doesn't mean + // "not a block theme" — only note that when it's also actually true. + if ( empty( $formatted ) && ! wp_is_block_theme() ) { + $result['note'] = __( 'Active theme is not a full block theme; only registered block templates/parts are listed.', 'gk-block-mcp' ); + } + + return $result; } /** @@ -254,14 +256,6 @@ public function update_template( $id, $type, array $args ) { return $type; } - if ( ! wp_is_block_theme() ) { - return new \WP_Error( - 'classic_theme', - __( 'Active theme is not a block theme; there are no block templates to edit.', 'gk-block-mcp' ), - array( 'status' => 400 ) - ); - } - $id = is_string( $id ) ? sanitize_text_field( $id ) : ''; if ( '' === $id ) { return new \WP_Error( @@ -281,8 +275,18 @@ public function update_template( $id, $type, array $args ) { ); } + // Gate on whether the id actually resolves, not wp_is_block_theme(): + // a hybrid theme (no templates/index.html) can still have real, + // resolvable templates/parts, and those are meaningful to edit. $template = get_block_template( $id, $type ); if ( ! $template ) { + if ( ! wp_is_block_theme() ) { + return new \WP_Error( + 'classic_theme', + __( 'Active theme is not a block theme; there are no block templates to edit.', 'gk-block-mcp' ), + array( 'status' => 400 ) + ); + } return new \WP_Error( 'not_found', sprintf( /* translators: %s: template id */ __( 'Template "%s" not found.', 'gk-block-mcp' ), $id ), @@ -375,14 +379,6 @@ public function reset_template( $id, $type = 'wp_template' ) { return $type; } - if ( ! wp_is_block_theme() ) { - return new \WP_Error( - 'classic_theme', - __( 'Active theme is not a block theme; there are no template overrides to reset.', 'gk-block-mcp' ), - array( 'status' => 400 ) - ); - } - $id = is_string( $id ) ? sanitize_text_field( $id ) : ''; if ( '' === $id ) { return new \WP_Error( @@ -392,8 +388,16 @@ public function reset_template( $id, $type = 'wp_template' ) { ); } + // Same resolution-based gate as update_template() — see its comment. $template = get_block_template( $id, $type ); if ( ! $template ) { + if ( ! wp_is_block_theme() ) { + return new \WP_Error( + 'classic_theme', + __( 'Active theme is not a block theme; there are no template overrides to reset.', 'gk-block-mcp' ), + array( 'status' => 400 ) + ); + } return new \WP_Error( 'not_found', sprintf( /* translators: %s: template id */ __( 'Template "%s" not found.', 'gk-block-mcp' ), $id ), diff --git a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php index 4791ec1..7558d2b 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php @@ -107,6 +107,11 @@ private function find_block_theme() { */ private function register_hybrid_theme_root() { register_theme_directory( dirname( __DIR__ ) . '/fixtures/themes' ); + // search_theme_directories() memoizes its scan in a function-local + // static for the rest of the process; by this point in the run + // something has always already forced that memoization without + // this root, so appending it here is invisible until forced. + wp_clean_themes_cache(); } /** diff --git a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php index 4cc5e0b..3f83464 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php @@ -109,6 +109,9 @@ private function find_block_theme() { */ private function register_hybrid_theme_root() { register_theme_directory( dirname( __DIR__ ) . '/fixtures/themes' ); + // See TemplateManagerTest::register_hybrid_theme_root() — forces + // search_theme_directories()'s memoized scan to pick this root up. + wp_clean_themes_cache(); } // ── Gate ───────────────────────────────────────────────────────────