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

bamaer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 264c70ddf2 Fixes #8403 : Add a Plugin Catalog transform that exposes 
the plugin … (#8408)
264c70ddf2 is described below

commit 264c70ddf23a4bd28bb53ee589c8dc38b28ea397
Author: Bart Maertens <[email protected]>
AuthorDate: Wed Sep 16 21:11:07 2026 +0200

    Fixes #8403 : Add a Plugin Catalog transform that exposes the plugin … 
(#8408)
    
    * Issue #8403 : Add a Plugin Catalog transform that exposes the plugin 
registry as pipeline rows
    
    * Issue #8403 : Address review feedback on the Plugin Catalog transform
---
 assemblies/plugins/pom.xml                         |   6 +
 .../images/transforms/icons/plugincatalog.svg      |  11 +
 docs/hop-user-manual/modules/ROOT/nav.adoc         |   1 +
 .../pages/pipeline/transforms/plugincatalog.adoc   | 123 ++++++++
 .../transform/never-consume-at-default-plugins.txt |   1 +
 .../transforms/0113-plugin-catalog.hpl             | 179 ++++++++++++
 .../transforms/datasets/golden-plugin-catalog.csv  |   5 +
 .../transforms/main-0113-plugin-catalog.hwf        |  79 ++++++
 .../metadata/dataset/golden-plugin-catalog.json    |  39 +++
 .../unit-test/0113-plugin-catalog UNIT.json        |  43 +++
 plugins/transforms/plugincatalog/pom.xml           |  32 +++
 .../plugincatalog/src/assembly/assembly.xml        |  43 +++
 .../transforms/plugincatalog/DetailLevel.java      |  37 +++
 .../transforms/plugincatalog/PluginCatalog.java    | 113 ++++++++
 .../plugincatalog/PluginCatalogData.java           |  31 +++
 .../plugincatalog/PluginCatalogDialog.java         |  80 ++++++
 .../plugincatalog/PluginCatalogMeta.java           | 234 ++++++++++++++++
 .../plugincatalog/PluginCatalogReader.java         | 309 +++++++++++++++++++++
 .../transforms/plugincatalog/PluginRecord.java     |  42 +++
 .../transforms/plugincatalog/PropertyRecord.java   |  36 +++
 .../messages/messages_en_US.properties             |  36 +++
 .../src/main/resources/plugincatalog.svg           |  11 +
 .../plugincatalog/src/main/resources/version.xml   |  20 ++
 .../samples/transforms/plugin-catalog-basic.hpl    | 108 +++++++
 .../io/github/thirdparty/ThirdPartyMapping.java    |  31 +++
 .../plugincatalog/PluginCatalogReaderTest.java     | 202 ++++++++++++++
 plugins/transforms/pom.xml                         |   1 +
 27 files changed, 1853 insertions(+)

diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index 0d3dfe9227..1c53b8f415 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -1284,6 +1284,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-transform-plugincatalog</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-processfiles</artifactId>
diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/plugincatalog.svg
 
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/plugincatalog.svg
new file mode 100644
index 0000000000..53ec8ed4b5
--- /dev/null
+++ 
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/plugincatalog.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" 
xmlns="http://www.w3.org/2000/svg";>
+  <rect x="3" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F" 
stroke-width="1.6"/>
+  <rect x="13" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F" 
stroke-width="1.6"/>
+  <rect x="3" y="13" width="8" height="8" rx="1.5" stroke="#2A5D8F" 
stroke-width="1.6"/>
+  <rect x="13" y="13" width="8" height="8" rx="1.5" stroke="#7BB661" 
stroke-width="1.6"/>
+  <line x1="7" y1="11" x2="7" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+  <line x1="17" y1="11" x2="17" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+  <line x1="11" y1="7" x2="13" y2="7" stroke="#2A5D8F" stroke-width="1.6"/>
+  <line x1="11" y1="17" x2="13" y2="17" stroke="#2A5D8F" stroke-width="1.6"/>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc 
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 47c2894cbf..9952b52dca 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -249,6 +249,7 @@ under the License.
 *** xref:pipeline/transforms/pgpdecryptstream.adoc[PGP decrypt stream]
 *** xref:pipeline/transforms/pgpencryptstream.adoc[PGP encrypt stream]
 *** xref:pipeline/transforms/pipeline-executor.adoc[Pipeline Executor]
+*** xref:pipeline/transforms/plugincatalog.adoc[Plugin Catalog]
 *** xref:pipeline/transforms/postgresbulkloader.adoc[PostgreSQL bulk loader]
 *** xref:pipeline/transforms/processfiles.adoc[Process files]
 *** xref:pipeline/transforms/propertyinput.adoc[Properties file Input]
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/plugincatalog.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/plugincatalog.adoc
new file mode 100644
index 0000000000..2e2c794a66
--- /dev/null
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/plugincatalog.adoc
@@ -0,0 +1,123 @@
+////
+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.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: The Plugin Catalog transform reads the live Hop plugin registry 
and emits the installed transforms, actions and metadata types as rows.
+
+= image:transforms/icons/plugincatalog.svg[Plugin Catalog transform Icon, 
role="image-doc-icon"] Plugin Catalog
+
+[%noheader,cols="3a,1a", role="table-no-borders" ]
+|===
+|
+== Description
+
+The Plugin Catalog transform reads the live Hop plugin registry and emits one 
row per installed plugin: transforms, workflow actions and metadata types.
+
+For each plugin it also reflects over the plugin's metadata class and reports 
the `@HopMetadataProperty` fields it accepts, so the catalog describes not just 
which plugins are installed but which properties each one exposes.
+
+Because the catalog comes from the registry of the running Hop installation 
rather than from a static list, it always matches the version in use and 
includes any third-party or Marketplace plugins that are installed.
+
+This is a source transform: it generates rows and does not read an input 
stream.
+
+|
+== Supported Engines
+[%noheader,cols="2,1a",frame=none, role="table-supported-engines"]
+!===
+!Hop Engine! image:check_mark.svg[Supported, 24]
+!Single Threaded! image:check_mark.svg[Supported, 24]
+!Native Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Flink! image:question_mark.svg[Maybe Supported, 24]
+!Beam Dataflow! image:question_mark.svg[Maybe Supported, 24]
+!===
+|===
+
+== Usage
+
+The catalog answers questions about what a given Hop installation actually 
contains, for example:
+
+* generating or checking documentation against the plugins that really ship
+* auditing which plugins are installed across environments
+* supplying an AI assistant with real plugin ids and property names instead of 
guessed ones
+* building migration or refactoring tooling that needs to resolve property 
names
+
+== Options
+
+[options="header"]
+|===
+|Option |Description
+
+|Transform name|Name of the transform, unique within the pipeline.
+|Include transforms|Emit an entry for every registered pipeline transform 
plugin.
+|Include actions|Emit an entry for every registered workflow action plugin.
+|Include metadata types|Emit an entry for every registered Hop metadata type, 
such as relational database connections or run configurations.
+|Detail level|`PER_PLUGIN` emits one row per plugin with the properties as a 
JSON column. `PER_PROPERTY` emits one row per property, repeating the plugin 
columns.
+|===
+
+== Output fields
+
+Every row carries these plugin-level fields:
+
+[options="header"]
+|===
+|Field |Type |Description
+
+|`plugin_id`|String|Plugin id, for example `TextFileInput2` or `ABORT`.
+|`plugin_type`|String|`transform`, `action` or `metadata`.
+|`name`|String|Display name, resolved against the Hop locale in use.
+|`description`|String|Description, resolved against the Hop locale in use.
+|`category`|String|Category, resolved against the Hop locale in use.
+|`keywords`|String|Comma-separated keywords, resolved against the Hop locale 
in use.
+|`class_name`|String|Fully qualified metadata or implementation class.
+|`english_aliases`|String|Comma-separated English name, category and keyword 
aliases, independent of the locale in use.
+|`locale`|String|BCP-47 tag of the locale the resolved labels above came from.
+|===
+
+`PER_PLUGIN` adds one field:
+
+[options="header"]
+|===
+|Field |Type |Description
+
+|`metadata_fields`|String|JSON array of `{field, xml_key, java_type, password, 
group, group_key}` objects, one per property.
+|===
+
+`PER_PROPERTY` instead adds one row per property, with:
+
+[options="header"]
+|===
+|Field |Type |Description
+
+|`property_field`|String|Java field name on the metadata class.
+|`property_xml_key`|String|Key the property is serialised under in `.hpl` and 
`.hwf` files.
+|`property_java_type`|String|Simple Java type name of the field.
+|`property_password`|Boolean|`true` when the property is marked as a password 
and should be redacted.
+|`property_group`|String|Name of the containing property group for a nested 
property, empty for top-level properties. This is *nesting*, not the 
serialization wrapper.
+|`property_group_key`|String|The `groupKey` wrapper element a list or map is 
serialized under, for example `fields` in `<fields><field>…`. Empty when the 
property has no wrapper.
+|===
+
+A plugin with no properties still produces one row, with the property fields 
empty.
+
+== Notes
+
+`name`, `description`, `category` and `keywords` are resolved by the plugin 
registry against the locale Hop is running in. When the output is stored or 
indexed, use `english_aliases` for lookups that must not depend on the locale, 
and `locale` to record which locale the labels came from.
+
+Property reflection descends one level into complex property groups, which 
covers the tabular property groups plugins use. Deeper nesting is not reported. 
Group classes are recognised by carrying `@HopMetadataProperty` fields, 
whatever package they are declared in, so third-party and Marketplace plugins 
are described the same way as the ones shipped with Hop.
+
+Properties marked `isExcludedFromSerialization` are left out, since they never 
reach a `.hpl` or `.hwf` file.
+
+Plugins whose metadata class cannot be loaded are logged at detailed logging 
level and skipped rather than failing the pipeline, so a single broken plugin 
does not stop the catalog.
diff --git 
a/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
 
b/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
index c207f9f2b9..cb0c03cb1a 100644
--- 
a/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
+++ 
b/engine/src/test/resources/org/apache/hop/pipeline/transform/never-consume-at-default-plugins.txt
@@ -57,6 +57,7 @@ MetadataInput
 MongoDbInput
 ODataInput
 PipelineLogging
+PluginCatalog
 PropertyInput
 RowGenerator
 RowsFromResult
diff --git a/integration-tests/transforms/0113-plugin-catalog.hpl 
b/integration-tests/transforms/0113-plugin-catalog.hpl
new file mode 100644
index 0000000000..fbfc2320c2
--- /dev/null
+++ b/integration-tests/transforms/0113-plugin-catalog.hpl
@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+  <info>
+    <name>0113-plugin-catalog</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Plugin Catalog reads the live plugin registry. The golden set 
is restricted to the
+      Plugin Catalog transform's own row so that installing, removing or 
moving any other plugin to
+      the Marketplace cannot break this test.</description>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <pipeline_status>0</pipeline_status>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2026/09/16 08:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/09/16 08:00:00.000</modified_date>
+    <key_for_session_key/>
+    <is_key_private>N</is_key_private>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop> <from>Plugin Catalog</from> <to>Only this transform</to> 
<enabled>Y</enabled> </hop>
+    <hop> <from>Only this transform</from> <to>Sort by property</to> 
<enabled>Y</enabled> </hop>
+    <hop> <from>Only this transform</from> <to>DISCARD</to> 
<enabled>Y</enabled> </hop>
+    <hop> <from>Sort by property</from> <to>OUTPUT</to> <enabled>Y</enabled> 
</hop>
+  </order>
+  <transform>
+    <name>Plugin Catalog</name>
+    <type>PluginCatalog</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <includeTransforms>Y</includeTransforms>
+    <includeActions>N</includeActions>
+    <includeMetadataTypes>N</includeMetadataTypes>
+    <detailLevel>PER_PROPERTY</detailLevel>
+    <attributes/>
+    <GUI>
+      <xloc>96</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Only this transform</name>
+    <type>FilterRows</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <send_true_to>Sort by property</send_true_to>
+    <send_false_to>DISCARD</send_false_to>
+    <compare>
+      <condition>
+        <negated>N</negated>
+        <leftvalue>plugin_id</leftvalue>
+        <function>=</function>
+        <rightvalue/>
+        <value>
+          <name>constant</name>
+          <type>String</type>
+          <text>PluginCatalog</text>
+          <length>-1</length>
+          <precision>-1</precision>
+          <isnull>N</isnull>
+          <mask/>
+        </value>
+      </condition>
+    </compare>
+    <attributes/>
+    <GUI>
+      <xloc>288</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Sort by property</name>
+    <type>SortRows</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <directory>${java.io.tmpdir}</directory>
+    <prefix>out</prefix>
+    <sort_size>1000000</sort_size>
+    <free_memory/>
+    <compress>N</compress>
+    <compress_variable/>
+    <unique_rows>N</unique_rows>
+    <fields>
+      <field>
+        <name>property_xml_key</name>
+        <ascending>Y</ascending>
+        <case_sensitive>Y</case_sensitive>
+        <collator_enabled>N</collator_enabled>
+        <collator_strength>0</collator_strength>
+        <presorted>N</presorted>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>496</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>OUTPUT</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>688</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>DISCARD</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>288</xloc>
+      <yloc>224</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git a/integration-tests/transforms/datasets/golden-plugin-catalog.csv 
b/integration-tests/transforms/datasets/golden-plugin-catalog.csv
new file mode 100644
index 0000000000..5fa4ec4366
--- /dev/null
+++ b/integration-tests/transforms/datasets/golden-plugin-catalog.csv
@@ -0,0 +1,5 @@
+plugin_id,plugin_type,property_xml_key,property_java_type
+PluginCatalog,transform,detailLevel,DetailLevel
+PluginCatalog,transform,includeActions,boolean
+PluginCatalog,transform,includeMetadataTypes,boolean
+PluginCatalog,transform,includeTransforms,boolean
diff --git a/integration-tests/transforms/main-0113-plugin-catalog.hwf 
b/integration-tests/transforms/main-0113-plugin-catalog.hwf
new file mode 100644
index 0000000000..ccb052114d
--- /dev/null
+++ b/integration-tests/transforms/main-0113-plugin-catalog.hwf
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+  <name>main-0113-plugin-catalog</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description/>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/16 08:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/16 08:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>START</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <intervalSeconds>0</intervalSeconds>
+      <intervalMinutes>60</intervalMinutes>
+      <hour>12</hour>
+      <minutes>0</minutes>
+      <weekDay>1</weekDay>
+      <DayOfMonth>1</DayOfMonth>
+      <parallel>N</parallel>
+      <xloc>96</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>0113-plugin-catalog UNIT</name>
+      <description/>
+      <type>RunPipelineTests</type>
+      <attributes/>
+      <test_names>
+        <test_name>
+          <name>0113-plugin-catalog UNIT</name>
+        </test_name>
+      </test_names>
+      <parallel>N</parallel>
+      <xloc>256</xloc>
+      <yloc>80</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>START</from>
+      <to>0113-plugin-catalog UNIT</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/transforms/metadata/dataset/golden-plugin-catalog.json 
b/integration-tests/transforms/metadata/dataset/golden-plugin-catalog.json
new file mode 100644
index 0000000000..8bdc2c512c
--- /dev/null
+++ b/integration-tests/transforms/metadata/dataset/golden-plugin-catalog.json
@@ -0,0 +1,39 @@
+{
+  "base_filename": "golden-plugin-catalog.csv",
+  "name": "golden-plugin-catalog",
+  "description": "Plugin Catalog output for the Plugin Catalog transform 
itself. Deliberately excludes name/description/category (locale dependent) and 
any other plugin (Marketplace churn).",
+  "dataset_fields": [
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 2,
+      "field_precision": -1,
+      "field_name": "plugin_id",
+      "field_format": ""
+    },
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 2,
+      "field_precision": -1,
+      "field_name": "plugin_type",
+      "field_format": ""
+    },
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 2,
+      "field_precision": -1,
+      "field_name": "property_xml_key",
+      "field_format": ""
+    },
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 2,
+      "field_precision": -1,
+      "field_name": "property_java_type",
+      "field_format": ""
+    }
+  ]
+}
diff --git 
a/integration-tests/transforms/metadata/unit-test/0113-plugin-catalog UNIT.json 
b/integration-tests/transforms/metadata/unit-test/0113-plugin-catalog UNIT.json
new file mode 100644
index 0000000000..c663fe46d9
--- /dev/null
+++ b/integration-tests/transforms/metadata/unit-test/0113-plugin-catalog 
UNIT.json     
@@ -0,0 +1,43 @@
+{
+  "variableValues": [],
+  "database_replacements": [],
+  "autoOpening": false,
+  "basePath": "",
+  "golden_data_sets": [
+    {
+      "field_mappings": [
+        {
+          "transform_field": "plugin_id",
+          "data_set_field": "plugin_id"
+        },
+        {
+          "transform_field": "plugin_type",
+          "data_set_field": "plugin_type"
+        },
+        {
+          "transform_field": "property_xml_key",
+          "data_set_field": "property_xml_key"
+        },
+        {
+          "transform_field": "property_java_type",
+          "data_set_field": "property_java_type"
+        }
+      ],
+      "field_order": [
+        "plugin_id",
+        "plugin_type",
+        "property_xml_key",
+        "property_java_type"
+      ],
+      "transform_name": "OUTPUT",
+      "data_set_name": "golden-plugin-catalog"
+    }
+  ],
+  "input_data_sets": [],
+  "name": "0113-plugin-catalog UNIT",
+  "description": "Verifies Plugin Catalog reads the live plugin registry, 
resolves the metadata class and reflects its @HopMetadataProperty fields, using 
the transform's own registration as the fixture so other plugins cannot affect 
the result",
+  "trans_test_tweaks": [],
+  "persist_filename": "",
+  "pipeline_filename": "./0113-plugin-catalog.hpl",
+  "test_type": "UNIT_TEST"
+}
diff --git a/plugins/transforms/plugincatalog/pom.xml 
b/plugins/transforms/plugincatalog/pom.xml
new file mode 100644
index 0000000000..099b9b83dd
--- /dev/null
+++ b/plugins/transforms/plugincatalog/pom.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.hop</groupId>
+        <artifactId>hop-plugins-transforms</artifactId>
+        <version>2.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>hop-transform-plugincatalog</artifactId>
+    <packaging>jar</packaging>
+    <name>Hop Plugins Transforms Plugin Catalog</name>
+
+</project>
diff --git a/plugins/transforms/plugincatalog/src/assembly/assembly.xml 
b/plugins/transforms/plugincatalog/src/assembly/assembly.xml
new file mode 100644
index 0000000000..7df283f65c
--- /dev/null
+++ b/plugins/transforms/plugincatalog/src/assembly/assembly.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 
http://maven.apache.org/xsd/assembly-2.2.0.xsd";>
+    <id>hop-transform-plugincatalog</id>
+    <formats>
+        <format>zip</format>
+    </formats>
+    <baseDirectory>.</baseDirectory>
+    <files>
+        <file>
+            <source>${project.basedir}/src/main/resources/version.xml</source>
+            <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+            <filtered>true</filtered>
+        </file>
+    </files>
+    <fileSets>
+        <fileSet>
+            <directory>${project.basedir}/src/main/samples</directory>
+            <outputDirectory>config/projects/samples/</outputDirectory>
+        </fileSet>
+    </fileSets>
+    <componentDescriptors>
+        
<componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+    </componentDescriptors>
+</assembly>
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/DetailLevel.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/DetailLevel.java
new file mode 100644
index 0000000000..20480eefc4
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/DetailLevel.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+/** Output granularity for the Plugin Catalog transform. */
+public enum DetailLevel {
+  /** One row per plugin; properties are emitted as a JSON array column. */
+  PER_PLUGIN,
+  /** One row per plugin property; plugin-level columns are repeated. */
+  PER_PROPERTY;
+
+  public static DetailLevel fromString(String value) {
+    if (value == null) {
+      return PER_PLUGIN;
+    }
+    for (DetailLevel level : values()) {
+      if (level.name().equalsIgnoreCase(value.trim())) {
+        return level;
+      }
+    }
+    return PER_PLUGIN;
+  }
+}
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalog.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalog.java
new file mode 100644
index 0000000000..4d8a62d66c
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalog.java
@@ -0,0 +1,113 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/**
+ * Plugin Catalog transform - a row-generating source that reflects the live 
Hop plugin registry
+ * into structured rows describing every transform, action and metadata type.
+ */
+public class PluginCatalog extends BaseTransform<PluginCatalogMeta, 
PluginCatalogData> {
+
+  public PluginCatalog(
+      TransformMeta transformMeta,
+      PluginCatalogMeta meta,
+      PluginCatalogData data,
+      int copyNr,
+      PipelineMeta pipelineMeta,
+      Pipeline pipeline) {
+    super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+  }
+
+  @Override
+  public boolean processRow() throws HopException {
+    data.outputRowMeta = new RowMeta();
+    meta.getFields(data.outputRowMeta, getTransformName(), null, null, this, 
getMetadataProvider());
+
+    PluginCatalogReader reader = new PluginCatalogReader();
+    List<PluginRecord> plugins =
+        reader.readAll(
+            meta.isIncludeTransforms(),
+            meta.isIncludeActions(),
+            meta.isIncludeMetadataTypes(),
+            (message, error) ->
+                logDetailed(error == null ? message : message + ": " + 
error.getMessage()));
+
+    long rowCount = 0;
+    for (PluginRecord plugin : plugins) {
+      if (meta.getDetailLevel() == DetailLevel.PER_PROPERTY) {
+        rowCount += emitPerProperty(plugin);
+      } else {
+        emitPerPlugin(plugin);
+        rowCount++;
+      }
+    }
+
+    logBasic("Plugin Catalog emitted " + rowCount + " row(s) for " + 
plugins.size() + " plugin(s)");
+    setOutputDone();
+    return false;
+  }
+
+  private void emitPerPlugin(PluginRecord plugin) throws HopException {
+    Object[] row = baseRow(plugin);
+    row[PluginCatalogMeta.BASE_FIELD_COUNT] =
+        PluginCatalogReader.propertiesToJson(plugin.properties);
+    putRow(data.outputRowMeta, row);
+  }
+
+  private long emitPerProperty(PluginRecord plugin) throws HopException {
+    if (plugin.properties.isEmpty()) {
+      // Still surface the plugin itself, with empty property columns.
+      Object[] row = baseRow(plugin);
+      putRow(data.outputRowMeta, row);
+      return 1;
+    }
+    for (PropertyRecord property : plugin.properties) {
+      Object[] row = baseRow(plugin);
+      int i = PluginCatalogMeta.BASE_FIELD_COUNT;
+      row[i] = property.field();
+      row[i + 1] = property.xmlKey();
+      row[i + 2] = property.javaType();
+      row[i + 3] = property.password();
+      row[i + 4] = property.group();
+      row[i + 5] = property.groupKey();
+      putRow(data.outputRowMeta, row);
+    }
+    return plugin.properties.size();
+  }
+
+  private Object[] baseRow(PluginRecord plugin) {
+    Object[] row = new Object[data.outputRowMeta.size()];
+    row[0] = plugin.pluginId;
+    row[1] = plugin.pluginType;
+    row[2] = plugin.name;
+    row[3] = plugin.description;
+    row[4] = plugin.category;
+    row[5] = plugin.keywords;
+    row[6] = plugin.className;
+    row[7] = plugin.englishAliases;
+    row[8] = plugin.locale;
+    return row;
+  }
+}
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogData.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogData.java
new file mode 100644
index 0000000000..4bc588f168
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogData.java
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+
+/** Runtime data for the Plugin Catalog transform. */
+public class PluginCatalogData extends BaseTransformData implements 
ITransformData {
+
+  public IRowMeta outputRowMeta;
+
+  public PluginCatalogData() {
+    super();
+  }
+}
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogDialog.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogDialog.java
new file mode 100644
index 0000000000..8a9087af13
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogDialog.java
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.widgets.Shell;
+
+public class PluginCatalogDialog extends BaseTransformDialog {
+
+  private static final Class<?> PKG = PluginCatalogMeta.class;
+
+  private final PluginCatalogMeta input;
+  private GuiCompositeWidgets widgets;
+
+  public PluginCatalogDialog(
+      Shell parent,
+      IVariables variables,
+      PluginCatalogMeta transformMeta,
+      PipelineMeta pipelineMeta) {
+    super(parent, variables, transformMeta, pipelineMeta);
+    input = transformMeta;
+  }
+
+  @Override
+  public String open() {
+    createShell(BaseMessages.getString(PKG, 
"PluginCatalogDialog.Shell.Title"));
+    buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+    changed = input.hasChanged();
+
+    widgets =
+        GuiCompositeWidgets.addScrolledComposite(
+            shell,
+            variables,
+            wTransformName,
+            wOk,
+            PluginCatalogMeta.GUI_PLUGIN_ELEMENT_PARENT_ID,
+            input);
+
+    focusTransformName();
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+    return transformName;
+  }
+
+  private void cancel() {
+    transformName = null;
+    input.setChanged(changed);
+    dispose();
+  }
+
+  private void ok() {
+    if (Utils.isEmpty(wTransformName.getText())) {
+      return;
+    }
+    widgets.getWidgetsContents(input, 
PluginCatalogMeta.GUI_PLUGIN_ELEMENT_PARENT_ID);
+    transformName = wTransformName.getText();
+    input.setChanged();
+    dispose();
+  }
+}
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogMeta.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogMeta.java
new file mode 100644
index 0000000000..a435727015
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogMeta.java
@@ -0,0 +1,234 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.value.ValueMetaBoolean;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/**
+ * Plugin Catalog transform metadata.
+ *
+ * <p>Emits one row per plugin (or per plugin property) by reflecting over the 
live Hop {@link
+ * org.apache.hop.core.plugins.PluginRegistry}. Labels are resolved to 
human-readable text; output
+ * is never stale against the running Hop version or any installed third-party 
plugins.
+ */
+@Getter
+@Setter
+@Transform(
+    id = "PluginCatalog",
+    image = "plugincatalog.svg",
+    name = "i18n::PluginCatalog.Name",
+    description = "i18n::PluginCatalog.Description",
+    categoryDescription = 
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Input",
+    documentationUrl = "/pipeline/transforms/plugincatalog.html",
+    keywords = "i18n::PluginCatalog.Keywords")
+@GuiPlugin
+public class PluginCatalogMeta extends BaseTransformMeta<PluginCatalog, 
PluginCatalogData> {
+  private static final Class<?> PKG = PluginCatalogMeta.class;
+  public static final String GUI_PLUGIN_ELEMENT_PARENT_ID = 
"PLUGIN_CATALOG_DIALOG_OPTIONS";
+  private static final String GROUP_OPTIONS = "Options";
+
+  // Output field names shared by both detail levels.
+  public static final String FIELD_PLUGIN_ID = "plugin_id";
+  public static final String FIELD_PLUGIN_TYPE = "plugin_type";
+  public static final String FIELD_NAME = "name";
+  public static final String FIELD_DESCRIPTION = "description";
+  public static final String FIELD_CATEGORY = "category";
+  public static final String FIELD_KEYWORDS = "keywords";
+  public static final String FIELD_CLASS_NAME = "class_name";
+  public static final String FIELD_ENGLISH_ALIASES = "english_aliases";
+  public static final String FIELD_LOCALE = "locale";
+
+  /** Number of plugin-level columns emitted before the detail-level columns. 
*/
+  public static final int BASE_FIELD_COUNT = 9;
+
+  // PER_PLUGIN extra.
+  public static final String FIELD_METADATA_FIELDS = "metadata_fields";
+  // PER_PROPERTY extras.
+  public static final String FIELD_PROPERTY_FIELD = "property_field";
+  public static final String FIELD_PROPERTY_XML_KEY = "property_xml_key";
+  public static final String FIELD_PROPERTY_JAVA_TYPE = "property_java_type";
+  public static final String FIELD_PROPERTY_PASSWORD = "property_password";
+  public static final String FIELD_PROPERTY_GROUP = "property_group";
+  public static final String FIELD_PROPERTY_GROUP_KEY = "property_group_key";
+
+  @GuiWidgetElement(
+      order = "0100",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::PluginCatalog.includeTransforms.Label",
+      toolTip = "i18n::PluginCatalog.includeTransforms.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_OPTIONS)
+  @HopMetadataProperty(key = "includeTransforms", injectionKey = 
"INCLUDE_TRANSFORMS")
+  private boolean includeTransforms = true;
+
+  @GuiWidgetElement(
+      order = "0200",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::PluginCatalog.includeActions.Label",
+      toolTip = "i18n::PluginCatalog.includeActions.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_OPTIONS)
+  @HopMetadataProperty(key = "includeActions", injectionKey = 
"INCLUDE_ACTIONS")
+  private boolean includeActions = true;
+
+  @GuiWidgetElement(
+      order = "0300",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::PluginCatalog.includeMetadataTypes.Label",
+      toolTip = "i18n::PluginCatalog.includeMetadataTypes.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_OPTIONS)
+  @HopMetadataProperty(key = "includeMetadataTypes", injectionKey = 
"INCLUDE_METADATA_TYPES")
+  private boolean includeMetadataTypes = true;
+
+  @GuiWidgetElement(
+      order = "0400",
+      type = GuiElementType.COMBO,
+      label = "i18n::PluginCatalog.detailLevel.Label",
+      toolTip = "i18n::PluginCatalog.detailLevel.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = GROUP_OPTIONS)
+  @HopMetadataProperty(key = "detailLevel", injectionKey = "DETAIL_LEVEL")
+  private DetailLevel detailLevel = DetailLevel.PER_PLUGIN;
+
+  public PluginCatalogMeta() {
+    super();
+  }
+
+  @Override
+  public void setDefault() {
+    includeTransforms = true;
+    includeActions = true;
+    includeMetadataTypes = true;
+    detailLevel = DetailLevel.PER_PLUGIN;
+  }
+
+  @Override
+  public void getFields(
+      IRowMeta row,
+      String origin,
+      IRowMeta[] info,
+      TransformMeta nextTransform,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider)
+      throws HopTransformException {
+    try {
+      row.clear();
+      addString(row, FIELD_PLUGIN_ID, origin);
+      addString(row, FIELD_PLUGIN_TYPE, origin);
+      addString(row, FIELD_NAME, origin);
+      addString(row, FIELD_DESCRIPTION, origin);
+      addString(row, FIELD_CATEGORY, origin);
+      addString(row, FIELD_KEYWORDS, origin);
+      addString(row, FIELD_CLASS_NAME, origin);
+      addString(row, FIELD_ENGLISH_ALIASES, origin);
+      addString(row, FIELD_LOCALE, origin);
+
+      if (detailLevel == DetailLevel.PER_PROPERTY) {
+        addString(row, FIELD_PROPERTY_FIELD, origin);
+        addString(row, FIELD_PROPERTY_XML_KEY, origin);
+        addString(row, FIELD_PROPERTY_JAVA_TYPE, origin);
+        addBoolean(row, FIELD_PROPERTY_PASSWORD, origin);
+        addString(row, FIELD_PROPERTY_GROUP, origin);
+        addString(row, FIELD_PROPERTY_GROUP_KEY, origin);
+      } else {
+        addString(row, FIELD_METADATA_FIELDS, origin);
+      }
+    } catch (Exception e) {
+      throw new HopTransformException("Error creating Plugin Catalog output 
fields", e);
+    }
+  }
+
+  @Override
+  public void check(
+      List<ICheckResult> remarks,
+      PipelineMeta pipelineMeta,
+      TransformMeta transformMeta,
+      IRowMeta prev,
+      String[] input,
+      String[] output,
+      IRowMeta info,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider) {
+    if (prev != null && !prev.isEmpty()) {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"PluginCatalogMeta.CheckResult.NoInputStreamsError"),
+              transformMeta));
+    } else {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_OK,
+              BaseMessages.getString(PKG, 
"PluginCatalogMeta.CheckResult.NoInputStreamOk"),
+              transformMeta));
+    }
+  }
+
+  @Override
+  public ITransformIOMeta getTransformIOMeta() {
+    return new TransformIOMeta(false, true, false, false, false, false);
+  }
+
+  @Override
+  public boolean consumesMainInput() {
+    return false;
+  }
+
+  @Override
+  public boolean canStartWithoutInput() {
+    return true;
+  }
+
+  private static void addString(IRowMeta row, String name, String origin) {
+    ValueMetaString field = new ValueMetaString(name);
+    field.setOrigin(origin);
+    row.addValueMeta(field);
+  }
+
+  private static void addBoolean(IRowMeta row, String name, String origin) {
+    ValueMetaBoolean field = new ValueMetaBoolean(name);
+    field.setOrigin(origin);
+    row.addValueMeta(field);
+  }
+}
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReader.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReader.java
new file mode 100644
index 0000000000..0e82049c2a
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReader.java
@@ -0,0 +1,309 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import org.apache.hop.core.plugins.ActionPluginType;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.i18n.LanguageChoice;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.plugin.MetadataPluginType;
+
+/**
+ * Reads the live {@link PluginRegistry} and reflects over each plugin's 
metadata class to expose a
+ * structured, never-stale catalog of transforms, actions and metadata types.
+ *
+ * <p>Field extraction is split out as a pure static method so it can be 
unit-tested against fixture
+ * classes without bootstrapping a Hop plugin registry.
+ */
+public class PluginCatalogReader {
+
+  /** Maximum nesting depth when descending into complex {@code 
@HopMetadataProperty} groups. */
+  private static final int MAX_NESTING_DEPTH = 1;
+
+  private final PluginRegistry registry;
+
+  public PluginCatalogReader() {
+    this(PluginRegistry.getInstance());
+  }
+
+  public PluginCatalogReader(PluginRegistry registry) {
+    this.registry = registry;
+  }
+
+  /**
+   * Enumerate the requested plugin families.
+   *
+   * @param warnings optional sink for non-fatal problems (e.g. a metadata 
class that fails to
+   *     load); may be {@code null}
+   */
+  public List<PluginRecord> readAll(
+      boolean includeTransforms,
+      boolean includeActions,
+      boolean includeMetadataTypes,
+      BiConsumer<String, Throwable> warnings) {
+    List<PluginRecord> out = new ArrayList<>();
+    if (includeTransforms) {
+      addFamily(registry.getPlugins(TransformPluginType.class), "transform", 
out, warnings);
+    }
+    if (includeActions) {
+      addFamily(registry.getPlugins(ActionPluginType.class), "action", out, 
warnings);
+    }
+    if (includeMetadataTypes) {
+      addFamily(registry.getPlugins(MetadataPluginType.class), "metadata", 
out, warnings);
+    }
+    // Labels are locale-resolved, so record which locale produced them.
+    String localeTag = currentLocaleTag();
+    for (PluginRecord record : out) {
+      record.locale = localeTag;
+    }
+    return out;
+  }
+
+  private void addFamily(
+      List<IPlugin> plugins,
+      String pluginType,
+      List<PluginRecord> out,
+      BiConsumer<String, Throwable> warnings) {
+    if (plugins == null) {
+      return;
+    }
+    for (IPlugin plugin : plugins) {
+      String id = firstId(plugin);
+      if (id.isEmpty()) {
+        // Skip anonymous/unregistered plugins rather than emit a junk row 
with no id.
+        if (warnings != null) {
+          warnings.accept("Skipping " + pluginType + " plugin with no id", 
null);
+        }
+        continue;
+      }
+      PluginRecord record = new PluginRecord();
+      record.pluginId = id;
+      record.pluginType = pluginType;
+      record.name = clean(plugin.getName());
+      record.description = clean(plugin.getDescription());
+      record.category = clean(plugin.getCategory());
+      record.keywords = joinKeywords(plugin.getKeywords());
+      record.englishAliases = joinKeywords(plugin.getEnglishKeywords());
+      String className = resolveClassName(plugin);
+      record.className = className == null ? "" : className;
+      if (className != null) {
+        try {
+          ClassLoader classLoader = registry.getClassLoader(plugin);
+          Class<?> clazz = classLoader.loadClass(className);
+          record.properties = extractProperties(clazz);
+        } catch (Exception | LinkageError e) {
+          if (warnings != null) {
+            warnings.accept("Could not reflect properties for plugin '" + 
record.pluginId + "'", e);
+          }
+        }
+      }
+      out.add(record);
+    }
+  }
+
+  /**
+   * Extract every {@code @HopMetadataProperty} field declared on {@code 
clazz} (including inherited
+   * fields), descending one level into complex property groups.
+   */
+  public static List<PropertyRecord> extractProperties(Class<?> clazz) {
+    List<PropertyRecord> out = new ArrayList<>();
+    collect(clazz, "", out, 0);
+    return out;
+  }
+
+  private static void collect(Class<?> clazz, String group, 
List<PropertyRecord> out, int depth) {
+    if (clazz == null) {
+      return;
+    }
+    for (Class<?> c = clazz; c != null && c != Object.class; c = 
c.getSuperclass()) {
+      for (Field field : c.getDeclaredFields()) {
+        HopMetadataProperty annotation = 
field.getAnnotation(HopMetadataProperty.class);
+        if (annotation == null) {
+          continue;
+        }
+        if (annotation.isExcludedFromSerialization()) {
+          continue;
+        }
+        String xmlKey = annotation.key().isEmpty() ? field.getName() : 
annotation.key();
+        out.add(
+            new PropertyRecord(
+                field.getName(),
+                xmlKey,
+                field.getType().getSimpleName(),
+                annotation.password(),
+                group,
+                annotation.groupKey()));
+        if (depth < MAX_NESTING_DEPTH) {
+          Class<?> nested = complexHopType(field, annotation);
+          if (nested != null) {
+            collect(nested, xmlKey, out, depth + 1);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * If {@code field} is a value class (or a collection of one) that itself 
carries
+   * {@code @HopMetadataProperty} fields, return that class; otherwise {@code 
null}.
+   *
+   * <p>Deliberately not restricted to {@code org.apache.hop}: a third-party 
or Marketplace plugin
+   * declaring its own group class is exactly the case this catalog exists to 
describe.
+   */
+  private static Class<?> complexHopType(Field field, HopMetadataProperty 
annotation) {
+    Class<?> candidate;
+    if (Collection.class.isAssignableFrom(field.getType())) {
+      // An annotated raw List carries its element type on the annotation, as 
XmlMetadataUtil reads
+      // it.
+      candidate =
+          annotation.listItemClass() != Object.class
+              ? annotation.listItemClass()
+              : collectionElementType(field);
+    } else {
+      candidate = field.getType();
+    }
+    if (candidate == null
+        || candidate.isEnum()
+        || candidate.isPrimitive()
+        || candidate.isArray()
+        || isPlatformClass(candidate)) {
+      return null;
+    }
+    for (Class<?> c = candidate; c != null && c != Object.class; c = 
c.getSuperclass()) {
+      for (Field f : c.getDeclaredFields()) {
+        if (f.isAnnotationPresent(HopMetadataProperty.class)) {
+          return candidate;
+        }
+      }
+    }
+    return null;
+  }
+
+  /** JDK types can never carry Hop annotations, so skip the hierarchy walk 
for them. */
+  private static boolean isPlatformClass(Class<?> clazz) {
+    String name = clazz.getName();
+    return name.startsWith("java.") || name.startsWith("javax.") || 
name.startsWith("jakarta.");
+  }
+
+  private static Class<?> collectionElementType(Field field) {
+    Type generic = field.getGenericType();
+    if (generic instanceof ParameterizedType parameterized) {
+      Type[] args = parameterized.getActualTypeArguments();
+      if (args.length == 1 && args[0] instanceof Class<?> elementType) {
+        return elementType;
+      }
+    }
+    return null;
+  }
+
+  private String resolveClassName(IPlugin plugin) {
+    Map<Class<?>, String> classMap = plugin.getClassMap();
+    if (classMap == null || classMap.isEmpty()) {
+      return null;
+    }
+    String className = plugin.getMainType() == null ? null : 
classMap.get(plugin.getMainType());
+    if (className == null) {
+      className = classMap.values().iterator().next();
+    }
+    return className;
+  }
+
+  /** The Hop locale that plugin labels were resolved against, as a BCP-47 
tag. */
+  static String currentLocaleTag() {
+    Locale locale;
+    try {
+      locale = LanguageChoice.getInstance().getDefaultLocale();
+    } catch (Exception | LinkageError e) {
+      locale = Locale.getDefault();
+    }
+    return locale == null ? "" : locale.toLanguageTag();
+  }
+
+  private static String firstId(IPlugin plugin) {
+    String[] ids = plugin.getIds();
+    return (ids != null && ids.length > 0) ? ids[0] : "";
+  }
+
+  private static String joinKeywords(String[] keywords) {
+    return keywords == null ? "" : String.join(",", keywords);
+  }
+
+  private static String clean(String value) {
+    return value == null ? "" : value;
+  }
+
+  /** Serialize a property list to a compact JSON array string 
(dependency-free). */
+  public static String propertiesToJson(List<PropertyRecord> properties) {
+    StringBuilder sb = new StringBuilder("[");
+    for (int i = 0; i < properties.size(); i++) {
+      PropertyRecord p = properties.get(i);
+      if (i > 0) {
+        sb.append(',');
+      }
+      sb.append("{\"field\":")
+          .append(quote(p.field()))
+          .append(",\"xml_key\":")
+          .append(quote(p.xmlKey()))
+          .append(",\"java_type\":")
+          .append(quote(p.javaType()))
+          .append(",\"password\":")
+          .append(p.password())
+          .append(",\"group\":")
+          .append(quote(p.group()))
+          .append(",\"group_key\":")
+          .append(quote(p.groupKey()))
+          .append('}');
+    }
+    return sb.append(']').toString();
+  }
+
+  private static String quote(String value) {
+    if (value == null) {
+      return "null";
+    }
+    StringBuilder sb = new StringBuilder("\"");
+    for (int i = 0; i < value.length(); i++) {
+      char c = value.charAt(i);
+      switch (c) {
+        case '"' -> sb.append("\\\"");
+        case '\\' -> sb.append("\\\\");
+        case '\n' -> sb.append("\\n");
+        case '\r' -> sb.append("\\r");
+        case '\t' -> sb.append("\\t");
+        default -> {
+          if (c < 0x20) {
+            sb.append(String.format("\\u%04x", (int) c));
+          } else {
+            sb.append(c);
+          }
+        }
+      }
+    }
+    return sb.append('"').toString();
+  }
+}
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginRecord.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginRecord.java
new file mode 100644
index 0000000000..52a94a773a
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginRecord.java
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** One catalogued plugin (transform, action or metadata type) with its 
resolved labels. */
+public class PluginRecord {
+  public String pluginId = "";
+
+  /** {@code transform}, {@code action} or {@code metadata}. */
+  public String pluginType = "";
+
+  public String name = "";
+  public String description = "";
+  public String category = "";
+  public String keywords = "";
+  public String className = "";
+
+  /** Locale-independent search aliases (English name, category and keywords). 
*/
+  public String englishAliases = "";
+
+  /** BCP-47 tag of the Hop locale the labels above were resolved against. */
+  public String locale = "";
+
+  public List<PropertyRecord> properties = new ArrayList<>();
+}
diff --git 
a/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PropertyRecord.java
 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PropertyRecord.java
new file mode 100644
index 0000000000..fce8a9c050
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/java/org/apache/hop/pipeline/transforms/plugincatalog/PropertyRecord.java
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+/**
+ * A single {@code @HopMetadataProperty} field discovered on a plugin's 
metadata class.
+ *
+ * @param field the Java field name
+ * @param xmlKey the serialization key ({@code @HopMetadataProperty.key()}, or 
the field name)
+ * @param javaType the simple name of the field's Java type
+ * @param password whether the property holds a sensitive value
+ * @param group the parent property key when this field belongs to a nested 
group, otherwise empty
+ * @param groupKey the {@code @HopMetadataProperty.groupKey()} wrapper element 
a list or map is
+ *     serialized under, otherwise empty. Distinct from {@code group}, which 
is nesting.
+ */
+public record PropertyRecord(
+    String field,
+    String xmlKey,
+    String javaType,
+    boolean password,
+    String group,
+    String groupKey) {}
diff --git 
a/plugins/transforms/plugincatalog/src/main/resources/org/apache/hop/pipeline/transforms/plugincatalog/messages/messages_en_US.properties
 
b/plugins/transforms/plugincatalog/src/main/resources/org/apache/hop/pipeline/transforms/plugincatalog/messages/messages_en_US.properties
new file mode 100644
index 0000000000..d430749a6d
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/resources/org/apache/hop/pipeline/transforms/plugincatalog/messages/messages_en_US.properties
@@ -0,0 +1,36 @@
+#
+# 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.
+#
+PluginCatalogDialog.Shell.Title=Plugin Catalog
+
+PluginCatalog.Name=Plugin Catalog
+PluginCatalog.Description=Reflect the live Hop plugin registry into rows 
(transforms, actions, metadata types) with resolved labels and property schemas
+PluginCatalog.Keywords=plugin,catalog,registry,transform,action,metadata,reflection,reference,RAG,documentation
+
+PluginCatalog.includeTransforms.Label=Include transforms
+PluginCatalog.includeTransforms.Tooltip=Emit one entry per registered 
transform plugin
+
+PluginCatalog.includeActions.Label=Include actions
+PluginCatalog.includeActions.Tooltip=Emit one entry per registered workflow 
action plugin
+
+PluginCatalog.includeMetadataTypes.Label=Include metadata types
+PluginCatalog.includeMetadataTypes.Tooltip=Emit one entry per registered Hop 
metadata type (connections, run configurations, ...)
+
+PluginCatalog.detailLevel.Label=Detail level
+PluginCatalog.detailLevel.Tooltip=PER_PLUGIN: one row per plugin with 
properties as a JSON column. PER_PROPERTY: one row per property with plugin 
columns repeated.
+
+PluginCatalogMeta.CheckResult.NoInputStreamsError=This transform reads the 
plugin registry and does not consume input rows. Remove the incoming hop.
+PluginCatalogMeta.CheckResult.NoInputStreamOk=No input streams, which is 
correct for a transform that reads the plugin registry.
diff --git 
a/plugins/transforms/plugincatalog/src/main/resources/plugincatalog.svg 
b/plugins/transforms/plugincatalog/src/main/resources/plugincatalog.svg
new file mode 100644
index 0000000000..53ec8ed4b5
--- /dev/null
+++ b/plugins/transforms/plugincatalog/src/main/resources/plugincatalog.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" 
xmlns="http://www.w3.org/2000/svg";>
+  <rect x="3" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F" 
stroke-width="1.6"/>
+  <rect x="13" y="3" width="8" height="8" rx="1.5" stroke="#2A5D8F" 
stroke-width="1.6"/>
+  <rect x="3" y="13" width="8" height="8" rx="1.5" stroke="#2A5D8F" 
stroke-width="1.6"/>
+  <rect x="13" y="13" width="8" height="8" rx="1.5" stroke="#7BB661" 
stroke-width="1.6"/>
+  <line x1="7" y1="11" x2="7" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+  <line x1="17" y1="11" x2="17" y2="13" stroke="#2A5D8F" stroke-width="1.6"/>
+  <line x1="11" y1="7" x2="13" y2="7" stroke="#2A5D8F" stroke-width="1.6"/>
+  <line x1="11" y1="17" x2="13" y2="17" stroke="#2A5D8F" stroke-width="1.6"/>
+</svg>
diff --git a/plugins/transforms/plugincatalog/src/main/resources/version.xml 
b/plugins/transforms/plugincatalog/src/main/resources/version.xml
new file mode 100644
index 0000000000..6be576acae
--- /dev/null
+++ b/plugins/transforms/plugincatalog/src/main/resources/version.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+
+<version>${project.version}</version>
\ No newline at end of file
diff --git 
a/plugins/transforms/plugincatalog/src/main/samples/transforms/plugin-catalog-basic.hpl
 
b/plugins/transforms/plugincatalog/src/main/samples/transforms/plugin-catalog-basic.hpl
new file mode 100644
index 0000000000..7e6d9b3c06
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/main/samples/transforms/plugin-catalog-basic.hpl
@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+  <info>
+    <name>plugin-catalog-basic</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Dump the live Hop plugin registry (transforms, actions, 
metadata types) into rows using the Plugin Catalog transform. Run and preview 
the Dummy transform.</description>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2026/06/20 12:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/06/20 12:00:00.000</modified_date>
+  </info>
+  <notepads>
+    <notepad>
+      <note>Plugin Catalog sample: the Plugin Catalog transform needs no 
input. It reflects the running Hop installation's plugin registry and emits one 
row per plugin (PER_PLUGIN) with a metadata_fields JSON column, or one row per 
property (PER_PROPERTY). Switch detailLevel in the transform dialog and 
re-run.</note>
+      <xloc>32</xloc>
+      <yloc>32</yloc>
+      <width>520</width>
+      <height>80</height>
+      <fontname>Sans</fontname>
+      <fontsize>9</fontsize>
+      <fontbold>N</fontbold>
+      <fontitalic>N</fontitalic>
+      <fontcolorred>14</fontcolorred>
+      <fontcolorgreen>58</fontcolorgreen>
+      <fontcolorblue>90</fontcolorblue>
+      <backgroundcolorred>201</backgroundcolorred>
+      <backgroundcolorgreen>232</backgroundcolorgreen>
+      <backgroundcolorblue>251</backgroundcolorblue>
+      <bordercolorred>14</bordercolorred>
+      <bordercolorgreen>58</bordercolorgreen>
+      <bordercolorblue>90</bordercolorblue>
+    </notepad>
+  </notepads>
+  <order>
+    <hop>
+      <from>Plugin Catalog</from>
+      <to>Catalog</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>Plugin Catalog</name>
+    <type>PluginCatalog</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <includeTransforms>Y</includeTransforms>
+    <includeActions>Y</includeActions>
+    <includeMetadataTypes>Y</includeMetadataTypes>
+    <detailLevel>PER_PLUGIN</detailLevel>
+    <attributes/>
+    <GUI>
+      <xloc>176</xloc>
+      <yloc>176</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Catalog</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>368</xloc>
+      <yloc>176</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git 
a/plugins/transforms/plugincatalog/src/test/java/io/github/thirdparty/ThirdPartyMapping.java
 
b/plugins/transforms/plugincatalog/src/test/java/io/github/thirdparty/ThirdPartyMapping.java
new file mode 100644
index 0000000000..bc3ef8ab63
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/test/java/io/github/thirdparty/ThirdPartyMapping.java
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+package io.github.thirdparty;
+
+import org.apache.hop.metadata.api.HopMetadataProperty;
+
+/**
+ * A property group declared outside {@code org.apache.hop}, standing in for a 
third-party or
+ * Marketplace plugin. The catalog must descend into it like any other.
+ */
+public class ThirdPartyMapping {
+  @HopMetadataProperty(key = "source")
+  private String source;
+
+  @HopMetadataProperty(key = "target")
+  private String target;
+}
diff --git 
a/plugins/transforms/plugincatalog/src/test/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReaderTest.java
 
b/plugins/transforms/plugincatalog/src/test/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReaderTest.java
new file mode 100644
index 0000000000..a1be89a516
--- /dev/null
+++ 
b/plugins/transforms/plugincatalog/src/test/java/org/apache/hop/pipeline/transforms/plugincatalog/PluginCatalogReaderTest.java
@@ -0,0 +1,202 @@
+/*
+ * 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.
+ */
+package org.apache.hop.pipeline.transforms.plugincatalog;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.thirdparty.ThirdPartyMapping;
+import java.util.List;
+import java.util.Optional;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.junit.jupiter.api.Test;
+
+class PluginCatalogReaderTest {
+
+  /** Fixture nested group element. */
+  static class Mapping {
+    @HopMetadataProperty(key = "source")
+    private String source;
+
+    @HopMetadataProperty(key = "target")
+    private String target;
+  }
+
+  /** Fixture metadata class with a password, a default-keyed field, and a 
nested list group. */
+  static class SampleMeta {
+    @HopMetadataProperty(key = "message")
+    private String messageField;
+
+    @HopMetadataProperty(key = "secret", password = true)
+    private String secret;
+
+    // No key() -> xml key falls back to the field name.
+    @HopMetadataProperty private int batchSize;
+
+    @HopMetadataProperty(key = "mappings")
+    private List<Mapping> mappings;
+  }
+
+  static class ChildMeta extends SampleMeta {
+    @HopMetadataProperty(key = "extra")
+    private String extra;
+  }
+
+  /** Stands in for a plugin whose group class lives outside org.apache.hop. */
+  static class ThirdPartyMeta {
+    @HopMetadataProperty(key = "mappings")
+    private List<ThirdPartyMapping> mappings;
+  }
+
+  /** A raw list: the element type is only discoverable through 
listItemClass(). */
+  static class RawListMeta {
+    @HopMetadataProperty(key = "mappings", listItemClass = 
ThirdPartyMapping.class)
+    private List mappings;
+  }
+
+  /** Carries a groupKey wrapper and a property that is never serialized. */
+  static class GroupKeyMeta {
+    @HopMetadataProperty(key = "field", groupKey = "fields")
+    private List<Mapping> fields;
+
+    @HopMetadataProperty(key = "runtimeOnly", isExcludedFromSerialization = 
true)
+    private String runtimeOnly;
+  }
+
+  private static PropertyRecord byXmlKey(List<PropertyRecord> props, String 
key) {
+    Optional<PropertyRecord> found = props.stream().filter(p -> 
p.xmlKey().equals(key)).findFirst();
+    assertTrue(found.isPresent(), "expected property with xml_key=" + key);
+    return found.get();
+  }
+
+  @Test
+  void extractsAnnotatedFieldsWithKeyFallback() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(SampleMeta.class);
+
+    PropertyRecord message = byXmlKey(props, "message");
+    assertEquals("messageField", message.field());
+    assertEquals("String", message.javaType());
+    assertEquals("", message.group());
+    assertEquals(false, message.password());
+
+    // Field without key() uses the Java field name as xml key.
+    PropertyRecord batch = byXmlKey(props, "batchSize");
+    assertEquals("int", batch.javaType());
+  }
+
+  @Test
+  void detectsPasswordProperties() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(SampleMeta.class);
+    assertTrue(byXmlKey(props, "secret").password());
+  }
+
+  @Test
+  void descendsOneLevelIntoNestedGroups() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(SampleMeta.class);
+
+    // The list field itself is present...
+    assertNotNull(byXmlKey(props, "mappings"));
+    // ...and its element's properties are tagged with the parent key as their 
group.
+    PropertyRecord source = byXmlKey(props, "source");
+    assertEquals("mappings", source.group());
+    assertEquals("target", byXmlKey(props, "target").xmlKey());
+  }
+
+  @Test
+  void includesInheritedFields() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(ChildMeta.class);
+    assertNotNull(byXmlKey(props, "extra"));
+    assertNotNull(byXmlKey(props, "message"));
+  }
+
+  @Test
+  void serializesPropertiesToJson() {
+    List<PropertyRecord> props =
+        List.of(new PropertyRecord("secret", "secret", "String", true, "auth", 
"credentials"));
+    String json = PluginCatalogReader.propertiesToJson(props);
+    assertEquals(
+        
"[{\"field\":\"secret\",\"xml_key\":\"secret\",\"java_type\":\"String\","
+            + 
"\"password\":true,\"group\":\"auth\",\"group_key\":\"credentials\"}]",
+        json);
+  }
+
+  @Test
+  void jsonEscapesSpecialCharacters() {
+    List<PropertyRecord> props = List.of(new PropertyRecord("a\"b", "x", 
"String", false, "", ""));
+    String json = PluginCatalogReader.propertiesToJson(props);
+    assertTrue(json.contains("a\\\"b"), "quotes should be escaped: " + json);
+  }
+
+  @Test
+  void emptyPropertyListSerializesToEmptyArray() {
+    assertEquals("[]", PluginCatalogReader.propertiesToJson(List.of()));
+  }
+
+  @Test
+  void jsonQuoterIsNullSafe() {
+    // null fields must not blow up serialization.
+    String json =
+        PluginCatalogReader.propertiesToJson(
+            List.of(new PropertyRecord(null, "k", null, false, null, null)));
+    assertTrue(json.contains("\"field\":null"), json);
+    assertTrue(json.contains("\"java_type\":null"), json);
+  }
+
+  @Test
+  void reportsAResolvableLocaleTag() {
+    String tag = PluginCatalogReader.currentLocaleTag();
+    // Deliberately not asserting a specific locale: this must hold on any 
machine, and the point
+    // of the column is that consumers can tell which locale the labels came 
from.
+    assertNotNull(tag);
+    assertTrue(!tag.isBlank(), "locale tag should not be blank");
+    assertEquals(tag, java.util.Locale.forLanguageTag(tag).toLanguageTag());
+  }
+
+  @Test
+  void descendsIntoGroupTypesOutsideTheHopPackage() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(ThirdPartyMeta.class);
+    assertNotNull(byXmlKey(props, "mappings"));
+    assertEquals("mappings", byXmlKey(props, "source").group());
+    assertEquals("mappings", byXmlKey(props, "target").group());
+  }
+
+  @Test
+  void resolvesRawListElementTypeFromListItemClass() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(RawListMeta.class);
+    assertNotNull(byXmlKey(props, "source"));
+    assertNotNull(byXmlKey(props, "target"));
+  }
+
+  @Test
+  void recordsTheGroupKeyWrapper() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(GroupKeyMeta.class);
+    assertEquals("fields", byXmlKey(props, "field").groupKey());
+    // A plain property has no wrapper.
+    assertEquals(
+        "",
+        byXmlKey(PluginCatalogReader.extractProperties(SampleMeta.class), 
"message").groupKey());
+  }
+
+  @Test
+  void skipsPropertiesExcludedFromSerialization() {
+    List<PropertyRecord> props = 
PluginCatalogReader.extractProperties(GroupKeyMeta.class);
+    assertTrue(
+        props.stream().noneMatch(r -> "runtimeOnly".equals(r.xmlKey())),
+        "isExcludedFromSerialization properties must not be catalogued");
+  }
+}
diff --git a/plugins/transforms/pom.xml b/plugins/transforms/pom.xml
index 240b5e594d..82f4740e66 100644
--- a/plugins/transforms/pom.xml
+++ b/plugins/transforms/pom.xml
@@ -117,6 +117,7 @@
         <module>odata</module>
         <module>pgp</module>
         <module>pipelineexecutor</module>
+        <module>plugincatalog</module>
         <module>processfiles</module>
         <module>propertyinput</module>
         <module>propertyoutput</module>

Reply via email to