[Samples] Add an out-of-tree compiler plugin (#24896)

Add an out-of-tree compiler plugin example containing an MLIR dialect
and a pass, with CMake and Bazel build definitions and a transformation
test.

Document how to build, load, and activate the example, including the
matching revisions and ABI-affecting settings required by experimental
dynamic plugins.

Co-authored-by: Claude Code <noreply@anthropic.com>

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>

Co-authored-by: Claude Code <noreply@anthropic.com>
diff --git a/samples/compiler_plugins/iree_compiler_plugin.cmake b/samples/compiler_plugins/iree_compiler_plugin.cmake
index 3f4990a..fb97261 100644
--- a/samples/compiler_plugins/iree_compiler_plugin.cmake
+++ b/samples/compiler_plugins/iree_compiler_plugin.cmake
@@ -6,3 +6,4 @@
 
 add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/example samples/example)
 add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/simple_io_sample samples/simple_io_sample)
+add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/out_of_tree_example samples/out_of_tree_example)
diff --git a/samples/compiler_plugins/out_of_tree_example/BUILD.bazel b/samples/compiler_plugins/out_of_tree_example/BUILD.bazel
new file mode 100644
index 0000000..c9ed0a4
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/BUILD.bazel
@@ -0,0 +1,143 @@
+# Copyright 2026 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+load("@rules_cc//cc:defs.bzl", "cc_library")
+load("//build_tools/bazel:build_defs.oss.bzl", "iree_cmake_extra_content", "iree_gentbl_cc_library", "iree_td_library")
+load("//build_tools/bazel:renamed_link.bzl", "iree_compiler_register_experimental_dynamic_plugin")
+
+package(
+    default_visibility = ["//visibility:public"],
+    features = ["layering_check"],
+    licenses = ["notice"],  # Apache 2.0
+)
+
+cc_library(
+    name = "defs",
+    includes = ["src"],
+)
+
+iree_td_library(
+    name = "td_files",
+    srcs = [
+        "src/ootex/IR/OotexOps.td",
+        "src/ootex/Transforms/Passes.td",
+    ],
+    deps = [
+        "@llvm-project//mlir:OpBaseTdFiles",
+        "@llvm-project//mlir:PassBaseTdFiles",
+        "@llvm-project//mlir:SideEffectInterfacesTdFiles",
+    ],
+)
+
+iree_gentbl_cc_library(
+    name = "OotexOpsGen",
+    tbl_outs = [
+        (
+            ["--gen-dialect-decls"],
+            "src/ootex/IR/OotexOpsDialect.h.inc",
+        ),
+        (
+            ["--gen-dialect-defs"],
+            "src/ootex/IR/OotexOpsDialect.cpp.inc",
+        ),
+        (
+            ["--gen-op-decls"],
+            "src/ootex/IR/OotexOps.h.inc",
+        ),
+        (
+            ["--gen-op-defs"],
+            "src/ootex/IR/OotexOps.cpp.inc",
+        ),
+    ],
+    tblgen = "@llvm-project//mlir:mlir-tblgen",
+    td_file = "src/ootex/IR/OotexOps.td",
+    deps = [":td_files"],
+)
+
+iree_gentbl_cc_library(
+    name = "PassesGen",
+    tbl_outs = [
+        (
+            [
+                "--gen-pass-decls",
+                "-name=Ootex",
+            ],
+            "src/ootex/Transforms/Passes.h.inc",
+        ),
+    ],
+    tblgen = "@llvm-project//mlir:mlir-tblgen",
+    td_file = "src/ootex/Transforms/Passes.td",
+    deps = [":td_files"],
+)
+
+cc_library(
+    name = "IR",
+    srcs = [
+        "src/ootex/IR/OotexDialect.cpp",
+        "src/ootex/IR/OotexOps.cpp.inc",
+    ],
+    hdrs = [
+        "src/ootex/IR/OotexDialect.h",
+        "src/ootex/IR/OotexOps.h.inc",
+    ],
+    deps = [
+        ":OotexOpsGen",
+        ":defs",
+        "@llvm-project//mlir:IR",
+    ],
+)
+
+cc_library(
+    name = "Transforms",
+    srcs = ["src/ootex/Transforms/AnnotateMarkedFunctions.cpp"],
+    hdrs = [
+        "src/ootex/Transforms/Passes.h",
+        "src/ootex/Transforms/Passes.h.inc",
+    ],
+    deps = [
+        ":IR",
+        ":PassesGen",
+        ":defs",
+        "//compiler/src/iree/compiler/Dialect/Util/IR",
+        "@llvm-project//mlir:IR",
+        "@llvm-project//mlir:Pass",
+    ],
+)
+
+cc_library(
+    name = "registration",
+    srcs = ["src/PluginRegistration.cpp"],
+    deps = [
+        ":IR",
+        ":Transforms",
+        ":defs",
+        "//compiler/src/iree/compiler/PluginAPI",
+        "//compiler/src/iree/compiler/PluginAPI:PluginEntryPoint",
+        "@llvm-project//mlir:IR",
+        "@llvm-project//mlir:Pass",
+    ],
+)
+
+# Package the registration library and its dependencies as a loadable plugin.
+iree_cmake_extra_content(
+    content = """
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+""",
+    inline = True,
+)
+
+iree_compiler_register_experimental_dynamic_plugin(
+    compiler = "//lib:IREECompilerShared",
+    plugin_id = "ootex",
+    target = ":registration",
+)
+
+iree_cmake_extra_content(
+    content = """
+endif()
+""",
+    inline = True,
+)
diff --git a/samples/compiler_plugins/out_of_tree_example/CMakeLists.txt b/samples/compiler_plugins/out_of_tree_example/CMakeLists.txt
new file mode 100644
index 0000000..120d504
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/CMakeLists.txt
@@ -0,0 +1,104 @@
+################################################################################
+# Autogenerated by build_tools/bazel_to_cmake/bazel_to_cmake.py from           #
+# samples/compiler_plugins/out_of_tree_example/BUILD.bazel                     #
+#                                                                              #
+# Use iree_cmake_extra_content from iree/build_defs.oss.bzl to add arbitrary   #
+# CMake-only content.                                                          #
+#                                                                              #
+# To disable autogeneration for this file entirely, delete this header.        #
+################################################################################
+
+iree_add_all_subdirs()
+
+iree_cc_library(
+  NAME
+    defs
+  INCLUDES
+    "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>"
+    "$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/src>"
+  PUBLIC
+)
+
+iree_tablegen_library(
+  NAME
+    OotexOpsGen
+  TD_FILE
+    "src/ootex/IR/OotexOps.td"
+  OUTS
+    --gen-dialect-decls src/ootex/IR/OotexOpsDialect.h.inc
+    --gen-dialect-defs src/ootex/IR/OotexOpsDialect.cpp.inc
+    --gen-op-decls src/ootex/IR/OotexOps.h.inc
+    --gen-op-defs src/ootex/IR/OotexOps.cpp.inc
+)
+
+iree_tablegen_library(
+  NAME
+    PassesGen
+  TD_FILE
+    "src/ootex/Transforms/Passes.td"
+  OUTS
+    --gen-pass-decls -name=Ootex src/ootex/Transforms/Passes.h.inc
+)
+
+iree_cc_library(
+  NAME
+    IR
+  HDRS
+    "src/ootex/IR/OotexDialect.h"
+    "src/ootex/IR/OotexOps.h.inc"
+  SRCS
+    "src/ootex/IR/OotexDialect.cpp"
+    "src/ootex/IR/OotexOps.cpp.inc"
+  DEPS
+    ::OotexOpsGen
+    ::defs
+    MLIRIR
+  PUBLIC
+)
+
+iree_cc_library(
+  NAME
+    Transforms
+  HDRS
+    "src/ootex/Transforms/Passes.h"
+    "src/ootex/Transforms/Passes.h.inc"
+  SRCS
+    "src/ootex/Transforms/AnnotateMarkedFunctions.cpp"
+  DEPS
+    ::IR
+    ::PassesGen
+    ::defs
+    MLIRIR
+    MLIRPass
+    iree::compiler::Dialect::Util::IR
+  PUBLIC
+)
+
+iree_cc_library(
+  NAME
+    registration
+  SRCS
+    "src/PluginRegistration.cpp"
+  DEPS
+    ::IR
+    ::Transforms
+    ::defs
+    MLIRIR
+    MLIRPass
+    iree::compiler::PluginAPI
+    iree::compiler::PluginAPI::PluginEntryPoint
+  PUBLIC
+)
+
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+
+iree_compiler_register_dynamic_plugin(
+  PLUGIN_ID
+    ootex
+  TARGET
+    ::registration
+)
+
+endif()
+
+### BAZEL_TO_CMAKE_PRESERVES_ALL_CONTENT_BELOW_THIS_LINE ###
diff --git a/samples/compiler_plugins/out_of_tree_example/README.md b/samples/compiler_plugins/out_of_tree_example/README.md
new file mode 100644
index 0000000..56f5300
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/README.md
@@ -0,0 +1,86 @@
+# Out-of-tree compiler plugin example
+
+An experimental dynamic IREE compiler plugin with its own dialect and pass.
+The sample is built within IREE and demonstrates how to implement a plugin
+that could be maintained in another repository.
+
+```
+src/ootex/IR/OotexOps.td            the ootex dialect, one op: ootex.mark
+src/ootex/Transforms/Passes.td      the pass and its `tag` option
+src/ootex/Transforms/               AnnotateMarkedFunctions
+src/PluginRegistration.cpp          the plugin: dialect, pass, pipeline hook
+test/annotate.mlir                  loads the plugin into iree-compile
+```
+
+## What it demonstrates
+
+The pass erases `ootex.mark` operations directly inside a `util.func` and sets
+`ootex.tag` on that function to the pass's `tag` option. Marks nested in other
+operations' regions are not handled. This demonstrates how a plugin's dialect
+and preprocessing pass can annotate IREE operations.
+
+The compiler is given the plugin by path; the plugin reports its id:
+
+```sh
+iree-compile --iree-load-plugin=/path/to/libiree_compiler_plugin_ootex.so \
+             --iree-plugin=ootex --ootex-tag=hello \
+             --compile-to=preprocessing input.mlir
+```
+
+```mlir
+util.func private @_marked() attributes {..., ootex.tag = "hello"} {
+```
+
+For `test/annotate.mlir`, the tag lands on the private function because IREE's
+ABI pass has already moved the marked body there.
+
+`--ootex-tag` is an ordinary compiler flag: plugins load before `llvm::cl`
+parses.
+
+## Building it
+
+Run these commands from the IREE repository root. CMake includes this sample
+through its built-in plugin paths when `IREE_BUILD_SAMPLES=ON`.
+`bazel_to_cmake` generates `CMakeLists.txt` from `BUILD.bazel`.
+
+```sh
+# CMake
+cmake -S . -B build -DIREE_BUILD_SAMPLES=ON \
+  -DIREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS=ON -DIREE_ENABLE_THIN_ARCHIVES=OFF
+cmake --build build --target iree-compile iree_compiler_plugin_ootex
+
+# Bazel
+bazel build //tools:iree-compile \
+  //samples/compiler_plugins/out_of_tree_example:iree_compiler_plugin_ootex
+```
+
+The Bazel rule packages the registration library and its dependencies:
+
+```python
+load("//build_tools/bazel:renamed_link.bzl", "iree_compiler_register_experimental_dynamic_plugin")
+
+iree_compiler_register_experimental_dynamic_plugin(
+    plugin_id = "ootex",
+    target = ":registration",
+    compiler = "//lib:IREECompilerShared",
+)
+```
+
+## Build requirements
+
+Dynamic plugins have no stable API or ABI. Use the host compiler's exact IREE
+and LLVM/MLIR revisions, generated headers, toolchain, and ABI-affecting build
+settings. Match the architecture, C++ standard library and ABI options, RTTI,
+exceptions, assertions (`NDEBUG`), LLVM ABI-breaking checks, and sanitizer
+configuration. See the [build requirements and compatibility checks](../../../compiler/src/iree/compiler/PluginAPI/README.md#build-requirements)
+for details. The loader cannot verify all of these settings.
+
+Use `IREE_DEFINE_COMPILER_PLUGIN` to supply compatibility metadata automatically.
+Its version argument identifies the plugin's release, not API or ABI compatibility.
+Do not edit the generated hash or symbol prefix to make
+a plugin load; rebuild it against the matching compiler.
+
+CMake requires a host built with
+`IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS=ON`. Bazel exports compiler symbols
+without a separate gate. Both builds require tools linked to the shared compiler
+library and plugin archives built with position-independent code.
diff --git a/samples/compiler_plugins/out_of_tree_example/src/PluginRegistration.cpp b/samples/compiler_plugins/out_of_tree_example/src/PluginRegistration.cpp
new file mode 100644
index 0000000..b9bbc8c
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/src/PluginRegistration.cpp
@@ -0,0 +1,52 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/compiler/PluginAPI/Client.h"
+#include "iree/compiler/PluginAPI/PluginEntryPoint.h"
+#include "mlir/Pass/PassManager.h"
+#include "ootex/IR/OotexDialect.h"
+#include "ootex/Transforms/Passes.h"
+
+namespace {
+
+struct OotexOptions {
+  std::string tag = "marked";
+
+  void bindOptions(mlir::iree_compiler::OptionsBinder& binder) {
+    static llvm::cl::OptionCategory category("Ootex plugin");
+    binder.opt<std::string>(
+        "ootex-tag", tag,
+        llvm::cl::desc("Written onto marked functions as ootex.tag"),
+        llvm::cl::cat(category));
+  }
+};
+
+struct OotexSession
+    : public mlir::iree_compiler::PluginSession<OotexSession, OotexOptions> {
+  static void registerPasses() { ootex::registerOotexPasses(); }
+
+  void onRegisterDialects(mlir::DialectRegistry& registry) override {
+    registry.insert<ootex::OotexDialect>();
+  }
+
+  void extendPreprocessingPassPipeline(mlir::OpPassManager& pm) override {
+    ootex::AnnotateMarkedFunctionsOptions passOptions;
+    passOptions.tag = options.tag;
+    pm.addPass(ootex::createAnnotateMarkedFunctions(passOptions));
+  }
+};
+
+}  // namespace
+
+IREE_DEFINE_COMPILER_OPTION_FLAGS(OotexOptions);
+
+static bool registerOotexPlugin(
+    mlir::iree_compiler::PluginRegistrar* registrar) {
+  registrar->registerPlugin<OotexSession>("ootex");
+  return true;
+}
+
+IREE_DEFINE_COMPILER_PLUGIN(ootex, registerOotexPlugin, "0.1")
diff --git a/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexDialect.cpp b/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexDialect.cpp
new file mode 100644
index 0000000..f1615c3
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexDialect.cpp
@@ -0,0 +1,23 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "ootex/IR/OotexDialect.h"
+
+#include "ootex/IR/OotexOpsDialect.cpp.inc"
+
+#define GET_OP_CLASSES
+#include "ootex/IR/OotexOps.cpp.inc"
+
+namespace ootex {
+
+void OotexDialect::initialize() {
+  addOperations<
+#define GET_OP_LIST
+#include "ootex/IR/OotexOps.cpp.inc"
+      >();
+}
+
+}  // namespace ootex
diff --git a/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexDialect.h b/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexDialect.h
new file mode 100644
index 0000000..ef76268
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexDialect.h
@@ -0,0 +1,23 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#ifndef OOTEX_IR_OOTEXDIALECT_H_
+#define OOTEX_IR_OOTEXDIALECT_H_
+
+#include "mlir/IR/Builders.h"
+#include "mlir/IR/BuiltinOps.h"
+#include "mlir/IR/Dialect.h"
+#include "mlir/IR/ImplicitLocOpBuilder.h"
+#include "mlir/IR/OpDefinition.h"
+#include "mlir/IR/OpImplementation.h"
+
+// clang-format off
+#include "ootex/IR/OotexOpsDialect.h.inc"
+#define GET_OP_CLASSES
+#include "ootex/IR/OotexOps.h.inc"
+// clang-format on
+
+#endif  // OOTEX_IR_OOTEXDIALECT_H_
diff --git a/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexOps.td b/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexOps.td
new file mode 100644
index 0000000..b0314e0
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/src/ootex/IR/OotexOps.td
@@ -0,0 +1,30 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#ifndef OOTEX_OPS
+#define OOTEX_OPS
+
+include "mlir/IR/OpBase.td"
+include "mlir/Interfaces/SideEffectInterfaces.td"
+
+def Ootex_Dialect : Dialect {
+  let name = "ootex";
+  let cppNamespace = "::ootex";
+  let summary = [{The plugin's dialect.}];
+}
+
+class Ootex_Op<string mnemonic, list<Trait> traits = []> :
+    Op<Ootex_Dialect, mnemonic, traits> {
+}
+
+def Ootex_MarkOp : Ootex_Op<"mark", []> {
+  let summary = [{Marks the enclosing function for the plugin's pass.}];
+  let arguments = (ins);
+  let results = (outs);
+  let assemblyFormat = [{ attr-dict }];
+}
+
+#endif // OOTEX_OPS
diff --git a/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/AnnotateMarkedFunctions.cpp b/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/AnnotateMarkedFunctions.cpp
new file mode 100644
index 0000000..0fedb14
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/AnnotateMarkedFunctions.cpp
@@ -0,0 +1,41 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#include "iree/compiler/Dialect/Util/IR/UtilOps.h"
+#include "ootex/IR/OotexDialect.h"
+#include "ootex/Transforms/Passes.h"
+
+namespace ootex {
+
+#define GEN_PASS_DEF_ANNOTATEMARKEDFUNCTIONS
+#include "ootex/Transforms/Passes.h.inc"
+
+namespace {
+
+struct AnnotateMarkedFunctionsPass
+    : public impl::AnnotateMarkedFunctionsBase<AnnotateMarkedFunctionsPass> {
+  using Base::Base;
+
+  void runOnOperation() override {
+    mlir::ModuleOp module = getOperation();
+    mlir::StringAttr tagAttr =
+        mlir::StringAttr::get(module.getContext(), this->tag);
+
+    module.walk([&](mlir::iree_compiler::IREE::Util::FuncOp funcOp) {
+      llvm::SmallVector<MarkOp> marks(funcOp.getOps<MarkOp>());
+      if (marks.empty()) {
+        return;
+      }
+      funcOp->setAttr("ootex.tag", tagAttr);
+      for (MarkOp mark : marks) {
+        mark.erase();
+      }
+    });
+  }
+};
+
+}  // namespace
+}  // namespace ootex
diff --git a/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/Passes.h b/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/Passes.h
new file mode 100644
index 0000000..9853d9e
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/Passes.h
@@ -0,0 +1,22 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#ifndef OOTEX_TRANSFORMS_PASSES_H_
+#define OOTEX_TRANSFORMS_PASSES_H_
+
+#include "mlir/Pass/Pass.h"
+
+namespace ootex {
+
+#define GEN_PASS_DECL
+#include "ootex/Transforms/Passes.h.inc"
+
+#define GEN_PASS_REGISTRATION
+#include "ootex/Transforms/Passes.h.inc"
+
+}  // namespace ootex
+
+#endif  // OOTEX_TRANSFORMS_PASSES_H_
diff --git a/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/Passes.td b/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/Passes.td
new file mode 100644
index 0000000..ed9bf2b
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/src/ootex/Transforms/Passes.td
@@ -0,0 +1,21 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+#ifndef OOTEX_PASSES
+#define OOTEX_PASSES
+
+include "mlir/Pass/PassBase.td"
+
+def AnnotateMarkedFunctions :
+    Pass<"ootex-annotate-marked-functions", "mlir::ModuleOp"> {
+  let summary = [{Annotates util.func ops holding an ootex.mark.}];
+  let options = [
+    Option<"tag", "tag", "std::string", [{"marked"}],
+           "Attribute value written onto each marked function.">,
+  ];
+}
+
+#endif // OOTEX_PASSES
diff --git a/samples/compiler_plugins/out_of_tree_example/test/BUILD.bazel b/samples/compiler_plugins/out_of_tree_example/test/BUILD.bazel
new file mode 100644
index 0000000..bd6bafd
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/test/BUILD.bazel
@@ -0,0 +1,31 @@
+# Copyright 2026 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+load("//build_tools/bazel:iree_lit_test.bzl", "iree_lit_test_suite")
+
+package(
+    features = ["layering_check"],
+    licenses = ["notice"],  # Apache 2.0
+)
+
+iree_lit_test_suite(
+    name = "lit",
+    srcs = ["annotate.mlir"],
+    cfg = "//samples:lit.cfg.py",
+    data = ["//samples/compiler_plugins/out_of_tree_example:iree_compiler_plugin_ootex"],
+    env = {
+        "IREE_OOTEX_PLUGIN": "$(rootpath //samples/compiler_plugins/out_of_tree_example:iree_compiler_plugin_ootex)",
+    },
+    target_compatible_with = select({
+        "@platforms//os:linux": [],
+        "@platforms//os:macos": [],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    tools = [
+        "//tools:iree-compile",
+        "@llvm-project//llvm:FileCheck",
+    ],
+)
diff --git a/samples/compiler_plugins/out_of_tree_example/test/CMakeLists.txt b/samples/compiler_plugins/out_of_tree_example/test/CMakeLists.txt
new file mode 100644
index 0000000..e4096c0
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/test/CMakeLists.txt
@@ -0,0 +1,29 @@
+# Copyright 2026 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+# Hand-written: bazel_to_cmake cannot express the test environment below.
+
+if(IREE_EXPERIMENTAL_COMPILER_DYNAMIC_PLUGINS)
+  iree_lit_test_suite(
+    NAME
+      lit
+    SRCS
+      "annotate.mlir"
+    TOOLS
+      FileCheck
+      iree-compile
+  )
+
+  # Nothing to attach to when tests are off: the suite above creates no tests.
+  if(IREE_BUILD_TESTS)
+    iree_package_ns(_PACKAGE_NS)
+    string(REPLACE "::" "/" _PACKAGE_PATH ${_PACKAGE_NS})
+    set_property(TEST "${_PACKAGE_PATH}/annotate.mlir.test"
+      APPEND PROPERTY ENVIRONMENT
+      "IREE_OOTEX_PLUGIN=$<TARGET_FILE:iree_compiler_plugin_ootex>"
+    )
+  endif()
+endif()
diff --git a/samples/compiler_plugins/out_of_tree_example/test/annotate.mlir b/samples/compiler_plugins/out_of_tree_example/test/annotate.mlir
new file mode 100644
index 0000000..06427df
--- /dev/null
+++ b/samples/compiler_plugins/out_of_tree_example/test/annotate.mlir
@@ -0,0 +1,15 @@
+// REQUIRES: iree_dynamic_plugins
+
+// RUN: iree-compile --iree-load-plugin=%iree_ootex_plugin \
+// RUN:   --iree-plugin=ootex --ootex-tag=from_out_of_tree \
+// RUN:   --compile-to=preprocessing %s | FileCheck %s
+
+// The ABI pass has moved the marked body into a private function.
+
+// CHECK: util.func private @_marked
+// CHECK-SAME: ootex.tag = "from_out_of_tree"
+// CHECK-NOT: ootex.mark
+func.func @marked() {
+  "ootex.mark"() : () -> ()
+  return
+}