This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch villebro/ext-automanifest
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 423e5280d08d9a1e5e73ac9e3730c5e25afb6acd
Author: Ville Brofeldt <[email protected]>
AuthorDate: Mon Feb 9 07:47:35 2026 -0800

    feat(extensions): autodetect contributions
---
 .../extensions/contribution-types.md               | 128 ++---
 docs/developer_portal/extensions/quick-start.md    |  45 +-
 .../src/superset_core/extensions/types.py          |  13 +-
 superset-core/tests/__init__.py                    |  16 +
 superset-core/tests/test_api_decorators.py         | 147 ++++++
 superset-core/tests/test_mcp_decorators.py         | 154 ++++++
 .../src/superset_extensions_cli/cli.py             |  88 ++--
 .../templates/extension.json.j2                    |   6 -
 .../templates/frontend/package.json.j2             |   1 +
 .../templates/frontend/src/index.tsx.j2            |  39 +-
 .../templates/frontend/webpack.config.js.j2        |   7 +
 superset-extensions-cli/tests/test_cli_build.py    |  38 +-
 superset-frontend/package-lock.json                |  72 +--
 .../packages/superset-core/src/extensions/index.ts | 326 +++++++++++++
 .../packages/superset-core/src/index.ts            |   1 +
 .../packages/superset-core/test/extensions.test.ts | 253 ++++++++++
 .../packages/webpack-extension-plugin/package.json |  45 ++
 .../packages/webpack-extension-plugin/src/index.ts | 532 +++++++++++++++++++++
 .../webpack-extension-plugin/test/index.test.ts    | 148 ++++++
 .../webpack-extension-plugin/tsconfig.json         |  19 +
 .../src/SqlLab/components/SouthPane/index.tsx      |   6 +-
 .../src/extensions/ExtensionLoader.ts              | 470 ++++++++++++++++++
 .../src/extensions/ExtensionsStartup.tsx           |   4 +-
 superset/static/service-worker.js                  |  28 +-
 24 files changed, 2325 insertions(+), 261 deletions(-)

diff --git a/docs/developer_portal/extensions/contribution-types.md 
b/docs/developer_portal/extensions/contribution-types.md
index d88b141e5ff..83b8f4f245d 100644
--- a/docs/developer_portal/extensions/contribution-types.md
+++ b/docs/developer_portal/extensions/contribution-types.md
@@ -38,9 +38,9 @@ The contribution system provides several key benefits:
 
 ## How Contributions Work
 
-Contributions are automatically inferred from source code during build. The 
build tool scans your code and generates a `manifest.json` with all discovered 
contributions.
+Contributions are automatically discovered from source code at build time. 
Simply use the `@extension_api`, `@tool`, `@prompt` decorators in Python or 
`define*()` functions in TypeScript - the build system finds them and generates 
a `manifest.json` with all discovered contributions.
 
-For advanced use cases, contributions can be manually specified in 
`extension.json` (overrides auto-discovery).
+No manual configuration needed!
 
 ## Backend Contributions
 
@@ -91,98 +91,74 @@ See [MCP Integration](./mcp) for more details.
 
 ### Views
 
-Add panels or views to the UI:
+Add panels or views to the UI using `defineView()`:
 
-```json
-"frontend": {
-  "contributions": {
-    "views": {
-      "sqllab": {
-        "panels": [
-          {
-            "id": "my_extension.main",
-            "name": "My Panel Name"
-          }
-        ]
-      }
-    }
-  }
-}
+```tsx
+import React from 'react';
+import { defineView } from '@apache-superset/core';
+import MyPanel from './MyPanel';
+
+export const myView = defineView({
+  id: 'main',
+  title: 'My Panel',
+  location: 'sqllab.panels', // or dashboard.tabs, explore.panels, etc.
+  component: () => <MyPanel />,
+});
 ```
 
 ### Commands
 
-Define executable commands:
+Define executable commands using `defineCommand()`:
 
-```json
-"frontend": {
-  "contributions": {
-    "commands": [
-      {
-        "command": "my_extension.copy_query",
-        "icon": "CopyOutlined",
-        "title": "Copy Query",
-        "description": "Copy the current query"
-      }
-    ]
-  }
-}
+```tsx
+import { defineCommand } from '@apache-superset/core';
+
+export const copyQuery = defineCommand({
+  id: 'copy_query',
+  title: 'Copy Query',
+  icon: 'CopyOutlined',
+  execute: async () => {
+    // Copy the current query
+    navigator.clipboard.writeText(getCurrentQuery());
+  },
+});
 ```
 
 ### Menus
 
-Add items to menus:
+Add items to menus using `defineMenu()`:
 
-```json
-"frontend": {
-  "contributions": {
-    "menus": {
-      "sqllab": {
-        "editor": {
-          "primary": [
-            {
-              "view": "builtin.editor",
-              "command": "my_extension.copy_query"
-            }
-          ],
-          "secondary": [
-            {
-              "view": "builtin.editor",
-              "command": "my_extension.prettify"
-            }
-          ],
-          "context": [
-            {
-              "view": "builtin.editor",
-              "command": "my_extension.clear"
-            }
-          ]
-        }
-      }
-    }
-  }
-}
+```tsx
+import { defineMenu } from '@apache-superset/core';
+
+export const contextMenu = defineMenu({
+  id: 'clear_editor',
+  title: 'Clear Editor',
+  location: 'sqllab.editor.context',
+  action: () => {
+    clearEditor();
+  },
+});
 ```
 
 ### Editors
 
-Replace the default text editor:
+Replace the default text editor using `defineEditor()`:
 
-```json
-"frontend": {
-  "contributions": {
-    "editors": [
-      {
-        "id": "my_extension.monaco_sql",
-        "name": "Monaco SQL Editor",
-        "languages": ["sql"]
-      }
-    ]
-  }
-}
+```tsx
+import React from 'react';
+import { defineEditor } from '@apache-superset/core';
+import MonacoEditor from './MonacoEditor';
+
+export const monacoSqlEditor = defineEditor({
+  id: 'monaco_sql',
+  name: 'Monaco SQL Editor',
+  mimeTypes: ['text/x-sql'],
+  component: MonacoEditor,
+});
 ```
 
-See [Editors Extension Point](./extension-points/editors) for implementation 
details.
+All contributions are automatically discovered at build time and registered at 
runtime - no manual configuration needed!
 
 ## Configuration
 
diff --git a/docs/developer_portal/extensions/quick-start.md 
b/docs/developer_portal/extensions/quick-start.md
index f14e0be23b3..19455b4ba93 100644
--- a/docs/developer_portal/extensions/quick-start.md
+++ b/docs/developer_portal/extensions/quick-start.md
@@ -417,25 +417,48 @@ Replace the generated code with the extension entry point:
 
 ```tsx
 import React from 'react';
-import { core } from '@apache-superset/core';
+import { defineView } from '@apache-superset/core';
 import HelloWorldPanel from './HelloWorldPanel';
 
-export const activate = (context: core.ExtensionContext) => {
-  context.disposables.push(
-    core.registerViewProvider('my-org.hello-world.main', () => 
<HelloWorldPanel />),
-  );
-};
+// Define the view - automatically registered when extension loads
+export const helloWorldView = defineView({
+  id: 'main',
+  title: 'Hello World',
+  location: 'sqllab.panels',
+  component: () => <HelloWorldPanel />,
+});
+```
+
+That's it! For most extensions, this is all you need.
+
+**Optional lifecycle callbacks:**
 
-export const deactivate = () => {};
+If you need to run code when your contribution activates or deactivates, add 
optional callbacks:
+
+```tsx
+export const helloWorldView = defineView({
+  id: 'main',
+  title: 'Hello World',
+  location: 'sqllab.panels',
+  component: () => <HelloWorldPanel />,
+  onActivate: () => {
+    // Optional: runs when panel is registered
+    console.log('Hello World panel activated');
+  },
+  onDeactivate: () => {
+    // Optional: runs when panel is unregistered
+    console.log('Hello World panel deactivated');
+  },
+});
 ```
 
 **Key patterns:**
 
-- `activate` function is called when the extension loads
-- `core.registerViewProvider` registers the component with ID 
`my-org.hello-world.main` (matching `extension.json`)
+- `defineView()` automatically handles discovery, registration, and cleanup
+- `onActivate` and `onDeactivate` are completely optional
 - `authentication.getCSRFToken()` retrieves the CSRF token for API calls
-- Fetch calls to `/extensions/{publisher}/{name}/{endpoint}` reach your 
backend API
-- `context.disposables.push()` ensures proper cleanup
+- Fetch calls to `/extensions/{extension_id}/{endpoint}` reach your backend API
+- Everything happens automatically - no manual setup required
 
 ## Step 6: Install Dependencies
 
diff --git a/superset-core/src/superset_core/extensions/types.py 
b/superset-core/src/superset_core/extensions/types.py
index 2615c65131f..9e345fd696d 100644
--- a/superset-core/src/superset_core/extensions/types.py
+++ b/superset-core/src/superset_core/extensions/types.py
@@ -222,11 +222,6 @@ class BaseExtension(BaseModel):
 class ExtensionConfigFrontend(BaseModel):
     """Frontend section in extension.json."""
 
-    # Optional: if provided, takes precedence over discovery
-    contributions: FrontendContributions | None = Field(
-        default=None,
-        description="Frontend contributions (optional, overrides discovery)",
-    )
     moduleFederation: ModuleFederationConfig = Field(  # noqa: N815
         default_factory=ModuleFederationConfig,
         description="Module Federation configuration",
@@ -242,12 +237,8 @@ class ExtensionConfigBackend(BaseModel):
     )
     files: list[str] = Field(
         default_factory=list,
-        description="Glob patterns for backend Python files",
-    )
-    # Optional: if provided, takes precedence over discovery
-    contributions: BackendContributions | None = Field(
-        default=None,
-        description="Backend contributions (optional, overrides discovery)",
+        description="Glob patterns for backend Python files (defaults to"
+        "backend/src/**/*.py)",
     )
 
 
diff --git a/superset-core/tests/__init__.py b/superset-core/tests/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset-core/tests/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset-core/tests/test_api_decorators.py 
b/superset-core/tests/test_api_decorators.py
new file mode 100644
index 00000000000..c779d3e8eff
--- /dev/null
+++ b/superset-core/tests/test_api_decorators.py
@@ -0,0 +1,147 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for REST API decorators in BUILD mode."""
+
+from superset_core.api.rest_api import extension_api, RestApi
+from superset_core.extensions.context import get_context, RegistrationMode
+
+
+def test_extension_api_decorator_stores_metadata():
+    """Test that @extension_api decorator stores metadata."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @extension_api(id="test_api", name="Test API")
+        class TestAPI(RestApi):
+            """Test API class."""
+
+            pass
+
+        # Should have metadata attached
+        assert hasattr(TestAPI, "__rest_api_metadata__")
+        meta = TestAPI.__rest_api_metadata__
+        assert meta.id == "test_api"
+        assert meta.name == "Test API"
+        assert meta.description == "Test API class."
+        assert meta.base_path == "/test_api"
+        assert "TestAPI" in meta.module
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_extension_api_decorator_with_custom_metadata():
+    """Test @extension_api decorator with custom metadata."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @extension_api(
+            id="custom_api",
+            name="Custom API",
+            description="Custom description",
+            base_path="/custom/path",
+        )
+        class CustomAPI(RestApi):
+            """Original docstring."""
+
+            pass
+
+        meta = CustomAPI.__rest_api_metadata__
+        assert meta.id == "custom_api"
+        assert meta.name == "Custom API"
+        assert meta.description == "Custom description"
+        assert meta.base_path == "/custom/path"
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_extension_api_decorator_auto_infers_flask_fields():
+    """Test that @extension_api auto-infers Flask-AppBuilder fields."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @extension_api(id="infer_api", name="Infer API")
+        class InferAPI(RestApi):
+            """Test auto-inference."""
+
+            pass
+
+        meta = InferAPI.__rest_api_metadata__
+        # Check auto-inferred fields
+        assert meta.resource_name == "infer_api"
+        assert meta.openapi_spec_tag == "Infer API"
+        assert meta.class_permission_name == "infer_api"
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_extension_api_decorator_custom_flask_fields():
+    """Test @extension_api with custom Flask-AppBuilder fields."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @extension_api(
+            id="custom_flask_api",
+            name="Custom Flask API",
+            resource_name="custom_resource",
+            openapi_spec_tag="Custom Tag",
+            class_permission_name="custom_permission",
+        )
+        class CustomFlaskAPI(RestApi):
+            """Custom Flask fields."""
+
+            pass
+
+        meta = CustomFlaskAPI.__rest_api_metadata__
+        assert meta.resource_name == "custom_resource"
+        assert meta.openapi_spec_tag == "Custom Tag"
+        assert meta.class_permission_name == "custom_permission"
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_extension_api_decorator_does_not_register_in_build_mode():
+    """Test that @extension_api doesn't attempt registration in BUILD mode."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+        # This should not raise an exception even though no registration 
mechanism
+        # is available
+        @extension_api(id="build_api", name="Build API")
+        class BuildAPI(RestApi):
+            """Build mode API."""
+
+            pass
+
+        # Should have metadata
+        assert hasattr(BuildAPI, "__rest_api_metadata__")
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
diff --git a/superset-core/tests/test_mcp_decorators.py 
b/superset-core/tests/test_mcp_decorators.py
new file mode 100644
index 00000000000..51e33d44477
--- /dev/null
+++ b/superset-core/tests/test_mcp_decorators.py
@@ -0,0 +1,154 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for MCP decorators in BUILD mode."""
+
+from superset_core.extensions.context import get_context, RegistrationMode
+from superset_core.mcp import prompt, tool
+
+
+def test_tool_decorator_stores_metadata_in_build_mode():
+    """Test that @tool decorator stores metadata when in BUILD mode."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @tool(tags=["database"])
+        def test_query_tool(sql: str) -> dict:
+            """Execute a SQL query."""
+            return {"result": "test"}
+
+        # Should have metadata attached
+        assert hasattr(test_query_tool, "__tool_metadata__")
+        meta = test_query_tool.__tool_metadata__
+        assert meta.id == "test_query_tool"
+        assert meta.name == "test_query_tool"
+        assert meta.description == "Execute a SQL query."
+        assert meta.tags == ["database"]
+        assert meta.protect is True
+        assert "test_query_tool" in meta.module
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_tool_decorator_with_custom_metadata():
+    """Test @tool decorator with custom name and description."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @tool(
+            name="Custom Tool",
+            description="Custom description",
+            tags=["test"],
+            protect=False,
+        )
+        def my_tool() -> str:
+            """Original docstring."""
+            return "test"
+
+        meta = my_tool.__tool_metadata__
+        assert meta.id == "my_tool"
+        assert meta.name == "Custom Tool"
+        assert meta.description == "Custom description"
+        assert meta.tags == ["test"]
+        assert meta.protect is False
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_prompt_decorator_stores_metadata_in_build_mode():
+    """Test that @prompt decorator stores metadata when in BUILD mode."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @prompt(tags=["analysis"])
+        async def test_prompt(ctx, dataset: str) -> str:
+            """Generate analysis for a dataset."""
+            return f"Analyze {dataset}"
+
+        # Should have metadata attached
+        assert hasattr(test_prompt, "__prompt_metadata__")
+        meta = test_prompt.__prompt_metadata__
+        assert meta.id == "test_prompt"
+        assert meta.name == "test_prompt"
+        assert meta.description == "Generate analysis for a dataset."
+        assert meta.tags == ["analysis"]
+        assert meta.protect is True
+        assert "test_prompt" in meta.module
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_prompt_decorator_with_custom_metadata():
+    """Test @prompt decorator with custom metadata."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+
+        @prompt(
+            name="Custom Prompt",
+            title="Custom Title",
+            description="Custom description",
+            tags=["custom"],
+            protect=False,
+        )
+        async def my_prompt(ctx) -> str:
+            """Original docstring."""
+            return "test"
+
+        meta = my_prompt.__prompt_metadata__
+        assert meta.id == "my_prompt"
+        assert meta.name == "Custom Prompt"
+        assert meta.title == "Custom Title"
+        assert meta.description == "Custom description"
+        assert meta.tags == ["custom"]
+        assert meta.protect is False
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
+
+
+def test_decorators_do_not_register_in_build_mode():
+    """Test that decorators don't attempt registration in BUILD mode."""
+    ctx = get_context()
+    ctx.set_mode(RegistrationMode.BUILD)
+
+    try:
+        # This should not raise an exception even though no MCP server is 
available
+        @tool(tags=["test"])
+        def build_mode_tool() -> str:
+            return "test"
+
+        @prompt(tags=["test"])
+        async def build_mode_prompt(ctx) -> str:
+            return "test"
+
+        # Both should have metadata
+        assert hasattr(build_mode_tool, "__tool_metadata__")
+        assert hasattr(build_mode_prompt, "__prompt_metadata__")
+
+    finally:
+        ctx.set_mode(RegistrationMode.HOST)
diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py 
b/superset-extensions-cli/src/superset_extensions_cli/cli.py
index 27b238d5241..f9eb8a6995e 100644
--- a/superset-extensions-cli/src/superset_extensions_cli/cli.py
+++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py
@@ -84,10 +84,10 @@ def discover_backend_contributions(
         for pattern in files_patterns:
             py_files.extend(cwd.glob(pattern))
 
-        for py_file in py_files:
-            if not py_file.is_file() or py_file.suffix != ".py":
-                continue
+        # Filter to only process Python files
+        python_files = [f for f in py_files if f.is_file() and f.suffix == 
".py"]
 
+        for py_file in python_files:
             try:
                 # Import module dynamically
                 module = _import_module_from_path(py_file)
@@ -154,6 +154,26 @@ def discover_backend_contributions(
     return contributions
 
 
+def discover_frontend_contributions(cwd: Path) -> FrontendContributions:
+    """
+    Discover frontend contributions from webpack plugin output.
+
+    The webpack plugin outputs a contributions.json file during build.
+    """
+    contributions_file = cwd / "frontend" / "dist" / "contributions.json"
+
+    if not contributions_file.exists():
+        # No frontend contributions found - this is normal for extensions 
without frontend
+        return FrontendContributions()
+
+    try:
+        contributions_data = json.loads(contributions_file.read_text())
+        return FrontendContributions.model_validate(contributions_data)
+    except Exception as e:
+        click.secho(f"⚠️  Failed to parse frontend contributions: {e}", 
fg="yellow")
+        return FrontendContributions()
+
+
 def _import_module_from_path(py_file: Path) -> Any:
     """Import a Python module from a file path."""
     module_name = py_file.stem
@@ -266,46 +286,48 @@ def build_manifest(cwd: Path, remote_entry: str | None) 
-> Manifest:
 
     extension = ExtensionConfig.model_validate(extension_data)
 
-    # Generate composite ID from publisher and name
-    composite_id = f"{extension.publisher}.{extension.name}"
-
-    # Build frontend manifest
+    # Build frontend manifest with auto-discovery
     frontend: ManifestFrontend | None = None
     if extension.frontend and remote_entry:
-        # Use manually specified contributions if present, otherwise empty
-        # (frontend discovery will be added later)
-        frontend_contributions = (
-            extension.frontend.contributions
-            if extension.frontend.contributions
-            else FrontendContributions()
-        )
+        click.secho("🔍 Auto-discovering frontend contributions...", fg="cyan")
+        frontend_contributions = discover_frontend_contributions(cwd)
+
+        # Count contributions for feedback
+        command_count = len(frontend_contributions.commands)
+        view_count = sum(len(views) for views in 
frontend_contributions.views.values())
+        menu_count = len(frontend_contributions.menus)
+        editor_count = len(frontend_contributions.editors)
+
+        total_count = command_count + view_count + menu_count + editor_count
+        if total_count > 0:
+            click.secho(
+                f"   Found: {command_count} commands, {view_count} views, 
{menu_count} menus, {editor_count} editors",
+                fg="green",
+            )
+        else:
+            click.secho("   No frontend contributions found", fg="yellow")
+
         frontend = ManifestFrontend(
             contributions=frontend_contributions,
             moduleFederation=extension.frontend.moduleFederation,
             remoteEntry=remote_entry,
         )
 
-    # Build backend manifest with contributions
+    # Build backend manifest with auto-discovered contributions
     backend: ManifestBackend | None = None
     if extension.backend:
-        # Use manually specified contributions if present, otherwise discover
-        if extension.backend.contributions:
-            backend_contributions = extension.backend.contributions
-            click.secho("📋 Using manually specified backend contributions", 
fg="cyan")
-        elif extension.backend.files:
-            click.secho("🔍 Discovering backend contributions...", fg="cyan")
-            backend_contributions = discover_backend_contributions(
-                cwd, extension.backend.files
-            )
-            tool_count = len(backend_contributions.mcp_tools)
-            prompt_count = len(backend_contributions.mcp_prompts)
-            api_count = len(backend_contributions.rest_apis)
-            click.secho(
-                f"   Found: {tool_count} tools, {prompt_count} prompts, 
{api_count} APIs",
-                fg="green",
-            )
-        else:
-            backend_contributions = BackendContributions()
+        click.secho("🔍 Auto-discovering backend contributions...", fg="cyan")
+        backend_contributions = discover_backend_contributions(
+            cwd, extension.backend.files
+        )
+
+        tool_count = len(backend_contributions.mcp_tools)
+        prompt_count = len(backend_contributions.mcp_prompts)
+        api_count = len(backend_contributions.rest_apis)
+        click.secho(
+            f"   Found: {tool_count} tools, {prompt_count} prompts, 
{api_count} APIs",
+            fg="green",
+        )
 
         backend = ManifestBackend(
             entryPoints=extension.backend.entryPoints,
diff --git 
a/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
 
b/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
index 08410b6d6d6..3954b5e1c7d 100644
--- 
a/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
+++ 
b/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2
@@ -6,12 +6,6 @@
   "license": "{{ license }}",
   {% if include_frontend -%}
   "frontend": {
-    "contributions": {
-      "commands": [],
-      "views": {},
-      "menus": {},
-      "editors": []
-    },
     "moduleFederation": {
       "name": "{{ mf_name }}",
       "exposes": ["./index"]
diff --git 
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
 
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
index be4d844aa25..b4e51d41094 100644
--- 
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
+++ 
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2
@@ -19,6 +19,7 @@
     "react-dom": "^17.0.2"
   },
   "devDependencies": {
+    "@apache-superset/webpack-extension-plugin": "^0.0.1",
     "@babel/preset-react": "^7.26.3",
     "@babel/preset-typescript": "^7.26.0",
     "@types/react": "^19.0.10",
diff --git 
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2
 
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2
index b9a6266216d..f46d4d144c4 100644
--- 
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2
+++ 
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2
@@ -1,13 +1,32 @@
 import React from "react";
-import { core } from "@apache-superset/core";
+import { defineCommand, defineView } from "@apache-superset/core";
 
-export const activate = (context: core.ExtensionContext) => {
-  context.disposables.push(
-    core.registerViewProvider("{{ id }}.example", () => <p>{{ name }}</p>)
-  );
-  console.log("{{ name }} extension activated");
-};
+// Example command
+export const exampleCommand = defineCommand({
+  id: "example",
+  title: "Example Command",
+  icon: "ExperimentOutlined",
+  execute: async () => {
+    console.log("{{ name }} command executed!");
+  },
+  onActivate: () => {
+    console.log("Example command activated");
+  },
+  onDeactivate: () => {
+    console.log("Example command deactivated");
+  },
+});
 
-export const deactivate = () => {
-  console.log("{{ name }} extension deactivated");
-};
+// Example view
+export const exampleView = defineView({
+  id: "example",
+  title: "{{ name }} View",
+  location: "explore.panels", // or dashboard.tabs, sqllab.panels, etc.
+  component: () => <div>Welcome to {{ name }}!</div>,
+  onActivate: () => {
+    console.log("Example view activated");
+  },
+  onDeactivate: () => {
+    console.log("Example view deactivated");
+  },
+});
diff --git 
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
 
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
index b3bf5139899..6793a68a351 100644
--- 
a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
+++ 
b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2
@@ -1,5 +1,6 @@
 const path = require("path");
 const { ModuleFederationPlugin } = require("webpack").container;
+const SupersetExtensionPlugin = 
require("@apache-superset/webpack-extension-plugin");
 const packageConfig = require("./package");
 
 module.exports = (env, argv) => {
@@ -62,6 +63,12 @@ module.exports = (env, argv) => {
           },
         },
       }),
+      // Auto-discover and validate frontend contributions
+      new SupersetExtensionPlugin({
+        outputPath: "contributions.json",
+        include: ["src/**/*.{ts,tsx,js,jsx}"],
+        exclude: ["**/*.test.*", "**/node_modules/**"],
+      }),
     ],
   };
 };
diff --git a/superset-extensions-cli/tests/test_cli_build.py 
b/superset-extensions-cli/tests/test_cli_build.py
index 94116e76b5a..5a59ddd676d 100644
--- a/superset-extensions-cli/tests/test_cli_build.py
+++ b/superset-extensions-cli/tests/test_cli_build.py
@@ -61,23 +61,15 @@ def extension_with_build_structure():
 
         if include_frontend:
             extension_json["frontend"] = {
-                "contributions": {
-                    "commands": [],
-                    "views": {},
-                    "menus": {},
-                    "editors": [],
-                },
-                "moduleFederation": {
-                    "exposes": ["./index"],
-                    "name": "testOrg_testExtension",
-                },
+                "moduleFederation": {"exposes": ["./index"]},
             }
 
         if include_backend:
             extension_json["backend"] = {
                 "entryPoints": [
                     "superset_extensions.test_org.test_extension.entrypoint"
-                ]
+                ],
+                "files": ["backend/src/**/*.py"],
             }
 
         (base_path / "extension.json").write_text(json.dumps(extension_json))
@@ -250,19 +242,11 @@ def 
test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
         "permissions": ["read_data"],
         "dependencies": ["some_dep"],
         "frontend": {
-            "contributions": {
-                "commands": [{"id": "test_command", "title": "Test"}],
-                "views": {},
-                "menus": {},
-                "editors": [],
-            },
-            "moduleFederation": {
-                "exposes": ["./index"],
-                "name": "testOrg_testExtension",
-            },
+            "moduleFederation": {"exposes": ["./index"]},
         },
         "backend": {
-            "entryPoints": 
["superset_extensions.test_org.test_extension.entrypoint"]
+            "entryPoints": 
["superset_extensions.test_org.test_extension.entrypoint"],
+            "files": ["backend/src/**/*.py"],
         },
     }
     extension_json = isolated_filesystem / "extension.json"
@@ -279,15 +263,15 @@ def 
test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
     assert manifest.permissions == ["read_data"]
     assert manifest.dependencies == ["some_dep"]
 
-    # Verify frontend section
+    # Verify frontend section (auto-discovery, currently empty)
     assert manifest.frontend is not None
-    assert manifest.frontend.contributions.commands == [
-        {"id": "test_command", "title": "Test"}
-    ]
+    assert (
+        manifest.frontend.contributions.commands == []
+    )  # Auto-discovered (empty for now)
     assert manifest.frontend.moduleFederation.exposes == ["./index"]
     assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
 
-    # Verify backend section
+    # Verify backend section (auto-discovery)
     assert manifest.backend is not None
     assert manifest.backend.entryPoints == [
         "superset_extensions.test_org.test_extension.entrypoint"
diff --git a/superset-frontend/package-lock.json 
b/superset-frontend/package-lock.json
index 347e4edc0e9..9162978ce3e 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -470,6 +470,10 @@
       "resolved": "packages/superset-core",
       "link": true
     },
+    "node_modules/@apache-superset/webpack-extension-plugin": {
+      "resolved": "packages/webpack-extension-plugin",
+      "link": true
+    },
     "node_modules/@asamuzakjp/css-color": {
       "version": "4.1.2",
       "resolved": 
"https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz";,
@@ -6072,7 +6076,6 @@
       "version": "0.3.6",
       "resolved": 
"https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz";,
       "integrity": 
"sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@jridgewell/gen-mapping": "^0.3.5",
@@ -14702,7 +14705,6 @@
       "version": "9.6.1",
       "resolved": 
"https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz";,
       "integrity": 
"sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/estree": "*",
@@ -14713,7 +14715,6 @@
       "version": "3.7.7",
       "resolved": 
"https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz";,
       "integrity": 
"sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/eslint": "*",
@@ -14724,7 +14725,6 @@
       "version": "0.0.51",
       "resolved": 
"https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz";,
       "integrity": 
"sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/expect": {
@@ -17224,7 +17224,6 @@
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz";,
       "integrity": 
"sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/helper-numbers": "1.13.2",
@@ -17235,28 +17234,24 @@
       "version": "1.13.2",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz";,
       "integrity": 
"sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@webassemblyjs/helper-api-error": {
       "version": "1.13.2",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz";,
       "integrity": 
"sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@webassemblyjs/helper-buffer": {
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz";,
       "integrity": 
"sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@webassemblyjs/helper-numbers": {
       "version": "1.13.2",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz";,
       "integrity": 
"sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/floating-point-hex-parser": "1.13.2",
@@ -17268,14 +17263,12 @@
       "version": "1.13.2",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz";,
       "integrity": 
"sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@webassemblyjs/helper-wasm-section": {
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz";,
       "integrity": 
"sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/ast": "1.14.1",
@@ -17288,7 +17281,6 @@
       "version": "1.13.2",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz";,
       "integrity": 
"sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@xtuc/ieee754": "^1.2.0"
@@ -17298,7 +17290,6 @@
       "version": "1.13.2",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz";,
       "integrity": 
"sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
-      "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
         "@xtuc/long": "4.2.2"
@@ -17308,14 +17299,12 @@
       "version": "1.13.2",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz";,
       "integrity": 
"sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/@webassemblyjs/wasm-edit": {
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz";,
       "integrity": 
"sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/ast": "1.14.1",
@@ -17332,7 +17321,6 @@
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz";,
       "integrity": 
"sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/ast": "1.14.1",
@@ -17346,7 +17334,6 @@
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz";,
       "integrity": 
"sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/ast": "1.14.1",
@@ -17359,7 +17346,6 @@
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz";,
       "integrity": 
"sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/ast": "1.14.1",
@@ -17374,7 +17360,6 @@
       "version": "1.14.1",
       "resolved": 
"https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz";,
       "integrity": 
"sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@webassemblyjs/ast": "1.14.1",
@@ -17432,14 +17417,12 @@
       "version": "1.2.0",
       "resolved": 
"https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz";,
       "integrity": 
"sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
-      "dev": true,
       "license": "BSD-3-Clause"
     },
     "node_modules/@xtuc/long": {
       "version": "4.2.2",
       "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz";,
       "integrity": 
"sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
-      "dev": true,
       "license": "Apache-2.0"
     },
     "node_modules/@yarnpkg/lockfile": {
@@ -17931,7 +17914,6 @@
       "version": "5.1.0",
       "resolved": 
"https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz";,
       "integrity": 
"sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "fast-deep-equal": "^3.1.3"
@@ -19273,7 +19255,6 @@
       "version": "4.28.1",
       "resolved": 
"https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz";,
       "integrity": 
"sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
-      "dev": true,
       "funding": [
         {
           "type": "opencollective",
@@ -19706,7 +19687,6 @@
       "version": "1.0.30001764",
       "resolved": 
"https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz";,
       "integrity": 
"sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
-      "dev": true,
       "funding": [
         {
           "type": "opencollective",
@@ -19997,7 +19977,6 @@
       "version": "1.0.4",
       "resolved": 
"https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz";,
       "integrity": 
"sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=6.0"
@@ -23871,7 +23850,6 @@
       "version": "1.5.267",
       "resolved": 
"https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz";,
       "integrity": 
"sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/emittery": {
@@ -23963,7 +23941,6 @@
       "version": "5.19.0",
       "resolved": 
"https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz";,
       "integrity": 
"sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "graceful-fs": "^4.2.4",
@@ -25289,7 +25266,6 @@
       "version": "5.1.1",
       "resolved": 
"https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz";,
       "integrity": 
"sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "esrecurse": "^4.3.0",
@@ -25303,7 +25279,6 @@
       "version": "4.3.0",
       "resolved": 
"https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz";,
       "integrity": 
"sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
-      "dev": true,
       "license": "BSD-2-Clause",
       "engines": {
         "node": ">=4.0"
@@ -25621,7 +25596,6 @@
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz";,
       "integrity": 
"sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.8.x"
@@ -28059,7 +28033,6 @@
       "version": "0.4.1",
       "resolved": 
"https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz";,
       "integrity": 
"sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
-      "dev": true,
       "license": "BSD-2-Clause"
     },
     "node_modules/global-dirs": {
@@ -36195,7 +36168,6 @@
       "version": "4.3.1",
       "resolved": 
"https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz";,
       "integrity": 
"sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=6.11.5"
@@ -37468,7 +37440,6 @@
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz";,
       "integrity": 
"sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">= 0.6"
@@ -37478,7 +37449,6 @@
       "version": "2.1.35",
       "resolved": 
"https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz";,
       "integrity": 
"sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "mime-db": "1.52.0"
@@ -38227,7 +38197,6 @@
       "version": "2.0.27",
       "resolved": 
"https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz";,
       "integrity": 
"sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/nomnom": {
@@ -41368,7 +41337,6 @@
       "version": "2.1.0",
       "resolved": 
"https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz";,
       "integrity": 
"sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "safe-buffer": "^5.1.0"
@@ -44937,7 +44905,6 @@
       "version": "4.3.3",
       "resolved": 
"https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz";,
       "integrity": 
"sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/json-schema": "^7.0.9",
@@ -45064,7 +45031,6 @@
       "version": "6.0.2",
       "resolved": 
"https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz";,
       "integrity": 
"sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
-      "dev": true,
       "license": "BSD-3-Clause",
       "dependencies": {
         "randombytes": "^2.1.0"
@@ -45711,7 +45677,6 @@
       "version": "0.5.21",
       "resolved": 
"https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz";,
       "integrity": 
"sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "buffer-from": "^1.0.0",
@@ -45722,7 +45687,6 @@
       "version": "0.6.1",
       "resolved": 
"https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz";,
       "integrity": 
"sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-      "dev": true,
       "license": "BSD-3-Clause",
       "engines": {
         "node": ">=0.10.0"
@@ -46941,7 +46905,6 @@
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz";,
       "integrity": 
"sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=6"
@@ -47094,7 +47057,6 @@
       "version": "5.37.0",
       "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz";,
       "integrity": 
"sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "@jridgewell/source-map": "^0.3.3",
@@ -47113,7 +47075,6 @@
       "version": "5.3.16",
       "resolved": 
"https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz";,
       "integrity": 
"sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@jridgewell/trace-mapping": "^0.3.25",
@@ -47148,7 +47109,6 @@
       "version": "27.5.1",
       "resolved": 
"https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz";,
       "integrity": 
"sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*",
@@ -47163,7 +47123,6 @@
       "version": "8.1.1",
       "resolved": 
"https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz";,
       "integrity": 
"sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "has-flag": "^4.0.0"
@@ -47179,7 +47138,6 @@
       "version": "8.14.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz";,
       "integrity": 
"sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
-      "dev": true,
       "license": "MIT",
       "bin": {
         "acorn": "bin/acorn"
@@ -47192,7 +47150,6 @@
       "version": "2.20.3",
       "resolved": 
"https://registry.npmjs.org/commander/-/commander-2.20.3.tgz";,
       "integrity": 
"sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/test-exclude": {
@@ -49185,7 +49142,6 @@
       "version": "1.2.3",
       "resolved": 
"https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz";,
       "integrity": 
"sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
-      "dev": true,
       "funding": [
         {
           "type": "opencollective",
@@ -49884,7 +49840,6 @@
       "version": "2.5.1",
       "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz";,
       "integrity": 
"sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "glob-to-regexp": "^0.4.1",
@@ -50565,14 +50520,12 @@
       "version": "1.0.8",
       "resolved": 
"https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz";,
       "integrity": 
"sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/webpack/node_modules/acorn": {
       "version": "8.15.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz";,
       "integrity": 
"sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
-      "dev": true,
       "license": "MIT",
       "bin": {
         "acorn": "bin/acorn"
@@ -50585,7 +50538,6 @@
       "version": "1.0.4",
       "resolved": 
"https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz";,
       "integrity": 
"sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=10.13.0"
@@ -50598,14 +50550,12 @@
       "version": "2.0.0",
       "resolved": 
"https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz";,
       "integrity": 
"sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/webpack/node_modules/json-parse-even-better-errors": {
       "version": "2.3.1",
       "resolved": 
"https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz";,
       "integrity": 
"sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/websocket-driver": {
@@ -53013,6 +52963,20 @@
       "version": "0.20.3",
       "license": "Apache-2.0"
     },
+    "packages/webpack-extension-plugin": {
+      "name": "@apache-superset/webpack-extension-plugin",
+      "version": "0.0.1",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "typescript": "^5.0.0"
+      },
+      "devDependencies": {
+        "@types/node": "^25.1.0"
+      },
+      "peerDependencies": {
+        "webpack": "^5.0.0"
+      }
+    },
     "plugins/legacy-plugin-chart-calendar": {
       "name": "@superset-ui/legacy-plugin-chart-calendar",
       "version": "0.20.3",
diff --git a/superset-frontend/packages/superset-core/src/extensions/index.ts 
b/superset-frontend/packages/superset-core/src/extensions/index.ts
new file mode 100644
index 00000000000..0a38093e427
--- /dev/null
+++ b/superset-frontend/packages/superset-core/src/extensions/index.ts
@@ -0,0 +1,326 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Contribution configuration interfaces
+export interface CommandConfig {
+  id: string;
+  title: string;
+  icon?: string;
+  execute: () => void | Promise<void>;
+  when?: () => boolean;
+  onActivate?: () => void; // Called when command is registered
+  onDeactivate?: () => void; // Called when command is unregistered
+}
+
+export interface ViewConfig {
+  id: string;
+  title: string;
+  location: string; // e.g., "dashboard.tabs", "explore.panels"
+  component: React.ComponentType;
+  when?: () => boolean;
+  onActivate?: () => void; // Called when view is registered
+  onDeactivate?: () => void; // Called when view is unregistered
+}
+
+export interface EditorConfig {
+  id: string;
+  name: string;
+  mimeTypes: string[];
+  component: React.ComponentType;
+  onActivate?: () => void; // Called when editor is registered
+  onDeactivate?: () => void; // Called when editor is unregistered
+}
+
+export interface MenuConfig {
+  id: string;
+  title: string;
+  location: string; // e.g., "navbar.items", "context.menus"
+  action: () => void | Promise<void>;
+  when?: () => boolean;
+  onActivate?: () => void; // Called when menu item is registered
+  onDeactivate?: () => void; // Called when menu item is unregistered
+}
+
+// Extension metadata attached to defined contributions
+export interface ContributionMetadata {
+  type: 'command' | 'view' | 'editor' | 'menu';
+  id: string;
+  config: any;
+}
+
+// Handle returned by define* functions for cleanup
+export interface ContributionHandle<T = any> {
+  config: T;
+  dispose: () => void;
+  __contributionMeta__: ContributionMetadata;
+}
+
+// Extension context interface (simplified)
+export interface ExtensionContext {
+  registerCommand: (config: CommandConfig) => () => void;
+  registerViewProvider: (
+    id: string,
+    component: React.ComponentType,
+  ) => () => void;
+  registerEditor: (config: EditorConfig) => () => void;
+  registerMenu: (config: MenuConfig) => () => void;
+}
+
+// Global registry for auto-registration
+let _context: ExtensionContext | null = null;
+const _pendingContributions: ContributionHandle[] = [];
+
+/**
+ * Set the extension context for auto-registration.
+ * Called automatically by the extension loader.
+ */
+export function setExtensionContext(context: ExtensionContext): void {
+  _context = context;
+
+  // Auto-register any pending contributions
+  for (const handle of _pendingContributions) {
+    _registerContribution(handle);
+  }
+  _pendingContributions.length = 0;
+}
+
+/**
+ * Internal: Auto-register a single contribution
+ */
+function _registerContribution(handle: ContributionHandle): void {
+  if (!_context) {
+    _pendingContributions.push(handle);
+    return;
+  }
+
+  const { config, __contributionMeta__ } = handle;
+  let disposeFn: () => void;
+
+  // Call onActivate callback if provided
+  const typedConfig = config as
+    | CommandConfig
+    | ViewConfig
+    | EditorConfig
+    | MenuConfig;
+  if (typedConfig.onActivate) {
+    typedConfig.onActivate();
+  }
+
+  switch (__contributionMeta__.type) {
+    case 'command':
+      disposeFn = _context.registerCommand(config as CommandConfig);
+      break;
+    case 'view':
+      const viewConfig = config as ViewConfig;
+      disposeFn = _context.registerViewProvider(
+        viewConfig.id,
+        viewConfig.component,
+      );
+      break;
+    case 'editor':
+      disposeFn = _context.registerEditor(config as EditorConfig);
+      break;
+    case 'menu':
+      disposeFn = _context.registerMenu(config as MenuConfig);
+      break;
+    default:
+      throw new Error(
+        `Unknown contribution type: ${__contributionMeta__.type}`,
+      );
+  }
+
+  // Wrap dispose function to call onDeactivate
+  const originalDispose = disposeFn;
+  handle.dispose = () => {
+    if (typedConfig.onDeactivate) {
+      typedConfig.onDeactivate();
+    }
+    originalDispose();
+  };
+}
+
+// Type augmentation to add metadata to functions
+declare global {
+  interface Function {
+    __contributionMeta__?: ContributionMetadata;
+  }
+}
+
+/**
+ * Define a command contribution.
+ *
+ * Commands are actions that can be triggered from various UI elements
+ * like menus, toolbars, or keyboard shortcuts.
+ *
+ * Auto-registers when extension context is available.
+ *
+ * @param config Command configuration
+ * @returns Handle with config and dispose function
+ */
+export function defineCommand<T extends CommandConfig>(
+  config: T,
+): ContributionHandle<T> {
+  // Store metadata for webpack plugin discovery
+  const metadata: ContributionMetadata = {
+    type: 'command',
+    id: config.id,
+    config,
+  };
+
+  // Attach metadata to the execute function for runtime validation
+  if (config.execute) {
+    config.execute.__contributionMeta__ = metadata;
+  }
+
+  // Create handle that auto-registers
+  const handle: ContributionHandle<T> = {
+    config,
+    dispose: () => {}, // Will be set by _registerContribution
+    __contributionMeta__: metadata,
+  };
+
+  // Auto-register immediately or queue for later
+  _registerContribution(handle);
+
+  return handle;
+}
+
+/**
+ * Define a view contribution.
+ *
+ * Views are UI components that can be embedded in various locations
+ * throughout the Superset interface.
+ *
+ * Auto-registers when extension context is available.
+ *
+ * @param config View configuration
+ * @returns Handle with config and dispose function
+ */
+export function defineView<T extends ViewConfig>(
+  config: T,
+): ContributionHandle<T> {
+  // Store metadata for webpack plugin discovery
+  const metadata: ContributionMetadata = {
+    type: 'view',
+    id: config.id,
+    config,
+  };
+
+  // Attach metadata to the component for runtime validation
+  if (config.component) {
+    (config.component as any).__contributionMeta__ = metadata;
+  }
+
+  // Create handle that auto-registers
+  const handle: ContributionHandle<T> = {
+    config,
+    dispose: () => {}, // Will be set by _registerContribution
+    __contributionMeta__: metadata,
+  };
+
+  // Auto-register immediately or queue for later
+  _registerContribution(handle);
+
+  return handle;
+}
+
+/**
+ * Define an editor contribution.
+ *
+ * Editors provide custom editing interfaces for specific MIME types
+ * in SQL Lab and other contexts.
+ *
+ * Auto-registers when extension context is available.
+ *
+ * @param config Editor configuration
+ * @returns Handle with config and dispose function
+ */
+export function defineEditor<T extends EditorConfig>(
+  config: T,
+): ContributionHandle<T> {
+  // Store metadata for webpack plugin discovery
+  const metadata: ContributionMetadata = {
+    type: 'editor',
+    id: config.id,
+    config,
+  };
+
+  // Attach metadata to the component for runtime validation
+  if (config.component) {
+    (config.component as any).__contributionMeta__ = metadata;
+  }
+
+  // Create handle that auto-registers
+  const handle: ContributionHandle<T> = {
+    config,
+    dispose: () => {}, // Will be set by _registerContribution
+    __contributionMeta__: metadata,
+  };
+
+  // Auto-register immediately or queue for later
+  _registerContribution(handle);
+
+  return handle;
+}
+
+/**
+ * Define a menu contribution.
+ *
+ * Menus add items to various menu locations throughout the interface.
+ *
+ * Auto-registers when extension context is available.
+ *
+ * @param config Menu configuration
+ * @returns Handle with config and dispose function
+ */
+export function defineMenu<T extends MenuConfig>(
+  config: T,
+): ContributionHandle<T> {
+  // Store metadata for webpack plugin discovery
+  const metadata: ContributionMetadata = {
+    type: 'menu',
+    id: config.id,
+    config,
+  };
+
+  // Attach metadata to the action function for runtime validation
+  if (config.action) {
+    config.action.__contributionMeta__ = metadata;
+  }
+
+  // Create handle that auto-registers
+  const handle: ContributionHandle<T> = {
+    config,
+    dispose: () => {}, // Will be set by _registerContribution
+    __contributionMeta__: metadata,
+  };
+
+  // Auto-register immediately or queue for later
+  _registerContribution(handle);
+
+  return handle;
+}
+
+/**
+ * Internal: Clear the contribution registry (for testing)
+ */
+export function _clearContributionRegistry(): void {
+  _pendingContributions.length = 0;
+  _context = null;
+}
diff --git a/superset-frontend/packages/superset-core/src/index.ts 
b/superset-frontend/packages/superset-core/src/index.ts
index b02156449f2..5f2a95d94c7 100644
--- a/superset-frontend/packages/superset-core/src/index.ts
+++ b/superset-frontend/packages/superset-core/src/index.ts
@@ -17,5 +17,6 @@
  * under the License.
  */
 export * from './api';
+export * from './extensions';
 export * from './ui';
 export * from './utils';
diff --git a/superset-frontend/packages/superset-core/test/extensions.test.ts 
b/superset-frontend/packages/superset-core/test/extensions.test.ts
new file mode 100644
index 00000000000..950eb775c29
--- /dev/null
+++ b/superset-frontend/packages/superset-core/test/extensions.test.ts
@@ -0,0 +1,253 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as React from 'react';
+import {
+  defineCommand,
+  defineView,
+  defineEditor,
+  defineMenu,
+  setExtensionContext,
+  _clearContributionRegistry,
+} from '../src/extensions';
+
+// Mock extension context for testing
+const mockContext = {
+  registerCommand: jest.fn(() => jest.fn()),
+  registerViewProvider: jest.fn(() => jest.fn()),
+  registerEditor: jest.fn(() => jest.fn()),
+  registerMenu: jest.fn(() => jest.fn()),
+};
+
+describe('Extension Contributions', () => {
+  beforeEach(() => {
+    _clearContributionRegistry();
+    jest.clearAllMocks();
+  });
+
+  describe('defineCommand', () => {
+    test('should create command contribution with metadata', () => {
+      const command = defineCommand({
+        id: 'test-command',
+        title: 'Test Command',
+        icon: 'TestIcon',
+        execute: async () => console.log('executed'),
+      });
+
+      expect(command.config.id).toBe('test-command');
+      expect(command.config.title).toBe('Test Command');
+      expect(command.config.icon).toBe('TestIcon');
+      expect(command.__contributionMeta__).toBeDefined();
+      expect(command.__contributionMeta__.type).toBe('command');
+      expect(command.__contributionMeta__.id).toBe('test-command');
+    });
+
+    test('should auto-register when context is available', () => {
+      setExtensionContext(mockContext);
+
+      const command = defineCommand({
+        id: 'auto-command',
+        title: 'Auto Command',
+        execute: async () => {},
+      });
+
+      expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
+    });
+
+    test('should call lifecycle callbacks', () => {
+      const onActivate = jest.fn();
+      const onDeactivate = jest.fn();
+
+      setExtensionContext(mockContext);
+
+      const command = defineCommand({
+        id: 'lifecycle-command',
+        title: 'Lifecycle Command',
+        execute: async () => {},
+        onActivate,
+        onDeactivate,
+      });
+
+      expect(onActivate).toHaveBeenCalled();
+
+      // Test disposal
+      command.dispose();
+      expect(onDeactivate).toHaveBeenCalled();
+    });
+  });
+
+  describe('defineView', () => {
+    test('should create view contribution with metadata', () => {
+      const TestComponent = () => React.createElement('div', null, 'Test');
+
+      const view = defineView({
+        id: 'test-view',
+        title: 'Test View',
+        location: 'sqllab.panels',
+        component: TestComponent,
+      });
+
+      expect(view.config.id).toBe('test-view');
+      expect(view.config.title).toBe('Test View');
+      expect(view.config.location).toBe('sqllab.panels');
+      expect(view.config.component).toBe(TestComponent);
+      expect(view.__contributionMeta__).toBeDefined();
+      expect(view.__contributionMeta__.type).toBe('view');
+    });
+
+    test('should auto-register when context is available', () => {
+      const TestComponent = () => React.createElement('div', null, 'Test');
+
+      setExtensionContext(mockContext);
+
+      defineView({
+        id: 'auto-view',
+        title: 'Auto View',
+        location: 'dashboard.tabs',
+        component: TestComponent,
+      });
+
+      expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
+        'auto-view',
+        TestComponent
+      );
+    });
+  });
+
+  describe('defineEditor', () => {
+    test('should create editor contribution with metadata', () => {
+      const EditorComponent = () => React.createElement('textarea');
+
+      const editor = defineEditor({
+        id: 'test-editor',
+        name: 'Test Editor',
+        mimeTypes: ['text/x-sql'],
+        component: EditorComponent,
+      });
+
+      expect(editor.config.id).toBe('test-editor');
+      expect(editor.config.name).toBe('Test Editor');
+      expect(editor.config.mimeTypes).toEqual(['text/x-sql']);
+      expect(editor.__contributionMeta__).toBeDefined();
+      expect(editor.__contributionMeta__.type).toBe('editor');
+    });
+
+    test('should auto-register when context is available', () => {
+      const EditorComponent = () => React.createElement('textarea');
+
+      setExtensionContext(mockContext);
+
+      const editor = defineEditor({
+        id: 'auto-editor',
+        name: 'Auto Editor',
+        mimeTypes: ['text/plain'],
+        component: EditorComponent,
+      });
+
+      expect(mockContext.registerEditor).toHaveBeenCalledWith(editor.config);
+    });
+  });
+
+  describe('defineMenu', () => {
+    test('should create menu contribution with metadata', () => {
+      const menu = defineMenu({
+        id: 'test-menu',
+        title: 'Test Menu',
+        location: 'navbar.items',
+        action: () => console.log('clicked'),
+      });
+
+      expect(menu.config.id).toBe('test-menu');
+      expect(menu.config.title).toBe('Test Menu');
+      expect(menu.config.location).toBe('navbar.items');
+      expect(menu.__contributionMeta__).toBeDefined();
+      expect(menu.__contributionMeta__.type).toBe('menu');
+    });
+
+    test('should auto-register when context is available', () => {
+      setExtensionContext(mockContext);
+
+      const menu = defineMenu({
+        id: 'auto-menu',
+        title: 'Auto Menu',
+        location: 'context.menus',
+        action: () => {},
+      });
+
+      expect(mockContext.registerMenu).toHaveBeenCalledWith(menu.config);
+    });
+  });
+
+  describe('Auto-registration system', () => {
+    test('should queue contributions when no context is set', () => {
+      const command = defineCommand({
+        id: 'queued-command',
+        title: 'Queued Command',
+        execute: async () => {},
+      });
+
+      // Should not be registered yet
+      expect(mockContext.registerCommand).not.toHaveBeenCalled();
+
+      // Set context - should register queued contributions
+      setExtensionContext(mockContext);
+      expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
+    });
+
+    test('should handle disposal correctly', () => {
+      const mockDispose = jest.fn();
+      mockContext.registerCommand.mockReturnValue(mockDispose);
+
+      setExtensionContext(mockContext);
+
+      const command = defineCommand({
+        id: 'dispose-command',
+        title: 'Dispose Command',
+        execute: async () => {},
+      });
+
+      // Dispose should call the returned cleanup function
+      command.dispose();
+      expect(mockDispose).toHaveBeenCalled();
+    });
+
+    test('should handle mixed contribution types', () => {
+      setExtensionContext(mockContext);
+
+      const command = defineCommand({
+        id: 'mixed-command',
+        title: 'Mixed Command',
+        execute: async () => {},
+      });
+
+      const view = defineView({
+        id: 'mixed-view',
+        title: 'Mixed View',
+        location: 'explore.panels',
+        component: () => React.createElement('div', null, 'Mixed'),
+      });
+
+      expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
+      expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
+        'mixed-view',
+        view.config.component
+      );
+    });
+  });
+});
diff --git a/superset-frontend/packages/webpack-extension-plugin/package.json 
b/superset-frontend/packages/webpack-extension-plugin/package.json
new file mode 100644
index 00000000000..24b3bd03df5
--- /dev/null
+++ b/superset-frontend/packages/webpack-extension-plugin/package.json
@@ -0,0 +1,45 @@
+{
+  "name": "@apache-superset/webpack-extension-plugin",
+  "version": "0.0.1",
+  "description": "Webpack plugin for processing and validating Superset 
extension contributions",
+  "keywords": [
+    "superset",
+    "webpack",
+    "plugin",
+    "extensions"
+  ],
+  "homepage": 
"https://github.com/apache/superset/tree/master/superset-frontend/packages/webpack-extension-plugin#readme";,
+  "bugs": {
+    "url": "https://github.com/apache/superset/issues";
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/apache/superset.git";,
+    "directory": "superset-frontend/packages/webpack-contribution-plugin"
+  },
+  "license": "Apache-2.0",
+  "author": "Superset",
+  "sideEffects": false,
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "files": [
+    "lib"
+  ],
+  "scripts": {
+    "clean": "rm -rf lib tsconfig.tsbuildinfo",
+    "build": "npm run clean && npx tsc --build",
+    "type": "npx tsc --noEmit"
+  },
+  "dependencies": {
+    "typescript": "^5.0.0"
+  },
+  "devDependencies": {
+    "@types/node": "^25.1.0"
+  },
+  "peerDependencies": {
+    "webpack": "^5.0.0"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}
diff --git a/superset-frontend/packages/webpack-extension-plugin/src/index.ts 
b/superset-frontend/packages/webpack-extension-plugin/src/index.ts
new file mode 100644
index 00000000000..0052d9045d8
--- /dev/null
+++ b/superset-frontend/packages/webpack-extension-plugin/src/index.ts
@@ -0,0 +1,532 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import * as ts from 'typescript';
+import * as path from 'path';
+import * as fs from 'fs';
+import { Compiler, WebpackPluginInstance, sources, Compilation } from 
'webpack';
+
+// Contribution type definitions matching backend schema
+interface CommandContribution {
+  id: string;
+  title: string;
+  icon?: string;
+  execute: string; // Module path to the execute function
+}
+
+interface ViewContribution {
+  id: string;
+  title: string;
+  component: string; // Module path to the component
+  location: string; // e.g., "dashboard.tabs", "explore.panels"
+}
+
+interface EditorContribution {
+  id: string;
+  name: string;
+  component: string; // Module path to the component
+  mimeTypes: string[];
+}
+
+interface MenuContribution {
+  id: string;
+  title: string;
+  location: string; // e.g., "navbar.items", "context.menus"
+  action: string; // Module path to the action
+}
+
+interface FrontendContributions {
+  commands: CommandContribution[];
+  views: Record<string, ViewContribution[]>; // Grouped by location
+  editors: EditorContribution[];
+  menus: Record<string, MenuContribution[]>; // Grouped by location
+}
+
+interface SupersetExtensionPluginOptions {
+  outputPath?: string; // Where to write contributions.json
+  include?: string[]; // File patterns to include
+  exclude?: string[]; // File patterns to exclude
+}
+
+/**
+ * Webpack plugin for auto-discovering Superset extension contributions.
+ *
+ * Analyzes TypeScript/JavaScript files during compilation to find calls to
+ * define* functions from @apache-superset/core and outputs a 
contributions.json
+ * file with the discovered contributions.
+ */
+export default class SupersetContributionPlugin implements 
WebpackPluginInstance {
+  private options: SupersetExtensionPluginOptions;
+
+  constructor(options: SupersetExtensionPluginOptions = {}) {
+    this.options = {
+      outputPath: 'contributions.json',
+      include: ['src/**/*.{ts,tsx,js,jsx}'],
+      exclude: ['**/*.test.*', '**/node_modules/**'],
+      ...options,
+    };
+  }
+
+  apply(compiler: Compiler): void {
+    compiler.hooks.compilation.tap(
+      'SupersetContributionPlugin',
+      compilation => {
+        compilation.hooks.processAssets.tap(
+          {
+            name: 'SupersetContributionPlugin',
+            stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
+          },
+          () => {
+            try {
+              const contributions = this.discoverContributions(
+                compiler.context,
+              );
+              const contributionsJson = JSON.stringify(contributions, null, 2);
+
+              // Add the contributions.json file to webpack's output
+              const outputPath = this.options.outputPath!;
+              compilation.emitAsset(
+                outputPath,
+                new sources.RawSource(contributionsJson),
+              );
+
+              console.log(
+                `🔍 Discovered ${this.countContributions(contributions)} 
frontend contributions`,
+              );
+            } catch (error) {
+              compilation.errors.push(
+                new Error(`SupersetContributionPlugin: ${error}`),
+              );
+            }
+          },
+        );
+      },
+    );
+  }
+
+  private discoverContributions(rootPath: string): FrontendContributions {
+    const contributions: FrontendContributions = {
+      commands: [],
+      views: {},
+      editors: [],
+      menus: {},
+    };
+
+    // Find all TypeScript/JavaScript files
+    const files = this.findSourceFiles(rootPath);
+
+    for (const filePath of files) {
+      try {
+        const fileContributions = this.analyzeFile(filePath);
+        this.mergeContributions(contributions, fileContributions);
+      } catch (error) {
+        console.warn(`⚠️  Failed to analyze ${filePath}:`, error);
+      }
+    }
+
+    return contributions;
+  }
+
+  private findSourceFiles(rootPath: string): string[] {
+    const files: string[] = [];
+    const { include, exclude } = this.options;
+
+    const walkDir = (dir: string) => {
+      try {
+        const entries = fs.readdirSync(dir, { withFileTypes: true });
+
+        for (const entry of entries) {
+          const fullPath = path.join(dir, entry.name);
+          const relativePath = path
+            .relative(rootPath, fullPath)
+            .replace(/\\/g, '/'); // Normalize path separators
+
+          if (entry.isDirectory()) {
+            // Skip excluded directories and node_modules
+            if (entry.name === 'node_modules') {
+              continue;
+            }
+            const isExcluded = this.matchesPatterns(relativePath, exclude!);
+            if (!isExcluded) {
+              walkDir(fullPath);
+            }
+          } else if (entry.isFile()) {
+            // Only check files that might be relevant (have extensions we 
care about)
+            const hasRelevantExtension = /\.(ts|tsx|js|jsx)$/.test(
+              relativePath,
+            );
+            if (hasRelevantExtension) {
+              const isIncluded = this.matchesPatterns(relativePath, include!);
+              const isExcluded = this.matchesPatterns(relativePath, exclude!);
+
+              if (isIncluded && !isExcluded) {
+                files.push(fullPath);
+              }
+            }
+          }
+        }
+      } catch (error) {
+        // Skip directories that can't be read
+      }
+    };
+
+    walkDir(rootPath);
+    return files;
+  }
+
+  private matchesPatterns(filePath: string, patterns: string[]): boolean {
+    return patterns.some(pattern => {
+      // Handle brace expansion like {ts,tsx,js,jsx}
+      const expandedPatterns = this.expandBraces(pattern);
+      return expandedPatterns.some(expandedPattern => {
+        // Convert glob pattern to regex
+        // Special handling for **/pattern which should match pattern directly
+        let regex = expandedPattern
+          // Replace glob patterns first
+          .replace(/\*\*\/\*/g, '§DOUBLESTAR_SLASH_STAR§') // **/* becomes 
special pattern
+          .replace(/\*\*/g, '§DOUBLESTAR§') // ** becomes another pattern
+          .replace(/\*/g, '§STAR§') // * becomes yet another
+          .replace(/\?/g, '§QUESTION§') // ? becomes final pattern
+          // Then escape all regex special chars
+          .replace(/[.+^${}()|[\]\\]/g, '\\$&')
+          // Then restore glob patterns as regex
+          .replace(/§DOUBLESTAR_SLASH_STAR§/g, '(?:.*/)?[^/]*') // **/* 
matches zero+ dirs + filename
+          .replace(/§DOUBLESTAR§/g, '(?:.*)?') // ** matches zero+ chars
+          .replace(/§STAR§/g, '[^/]*') // * matches anything except /
+          .replace(/§QUESTION§/g, '.'); // ? matches any single char
+
+        const regexPattern = new RegExp(`^${regex}$`);
+        const matches = regexPattern.test(filePath);
+
+        return matches;
+      });
+    });
+  }
+
+  private expandBraces(pattern: string): string[] {
+    const braceMatch = pattern.match(/^(.+)\{([^}]+)\}(.*)$/);
+    if (!braceMatch) {
+      return [pattern];
+    }
+
+    const [, prefix, options, suffix] = braceMatch;
+    return options.split(',').map(option => prefix + option.trim() + suffix);
+  }
+
+  private analyzeFile(filePath: string): FrontendContributions {
+    const sourceCode = fs.readFileSync(filePath, 'utf-8');
+    const sourceFile = ts.createSourceFile(
+      filePath,
+      sourceCode,
+      ts.ScriptTarget.Latest,
+      true,
+    );
+
+    const contributions: FrontendContributions = {
+      commands: [],
+      views: {},
+      editors: [],
+      menus: {},
+    };
+
+    // Create a TypeScript program for type checking
+    const program = ts.createProgram([filePath], {
+      target: ts.ScriptTarget.Latest,
+      module: ts.ModuleKind.ESNext,
+      allowJs: true,
+      jsx: ts.JsxEmit.React,
+    });
+
+    const checker = program.getTypeChecker();
+
+    const visit = (node: ts.Node) => {
+      if (ts.isCallExpression(node)) {
+        this.analyzeCallExpression(node, checker, contributions, filePath);
+      }
+      ts.forEachChild(node, visit);
+    };
+
+    visit(sourceFile);
+    return contributions;
+  }
+
+  private analyzeCallExpression(
+    call: ts.CallExpression,
+    checker: ts.TypeChecker,
+    contributions: FrontendContributions,
+    filePath: string,
+  ): void {
+    if (!ts.isIdentifier(call.expression)) {
+      return;
+    }
+
+    const functionName = call.expression.text;
+
+    // Check if this is a define* function call
+    if (
+      !['defineCommand', 'defineView', 'defineEditor', 'defineMenu'].includes(
+        functionName,
+      )
+    ) {
+      return;
+    }
+
+    // For externals (like @apache-superset/core), validate by checking import 
statements
+    const isValidPackage = this.isValidImportInFile(filePath, functionName);
+
+    if (!isValidPackage) {
+      console.warn(
+        `⚠️  ${functionName} not imported from @apache-superset/core in 
${filePath}`,
+      );
+      return;
+    }
+
+    // Extract the configuration object
+    if (call.arguments.length === 0) {
+      return;
+    }
+
+    const configArg = call.arguments[0];
+    const config = this.extractObjectLiteral(configArg);
+
+    if (!config) {
+      console.warn(
+        `⚠️  Could not extract config from ${functionName} call in 
${filePath}`,
+      );
+      return;
+    }
+
+    // Process the contribution based on its type
+    switch (functionName) {
+      case 'defineCommand':
+        this.processCommandContribution(config, contributions, filePath);
+        break;
+      case 'defineView':
+        this.processViewContribution(config, contributions, filePath);
+        break;
+      case 'defineEditor':
+        this.processEditorContribution(config, contributions, filePath);
+        break;
+      case 'defineMenu':
+        this.processMenuContribution(config, contributions, filePath);
+        break;
+    }
+  }
+
+  private isValidImportInFile(filePath: string, functionName: string): boolean 
{
+    try {
+      const sourceCode = fs.readFileSync(filePath, 'utf-8');
+
+      // Check for import statements that import our function from 
@apache-superset/core
+      const importRegex = new RegExp(
+        
`import\\s+{[^}]*\\b${functionName}\\b[^}]*}\\s+from\\s+['"]@apache-superset/core['"]`,
+      );
+
+      const hasValidImport = importRegex.test(sourceCode);
+
+      if (hasValidImport) {
+        return true;
+      }
+
+      // Also check for default imports or namespace imports
+      const defaultImportRegex =
+        /import\s+\w+\s+from\s+['"]@apache-superset\/core['"]/;
+      const namespaceImportRegex =
+        /import\s+\*\s+as\s+\w+\s+from\s+['"]@apache-superset\/core['"]/;
+
+      return (
+        defaultImportRegex.test(sourceCode) ||
+        namespaceImportRegex.test(sourceCode)
+      );
+    } catch (error) {
+      return false;
+    }
+  }
+
+  private extractObjectLiteral(node: ts.Node): Record<string, any> | null {
+    if (!ts.isObjectLiteralExpression(node)) {
+      return null;
+    }
+
+    const result: Record<string, any> = {};
+
+    for (const property of node.properties) {
+      if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) 
{
+        const key = property.name.text;
+        const value = this.extractValue(property.initializer);
+        result[key] = value;
+      }
+    }
+
+    return result;
+  }
+
+  private extractValue(node: ts.Node): any {
+    if (ts.isStringLiteral(node)) {
+      return node.text;
+    } else if (ts.isNumericLiteral(node)) {
+      return parseFloat(node.text);
+    } else if (node.kind === ts.SyntaxKind.TrueKeyword) {
+      return true;
+    } else if (node.kind === ts.SyntaxKind.FalseKeyword) {
+      return false;
+    } else if (ts.isArrayLiteralExpression(node)) {
+      return node.elements.map(element => this.extractValue(element));
+    } else if (ts.isObjectLiteralExpression(node)) {
+      return this.extractObjectLiteral(node);
+    }
+
+    // For complex expressions, return a string representation
+    return node.getText();
+  }
+
+  private processCommandContribution(
+    config: Record<string, any>,
+    contributions: FrontendContributions,
+    filePath: string,
+  ): void {
+    if (!config.id || !config.title) {
+      console.warn(
+        `⚠️  Command contribution missing required fields in ${filePath}`,
+      );
+      return;
+    }
+
+    contributions.commands.push({
+      id: config.id,
+      title: config.title,
+      icon: config.icon,
+      execute: this.getModulePath(filePath),
+    });
+  }
+
+  private processViewContribution(
+    config: Record<string, any>,
+    contributions: FrontendContributions,
+    filePath: string,
+  ): void {
+    if (!config.id || !config.title || !config.location) {
+      console.warn(
+        `⚠️  View contribution missing required fields in ${filePath}`,
+      );
+      return;
+    }
+
+    const { location } = config;
+    if (!contributions.views[location]) {
+      contributions.views[location] = [];
+    }
+
+    contributions.views[location].push({
+      id: config.id,
+      title: config.title,
+      component: this.getModulePath(filePath),
+      location,
+    });
+  }
+
+  private processEditorContribution(
+    config: Record<string, any>,
+    contributions: FrontendContributions,
+    filePath: string,
+  ): void {
+    if (!config.id || !config.name || !config.mimeTypes) {
+      console.warn(
+        `⚠️  Editor contribution missing required fields in ${filePath}`,
+      );
+      return;
+    }
+
+    contributions.editors.push({
+      id: config.id,
+      name: config.name,
+      component: this.getModulePath(filePath),
+      mimeTypes: Array.isArray(config.mimeTypes)
+        ? config.mimeTypes
+        : [config.mimeTypes],
+    });
+  }
+
+  private processMenuContribution(
+    config: Record<string, any>,
+    contributions: FrontendContributions,
+    filePath: string,
+  ): void {
+    if (!config.id || !config.title || !config.location) {
+      console.warn(
+        `⚠️  Menu contribution missing required fields in ${filePath}`,
+      );
+      return;
+    }
+
+    const { location } = config;
+    if (!contributions.menus[location]) {
+      contributions.menus[location] = [];
+    }
+
+    contributions.menus[location].push({
+      id: config.id,
+      title: config.title,
+      location,
+      action: this.getModulePath(filePath),
+    });
+  }
+
+  private getModulePath(filePath: string): string {
+    // Convert absolute file path to a module path relative to src/
+    const srcIndex = filePath.lastIndexOf('/src/');
+    if (srcIndex !== -1) {
+      return filePath.substring(srcIndex + 5).replace(/\.(ts|tsx|js|jsx)$/, 
'');
+    }
+    return filePath;
+  }
+
+  private mergeContributions(
+    target: FrontendContributions,
+    source: FrontendContributions,
+  ): void {
+    target.commands.push(...source.commands);
+    target.editors.push(...source.editors);
+
+    // Merge views by location
+    for (const [location, views] of Object.entries(source.views)) {
+      if (!target.views[location]) {
+        target.views[location] = [];
+      }
+      target.views[location].push(...views);
+    }
+
+    // Merge menus by location
+    for (const [location, menus] of Object.entries(source.menus)) {
+      if (!target.menus[location]) {
+        target.menus[location] = [];
+      }
+      target.menus[location].push(...menus);
+    }
+  }
+
+  private countContributions(contributions: FrontendContributions): number {
+    return (
+      contributions.commands.length +
+      contributions.editors.length +
+      Object.values(contributions.views).flat().length +
+      Object.values(contributions.menus).flat().length
+    );
+  }
+}
diff --git 
a/superset-frontend/packages/webpack-extension-plugin/test/index.test.ts 
b/superset-frontend/packages/webpack-extension-plugin/test/index.test.ts
new file mode 100644
index 00000000000..dd5a38745ff
--- /dev/null
+++ b/superset-frontend/packages/webpack-extension-plugin/test/index.test.ts
@@ -0,0 +1,148 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import SupersetContributionPlugin from '../src/index';
+
+// Mock webpack compiler and compilation
+const createMockCompiler = (outputPath: string = '/test/output') => {
+  return {
+    options: {
+      output: {
+        path: outputPath,
+      },
+    },
+    hooks: {
+      compilation: {
+        tap: jest.fn(),
+      },
+      emit: {
+        tap: jest.fn(),
+      },
+    },
+  } as any;
+};
+
+const createMockCompilation = (assets: Record<string, any> = {}) => {
+  return {
+    assets,
+    hooks: {
+      processAssets: {
+        tap: jest.fn(),
+      },
+    },
+    emitAsset: jest.fn(),
+  } as any;
+};
+
+describe('SupersetContributionPlugin', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('constructor', () => {
+    test('should create plugin with default options', () => {
+      const plugin = new SupersetContributionPlugin();
+
+      // Test that plugin was created (can't access private options easily)
+      expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
+    });
+
+    test('should create plugin with custom options', () => {
+      const plugin = new SupersetContributionPlugin({
+        outputPath: 'custom.json',
+        include: ['app/**/*.ts'],
+        exclude: ['**/*.spec.*'],
+      });
+
+      expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
+    });
+  });
+
+  describe('apply', () => {
+    test('should register emit hook', () => {
+      const plugin = new SupersetContributionPlugin();
+      const compiler = createMockCompiler();
+
+      plugin.apply(compiler);
+
+      expect(compiler.hooks.emit.tapAsync).toHaveBeenCalledWith(
+        'SupersetContributionPlugin',
+        expect.any(Function),
+      );
+    });
+  });
+
+  describe('integration test', () => {
+    test('should process and emit contributions.json', done => {
+      const plugin = new SupersetContributionPlugin();
+      const compiler = createMockCompiler('/test/root');
+      const compilation = createMockCompilation();
+
+      // Mock the emit hook to be called synchronously for testing
+      compiler.hooks.emit.tapAsync.mockImplementation(
+        (name: string, callback: any) => {
+          // Call the callback with mocked compilation
+          try {
+            callback(compilation, () => {
+              // Verify contributions.json was emitted
+              expect(compilation.assets['contributions.json']).toBeDefined();
+
+              const asset = compilation.assets['contributions.json'];
+              const content = asset.source();
+              const contributions = JSON.parse(content);
+
+              // Should have the expected structure
+              expect(contributions).toHaveProperty('commands');
+              expect(contributions).toHaveProperty('views');
+              expect(contributions).toHaveProperty('editors');
+              expect(contributions).toHaveProperty('menus');
+
+              expect(Array.isArray(contributions.commands)).toBe(true);
+              expect(typeof contributions.views).toBe('object');
+              expect(Array.isArray(contributions.editors)).toBe(true);
+              expect(typeof contributions.menus).toBe('object');
+
+              done();
+            });
+          } catch (error) {
+            done(error);
+          }
+        },
+      );
+
+      plugin.apply(compiler);
+    });
+  });
+
+  describe('file pattern matching', () => {
+    test('should match TypeScript files', () => {
+      const plugin = new SupersetContributionPlugin();
+
+      // Test that the plugin handles common file patterns
+      expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
+    });
+
+    test('should exclude test files', () => {
+      const plugin = new SupersetContributionPlugin();
+
+      // Test that the plugin excludes test patterns
+      expect(plugin).toBeInstanceOf(SupersetContributionPlugin);
+    });
+  });
+});
diff --git a/superset-frontend/packages/webpack-extension-plugin/tsconfig.json 
b/superset-frontend/packages/webpack-extension-plugin/tsconfig.json
new file mode 100644
index 00000000000..8157eef27f6
--- /dev/null
+++ b/superset-frontend/packages/webpack-extension-plugin/tsconfig.json
@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "lib": ["ES2020", "dom"],
+    "module": "CommonJS",
+    "declaration": true,
+    "outDir": "./lib",
+    "rootDir": "./src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "moduleResolution": "node",
+    "resolveJsonModule": true,
+    "types": ["node"]
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "lib", "**/*.test.ts"]
+}
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx 
b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
index 74c5ee05fd7..21c129fb66a 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
@@ -31,7 +31,7 @@ import { SqlLabRootState } from 'src/SqlLab/types';
 import { ViewLocations } from 'src/SqlLab/contributions';
 import PanelToolbar from 'src/components/PanelToolbar';
 import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
-import ExtensionsManager from 'src/extensions/ExtensionsManager';
+import ExtensionLoader from 'src/extensions/ExtensionLoader';
 import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
 import useLogAction from 'src/logger/useLogAction';
 import { LOG_ACTIONS_SQLLAB_SWITCH_SOUTH_PANE_TAB } from 'src/logger/LogUtils';
@@ -105,9 +105,7 @@ const SouthPane = ({
   const theme = useTheme();
   const dispatch = useDispatch();
   const contributions =
-    ExtensionsManager.getInstance().getViewContributions(
-      ViewLocations.sqllab.panels,
-    ) || [];
+    ExtensionLoader.getInstance().getViewContributions('sqllab.panels') || [];
   const { getView } = useExtensionsContext();
   const { offline, tables } = useSelector(
     ({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
diff --git a/superset-frontend/src/extensions/ExtensionLoader.ts 
b/superset-frontend/src/extensions/ExtensionLoader.ts
new file mode 100644
index 00000000000..42b4c3effe0
--- /dev/null
+++ b/superset-frontend/src/extensions/ExtensionLoader.ts
@@ -0,0 +1,470 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SupersetClient } from '@superset-ui/core';
+import { logging } from '@apache-superset/core';
+import { setExtensionContext } from '@apache-superset/core';
+import type { ExtensionContext } from '@apache-superset/core';
+
+// Manifest schema from auto-discovery
+interface ManifestFrontend {
+  remoteEntry: string;
+  contributions: FrontendContributions;
+}
+
+interface FrontendContributions {
+  commands: CommandContribution[];
+  views: Record<string, ViewContribution[]>;
+  editors: EditorContribution[];
+  menus: Record<string, MenuContribution[]>;
+}
+
+interface CommandContribution {
+  id: string;
+  title: string;
+  icon?: string;
+  execute: string; // Module path
+}
+
+interface ViewContribution {
+  id: string;
+  title: string;
+  component: string; // Module path
+  location: string;
+}
+
+interface EditorContribution {
+  id: string;
+  name: string;
+  component: string; // Module path
+  mimeTypes: string[];
+}
+
+interface MenuContribution {
+  id: string;
+  title: string;
+  location: string;
+  action: string; // Module path
+}
+
+interface ExtensionManifest {
+  id: string;
+  name: string;
+  version: string;
+  frontend?: ManifestFrontend;
+}
+
+interface LoadedExtension {
+  id: string;
+  name: string;
+  manifest: ExtensionManifest;
+  module?: any; // The loaded webpack module
+  disposables: Array<() => void>;
+}
+
+/**
+ * ExtensionLoader - Loads and validates extensions using the auto-discovery 
system
+ */
+class ExtensionLoader {
+  private static instance: ExtensionLoader;
+  private loadedExtensions: Map<string, LoadedExtension> = new Map();
+  private contributionRegistry: Map<string, any> = new Map(); // Track 
registered contributions
+
+  private constructor() {}
+
+  public static getInstance(): ExtensionLoader {
+    if (!ExtensionLoader.instance) {
+      ExtensionLoader.instance = new ExtensionLoader();
+    }
+    return ExtensionLoader.instance;
+  }
+
+  /**
+   * Initialize and load all available extensions
+   */
+  public async initializeExtensions(): Promise<void> {
+    try {
+      const response = await SupersetClient.get({
+        endpoint: '/api/v1/extensions/',
+      });
+
+      const extensions: ExtensionManifest[] = response.json.result;
+
+      await Promise.all(
+        extensions.map(async manifest => {
+          try {
+            await this.loadExtension(manifest);
+          } catch (error) {
+            logging.error(`Failed to load extension ${manifest.id}:`, error);
+          }
+        }),
+      );
+
+      logging.info(
+        `Loaded ${this.loadedExtensions.size} extensions successfully`,
+      );
+    } catch (error) {
+      logging.error('Failed to initialize extensions:', error);
+    }
+  }
+
+  /**
+   * Load a single extension using webpack module federation
+   */
+  public async loadExtension(manifest: ExtensionManifest): Promise<void> {
+    const { id, name, frontend } = manifest;
+
+    if (!frontend?.remoteEntry) {
+      logging.warn(`Extension ${id} has no frontend component, skipping`);
+      return;
+    }
+
+    try {
+      logging.info(`Loading extension: ${name} (${id})`);
+
+      // Construct full URL for remote entry
+      const remoteEntryUrl = frontend.remoteEntry.startsWith('http')
+        ? frontend.remoteEntry
+        : `/static/extensions/${id}/${frontend.remoteEntry}`;
+
+      // Load the remote entry script
+      await this.loadRemoteEntry(remoteEntryUrl, id);
+
+      // Get the webpack module federation container
+      const container = (window as any)[id];
+      if (!container) {
+        throw new Error(
+          `Extension container ${id} not found after loading remote entry`,
+        );
+      }
+
+      // Initialize webpack sharing
+      // @ts-ignore
+      await __webpack_init_sharing__('default');
+      // @ts-ignore
+      await container.init(__webpack_share_scopes__.default);
+
+      // Load the main module (typically exposed as './index')
+      const factory = await container.get('./index');
+      const extensionModule = factory();
+
+      // Create extension context for auto-registration
+      const context = this.createExtensionContext(id);
+
+      // Set context in the extension's environment so define* functions 
auto-register
+      setExtensionContext(context);
+
+      // Create loaded extension record
+      const loadedExtension: LoadedExtension = {
+        id,
+        name,
+        manifest,
+        module: extensionModule,
+        disposables: [],
+      };
+
+      // Validate contributions against manifest (security check)
+      await this.validateContributions(loadedExtension, 
frontend.contributions);
+
+      this.loadedExtensions.set(id, loadedExtension);
+
+      logging.info(`✅ Extension ${name} loaded and validated successfully`);
+    } catch (error) {
+      logging.error(`Failed to load extension ${name}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Load remote entry script for webpack module federation
+   */
+  private async loadRemoteEntry(
+    remoteEntry: string,
+    extensionId: string,
+  ): Promise<void> {
+    return new Promise<void>((resolve, reject) => {
+      // Check if already loaded
+      if (document.querySelector(`script[src="${remoteEntry}"]`)) {
+        resolve();
+        return;
+      }
+
+      const script = document.createElement('script');
+      script.src = remoteEntry;
+      script.type = 'text/javascript';
+      script.async = true;
+
+      script.onload = () => {
+        logging.debug(`Remote entry loaded: ${remoteEntry}`);
+        resolve();
+      };
+
+      script.onerror = error => {
+        const message = `Failed to load remote entry: ${remoteEntry}`;
+        logging.error(message, error);
+        reject(new Error(message));
+      };
+
+      document.head.appendChild(script);
+    });
+  }
+
+  /**
+   * Create extension context with registration callbacks
+   */
+  private createExtensionContext(extensionId: string): ExtensionContext {
+    return {
+      registerCommand: config => {
+        const key = `${extensionId}.${config.id}`;
+        this.contributionRegistry.set(`command:${key}`, config);
+        logging.debug(`Registered command: ${key}`);
+
+        return () => {
+          this.contributionRegistry.delete(`command:${key}`);
+          logging.debug(`Unregistered command: ${key}`);
+        };
+      },
+
+      registerViewProvider: (id, component) => {
+        const key = `${extensionId}.${id}`;
+        this.contributionRegistry.set(`view:${key}`, { id, component });
+        logging.debug(`Registered view provider: ${key}`);
+
+        return () => {
+          this.contributionRegistry.delete(`view:${key}`);
+          logging.debug(`Unregistered view provider: ${key}`);
+        };
+      },
+
+      registerEditor: config => {
+        const key = `${extensionId}.${config.id}`;
+        this.contributionRegistry.set(`editor:${key}`, config);
+        logging.debug(`Registered editor: ${key}`);
+
+        return () => {
+          this.contributionRegistry.delete(`editor:${key}`);
+          logging.debug(`Unregistered editor: ${key}`);
+        };
+      },
+
+      registerMenu: config => {
+        const key = `${extensionId}.${config.id}`;
+        this.contributionRegistry.set(`menu:${key}`, config);
+        logging.debug(`Registered menu: ${key}`);
+
+        return () => {
+          this.contributionRegistry.delete(`menu:${key}`);
+          logging.debug(`Unregistered menu: ${key}`);
+        };
+      },
+    };
+  }
+
+  /**
+   * Validate that runtime contributions match the manifest allowlist
+   */
+  private async validateContributions(
+    extension: LoadedExtension,
+    manifestContributions: FrontendContributions,
+  ): Promise<void> {
+    const { id } = extension;
+
+    // Small delay to allow define* functions to execute and register
+    await new Promise(resolve => setTimeout(resolve, 100));
+
+    // Validate commands
+    for (const commandDef of manifestContributions.commands) {
+      const key = `command:${id}.${commandDef.id}`;
+      if (!this.contributionRegistry.has(key)) {
+        throw new Error(
+          `Command ${commandDef.id} declared in manifest but not found in 
runtime exports`,
+        );
+      }
+    }
+
+    // Validate views
+    for (const [, views] of Object.entries(manifestContributions.views)) {
+      for (const viewDef of views) {
+        const key = `view:${id}.${viewDef.id}`;
+        if (!this.contributionRegistry.has(key)) {
+          throw new Error(
+            `View ${viewDef.id} declared in manifest but not found in runtime 
exports`,
+          );
+        }
+      }
+    }
+
+    // Validate editors
+    for (const editorDef of manifestContributions.editors) {
+      const key = `editor:${id}.${editorDef.id}`;
+      if (!this.contributionRegistry.has(key)) {
+        throw new Error(
+          `Editor ${editorDef.id} declared in manifest but not found in 
runtime exports`,
+        );
+      }
+    }
+
+    // Validate menus
+    for (const [, menus] of Object.entries(manifestContributions.menus)) {
+      for (const menuDef of menus) {
+        const key = `menu:${id}.${menuDef.id}`;
+        if (!this.contributionRegistry.has(key)) {
+          throw new Error(
+            `Menu ${menuDef.id} declared in manifest but not found in runtime 
exports`,
+          );
+        }
+      }
+    }
+
+    logging.debug(`✅ All contributions validated for extension ${id}`);
+  }
+
+  /**
+   * Get view contributions for a specific location
+   */
+  public getViewContributions(
+    location: string,
+  ): Array<{ id: string; name: string; component: React.ComponentType }> {
+    const views: Array<{
+      id: string;
+      name: string;
+      component: React.ComponentType;
+    }> = [];
+
+    for (const [key, contribution] of this.contributionRegistry.entries()) {
+      if (key.startsWith('view:')) {
+        const extension = Array.from(this.loadedExtensions.values()).find(ext 
=>
+          key.startsWith(`view:${ext.id}.`),
+        );
+
+        if (extension) {
+          const viewContributions =
+            extension.manifest.frontend?.contributions.views[location] || [];
+          const contributionId = key.replace(/^view:[^.]+\./, '');
+          const viewDef = viewContributions.find(v => v.id === contributionId);
+
+          if (viewDef) {
+            views.push({
+              id: contribution.id,
+              name: viewDef.title,
+              component: contribution.component,
+            });
+          }
+        }
+      }
+    }
+
+    return views;
+  }
+
+  /**
+   * Get command contributions
+   */
+  public getCommandContributions(): Array<any> {
+    const commands: Array<any> = [];
+
+    for (const [key, contribution] of this.contributionRegistry.entries()) {
+      if (key.startsWith('command:')) {
+        commands.push(contribution);
+      }
+    }
+
+    return commands;
+  }
+
+  /**
+   * Get editor contributions
+   */
+  public getEditorContributions(): Array<any> {
+    const editors: Array<any> = [];
+
+    for (const [key, contribution] of this.contributionRegistry.entries()) {
+      if (key.startsWith('editor:')) {
+        editors.push(contribution);
+      }
+    }
+
+    return editors;
+  }
+
+  /**
+   * Get menu contributions for a specific location
+   */
+  public getMenuContributions(location: string): Array<any> {
+    const menus: Array<any> = [];
+
+    for (const [key, contribution] of this.contributionRegistry.entries()) {
+      if (key.startsWith('menu:')) {
+        const extension = Array.from(this.loadedExtensions.values()).find(ext 
=>
+          key.startsWith(`menu:${ext.id}.`),
+        );
+
+        if (extension) {
+          const menuContributions =
+            extension.manifest.frontend?.contributions.menus[location] || [];
+          const contributionId = key.replace(/^menu:[^.]+\./, '');
+
+          if (menuContributions.some(m => m.id === contributionId)) {
+            menus.push(contribution);
+          }
+        }
+      }
+    }
+
+    return menus;
+  }
+
+  /**
+   * Get loaded extensions
+   */
+  public getLoadedExtensions(): LoadedExtension[] {
+    return Array.from(this.loadedExtensions.values());
+  }
+
+  /**
+   * Unload an extension
+   */
+  public unloadExtension(id: string): boolean {
+    const extension = this.loadedExtensions.get(id);
+    if (!extension) {
+      return false;
+    }
+
+    try {
+      // Dispose all registered contributions
+      extension.disposables.forEach(dispose => dispose());
+
+      // Clear from registry
+      for (const key of this.contributionRegistry.keys()) {
+        if (key.includes(`${id}.`)) {
+          this.contributionRegistry.delete(key);
+        }
+      }
+
+      this.loadedExtensions.delete(id);
+      logging.info(`Extension ${extension.name} unloaded successfully`);
+      return true;
+    } catch (error) {
+      logging.error(`Failed to unload extension ${extension.name}:`, error);
+      return false;
+    }
+  }
+}
+
+export default ExtensionLoader;
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx 
b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index b6f03eb5164..aa66ad31884 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -31,7 +31,7 @@ import {
 import { useSelector } from 'react-redux';
 import { RootState } from 'src/views/store';
 import { useExtensionsContext } from './ExtensionsContext';
-import ExtensionsManager from './ExtensionsManager';
+import ExtensionLoader from './ExtensionLoader';
 
 declare global {
   interface Window {
@@ -77,7 +77,7 @@ const ExtensionsStartup = () => {
     // Initialize extensions
     if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
       try {
-        ExtensionsManager.getInstance().initializeExtensions();
+        ExtensionLoader.getInstance().initializeExtensions();
         supersetCore.logging.info('Extensions initialized successfully.');
       } catch (error) {
         supersetCore.logging.error('Error setting up extensions:', error);
diff --git a/superset/static/service-worker.js 
b/superset/static/service-worker.js
index 43cb14a4894..55f3b1ce6cd 100644
--- a/superset/static/service-worker.js
+++ b/superset/static/service-worker.js
@@ -1,27 +1 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-// Minimal service worker for PWA file handling support
-self.addEventListener('install', event => {
-  event.waitUntil(self.skipWaiting());
-});
-
-self.addEventListener('activate', event => {
-  event.waitUntil(self.clients.claim());
-});
+(()=>{"use strict";let e;var 
r,t,n,o,a,i,f,u,l,s,d,p,c,v,h,g,y={55725(){self.addEventListener("install",e=>{e.waitUntil(self.skipWaiting())}),self.addEventListener("activate",e=>{e.waitUntil(self.clients.claim())})}},b={};function
 m(e){var r=b[e];if(void 0!==r)return r.exports;var 
t=b[e]={id:e,loaded:!1,exports:{}};return 
y[e].call(t.exports,t,t.exports,m),t.loaded=!0,t.exports}m.m=y,m.c=b,r=[],m.O=(e,t,n,o)=>{if(t){o=o||0;for(var
 a=r.length;a>0&&r[a-1][2]>o;a--)r[a]=r[a-1];r[a]=[t,n,o]; [...]

Reply via email to