Repository: 7mind/izumi
Pull request #2131 (2024-06-06 13:04:08)

Update classgraph to 4.8.173
## About this PR
📦 Updates [io.github.classgraph:classgraph](https://github.com/classgraph/classgraph) from `4.8.172` to `4.8.173`

## Usage
✅ **Please merge!**

I'll automatically update this PR to resolve conflicts as long as you don't change it yourself.

If you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below.

Configure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/scala-steward-org/scala-steward/blob/6259adb0414181876733ff6c34234387c6765277/docs/repo-specific-configuration.md) file.

_Have a fantastic day writing Scala!_

<details>
<summary>⚙ Adjust future updates</summary>

Add this to your `.scala-steward.conf` file to ignore future updates of this dependency:
```
updates.ignore = [ { groupId = "io.github.classgraph", artifactId = "classgraph" } ]
```
Or, add this to slow down future updates of this dependency:
```
dependencyOverrides = [{
  pullRequests = { frequency = "30 days" },
  dependency = { groupId = "io.github.classgraph", artifactId = "classgraph" }
}]
```
</details>

<sup>
labels: library-update, early-semver-patch, semver-spec-patch, commit-count:1
</sup>


Patch:
diff --git a/build.sbt b/build.sbt
index aaa621b3f..f2c8d6259 100644
--- a/build.sbt
+++ b/build.sbt
@@ -5297,7 +5297,8 @@ lazy val `sbt-izumi-deps` = project.in(file("sbt-plugins/sbt-izumi-deps"))
     } },
     Test / packageDoc / publishArtifact := false,
     sbtPlugin := true,
-    withBuildInfo("izumi.sbt.deps", "Izumi")
+    withBuildInfo("izumi.sbt.deps", "Izumi"),
+    SettingKey[Boolean]("ide-skip-project") := true
   )
   .enablePlugins(SitePreviewPlugin)
   .disablePlugins(ScoverageSbtPlugin, AssemblyPlugin)
@@ -5305,6 +5306,7 @@ lazy val `sbt-izumi-deps` = project.in(file("sbt-plugins/sbt-izumi-deps"))
 lazy val `fundamentals` = (project in file(".agg/fundamentals-fundamentals"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .enablePlugins(SitePreviewPlugin)
@@ -5324,6 +5326,7 @@ lazy val `fundamentals` = (project in file(".agg/fundamentals-fundamentals"))
 lazy val `fundamentals-jvm` = (project in file(".agg/fundamentals-fundamentals-jvm"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .disablePlugins(AssemblyPlugin)
@@ -5342,6 +5345,7 @@ lazy val `fundamentals-jvm` = (project in file(".agg/fundamentals-fundamentals-j
 lazy val `distage` = (project in file(".agg/distage-distage"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .enablePlugins(SitePreviewPlugin)
@@ -5364,6 +5368,7 @@ lazy val `distage` = (project in file(".agg/distage-distage"))
 lazy val `distage-jvm` = (project in file(".agg/distage-distage-jvm"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .disablePlugins(AssemblyPlugin)
@@ -5385,6 +5390,7 @@ lazy val `distage-jvm` = (project in file(".agg/distage-distage-jvm"))
 lazy val `logstage` = (project in file(".agg/logstage-logstage"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .enablePlugins(SitePreviewPlugin)
@@ -5399,6 +5405,7 @@ lazy val `logstage` = (project in file(".agg/logstage-logstage"))
 lazy val `logstage-jvm` = (project in file(".agg/logstage-logstage-jvm"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .disablePlugins(AssemblyPlugin)
@@ -5412,6 +5419,7 @@ lazy val `logstage-jvm` = (project in file(".agg/logstage-logstage-jvm"))
 lazy val `docs` = (project in file(".agg/doc-docs"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .enablePlugins(SitePreviewPlugin)
@@ -5423,6 +5431,7 @@ lazy val `docs` = (project in file(".agg/doc-docs"))
 lazy val `docs-jvm` = (project in file(".agg/doc-docs-jvm"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .disablePlugins(AssemblyPlugin)
@@ -5433,6 +5442,7 @@ lazy val `docs-jvm` = (project in file(".agg/doc-docs-jvm"))
 lazy val `sbt-plugins` = (project in file(".agg/sbt-plugins-sbt-plugins"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .enablePlugins(SitePreviewPlugin)
@@ -5444,6 +5454,7 @@ lazy val `sbt-plugins` = (project in file(".agg/sbt-plugins-sbt-plugins"))
 lazy val `sbt-plugins-jvm` = (project in file(".agg/sbt-plugins-sbt-plugins-jvm"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .disablePlugins(AssemblyPlugin)
@@ -5454,6 +5465,7 @@ lazy val `sbt-plugins-jvm` = (project in file(".agg/sbt-plugins-sbt-plugins-jvm"
 lazy val `izumi-jvm` = (project in file(".agg/.agg-jvm"))
   .settings(
     publish / skip := true,
+    SettingKey[Boolean]("ide-skip-project") := true,
     crossScalaVersions := Nil
   )
   .disablePlugins(AssemblyPlugin)
diff --git a/distage/distage-core/src/main/scala/izumi/distage/planning/solver/PlanVerifier.scala b/distage/distage-core/src/main/scala/izumi/distage/planning/solver/PlanVerifier.scala
index 1eb90dce3..2e0d5a6c1 100644
--- a/distage/distage-core/src/main/scala/izumi/distage/planning/solver/PlanVerifier.scala
+++ b/distage/distage-core/src/main/scala/izumi/distage/planning/solver/PlanVerifier.scala
@@ -15,6 +15,7 @@ import izumi.fundamentals.collections.nonempty.{NEList, NEMap, NESet}
 import izumi.fundamentals.platform.strings.IzString.toRichIterable
 import izumi.reflect.TagK
 
+import java.util.concurrent.TimeUnit
 import scala.annotation.{nowarn, tailrec}
 import scala.concurrent.duration.FiniteDuration
 
@@ -280,6 +281,19 @@ object PlanVerifier {
     final def verificationPassed: Boolean = issues.isEmpty
     final def verificationFailed: Boolean = issues.nonEmpty
 
+    def combine(that: PlanVerifierResult): PlanVerifierResult = {
+      (this, that) match {
+        case (PlanVerifierResult.Incorrect(Some(i1), v1, t1), PlanVerifierResult.Incorrect(Some(i2), v2, t2)) =>
+          PlanVerifierResult.Incorrect(Some(i1 ++ i2), v1 ++ v2, t1 + t2)
+        case (fail: PlanVerifierResult.Incorrect, _: PlanVerifierResult.Correct) =>
+          fail
+        case (_: PlanVerifierResult.Correct, fail: PlanVerifierResult.Incorrect) =>
+          fail
+        case (PlanVerifierResult.Correct(v1, t1), PlanVerifierResult.Correct(v2, t2)) =>
+          PlanVerifierResult.Correct(v1 ++ v2, t1 + t2)
+      }
+    }
+
     final def throwOnError(): Unit = this match {
       case incorrect: PlanVerifierResult.Incorrect =>
         throw new PlanVerificationException(
@@ -299,5 +313,7 @@ object PlanVerifier {
   object PlanVerifierResult {
     final case class Incorrect(issues: Some[NESet[PlanIssue]], visitedKeys: Set[DIKey], time: FiniteDuration) extends PlanVerifierResult
     final case class Correct(visitedKeys: Set[DIKey], time: FiniteDuration) extends PlanVerifierResult { override def issues: None.type = None }
+
+    def empty: PlanVerifierResult = PlanVerifierResult.Correct(Set.empty, FiniteDuration(0, TimeUnit.SECONDS))
   }
 }
diff --git a/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/CheckableApp.scala b/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/CheckableApp.scala
index 53bcfaac3..92f7f602a 100644
--- a/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/CheckableApp.scala
+++ b/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/CheckableApp.scala
@@ -6,19 +6,24 @@ import izumi.distage.InjectorFactory
 import izumi.distage.config.model.AppConfig
 import izumi.distage.config.model.exceptions.DIConfigReadException
 import izumi.distage.constructors.TraitConstructor
-import izumi.distage.framework.PlanCheck.runtime.RoleSelection
+import izumi.distage.framework.PlanCheck.RoleSelection
 import izumi.distage.framework.model.PlanCheckInput
 import izumi.distage.framework.services.ConfigLoader
 import izumi.distage.model.definition.{Binding, BootstrapModule, Id, Module, ModuleBase, ModuleDef, impl}
 import izumi.distage.model.plan.Roots
+import izumi.distage.model.planning.AxisPoint
 import izumi.distage.model.providers.Functoid
 import izumi.distage.model.reflection.SafeType
 import izumi.distage.modules.DefaultModule
+import izumi.distage.planning.solver.PlanVerifier
+import izumi.distage.planning.solver.PlanVerifier.PlanVerifierResult
 import izumi.distage.plugins.load.LoadedPlugins
 import izumi.distage.roles.launcher.RoleProvider
 import izumi.distage.roles.model.meta.{RoleBinding, RolesInfo}
+import izumi.fundamentals.collections.nonempty.NESet
 import izumi.fundamentals.platform.cli.model.raw.RawAppArgs
 import izumi.fundamentals.platform.functional.Identity
+import izumi.fundamentals.platform.language.Quirks
 import izumi.fundamentals.platform.language.Quirks.Discarder
 import izumi.logstage.api.IzLogger
 import izumi.reflect.TagK
@@ -43,6 +48,21 @@ trait CheckableApp {
     selectedRoles: RoleSelection,
     chosenConfigFile: Option[String],
   ): PlanCheckInput[AppEffectType]
+
+  /**
+    * Override this to execute additional arbitrary user-defined checks at compile-time (or runtime via `PlanCheck.runtime`)
+    *
+    * @throws Throwable You may throw a custom exception if your check error is not describable by [[izumi.distage.model.planning.PlanIssue]]
+    */
+  def customCheck(
+    planVerifier: PlanVerifier,
+    excludedActivations: Set[NESet[AxisPoint]],
+    checkConfig: Boolean,
+    planCheckInput: PlanCheckInput[AppEffectType],
+  ): PlanVerifierResult = {
+    Quirks.discard(planVerifier, excludedActivations, checkConfig, planCheckInput)
+    PlanVerifierResult.empty
+  }
 }
 object CheckableApp {
   type Aux[F[_]] = CheckableApp { type AppEffectType[A] = F[A] }
diff --git a/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/PlanCheck.scala b/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/PlanCheck.scala
index af73bb112..3fdc4f709 100644
--- a/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/PlanCheck.scala
+++ b/distage/distage-framework/.jvm/src/main/scala/izumi/distage/framework/PlanCheck.scala
@@ -94,7 +94,7 @@ object PlanCheck {
       planVerifier: PlanVerifier = PlanVerifier(),
       logger: TrivialLogger = defaultLogger(),
     ): PlanCheckResult = {
-      val chosenRoles = parseRoles(cfg.roles)
+      val chosenRoles = RoleSelection.parseRoles(cfg.roles)
       val chosenActivations = parseActivations(cfg.excludeActivations)
       val chosenConfig = if (cfg.config == "*") None else Some(cfg.config)
 
@@ -124,13 +124,13 @@ object PlanCheck {
       logger: TrivialLogger = defaultLogger(),
     ): PlanCheckResult = {
 
-      var effectiveRoleNames = "unknown, failed too early"
-      var effectiveRoots = "unknown, failed too early"
-      var effectiveConfig = "unknown, failed too early"
-      var effectiveBsPlugins = LoadedPlugins.empty
-      var effectiveAppPlugins = LoadedPlugins.empty
-      var effectiveModule = ModuleBase.empty
-      var effectivePlugins = LoadedPlugins.empty
+      var reportingEffectiveRoleNames = "unknown, failed too early"
+      var reportingEffectiveRoots = "unknown, failed too early"
+      var reportingEffectiveConfig = "unknown, failed too early"
+      var reportingEffectiveBsPlugins = LoadedPlugins.empty
+      var reportingEffectiveAppPlugins = LoadedPlugins.empty
+      var reportingEffectiveModule = ModuleBase.empty
+      var reportingEffectivePlugins = LoadedPlugins.empty
 
       def renderPlugins(loadedPlugins: LoadedPlugins): String = {
         val plugins = loadedPlugins.loaded
@@ -162,16 +162,16 @@ object PlanCheck {
         val errorMsg = cause.fold("\n" + _.stacktraceString, _.issues.fromNESet.map(_.render + "\n").niceList())
         val message = {
           val configStr = if (checkConfig) {
-            s"\n  config              = ${chosenConfig.fold("*")(c => s"resource:$c")} (effective: $effectiveConfig)"
+            s"\n  config              = ${chosenConfig.fold("*")(c => s"resource:$c")} (effective: $reportingEffectiveConfig)"
           } else {
             ""
           }
-          val bindings = effectiveModule
-          val bsPlugins = effectiveBsPlugins.result
-          val appPlugins = effectiveAppPlugins.result
+          val bindings = reportingEffectiveModule
+          val bsPlugins = reportingEffectiveBsPlugins.result
+          val appPlugins = reportingEffectiveAppPlugins.result
           // fixme missing DefaultModule bindings !!!
-          val bsPluginsStr = renderPlugins(effectiveBsPlugins)
-          val appPluginsStr = renderPlugins(effectiveAppPlugins)
+          val bsPluginsStr = renderPlugins(reportingEffectiveBsPlugins)
+          val appPluginsStr = renderPlugins(reportingEffectiveAppPlugins)
           val printedBindings = if (printBindings) {
             (if (bsPlugins.nonEmpty)
                s"""
@@ -200,7 +200,7 @@ object PlanCheck {
 
           s"""Found a problem with your DI wiring, when checking application=${app.getClass.getName.split('.').last.split('$').last}, with parameters:
              |
-             |  roles               = $chosenRoles (effective roles: $effectiveRoleNames) (all effective roots: $effectiveRoots)
+             |  roles               = $chosenRoles (effective roles: $reportingEffectiveRoleNames) (all effective roots: $reportingEffectiveRoots)
              |  excludedActivations = ${NESet.from(excludedActivations).fold("ø")(_.map(_.mkString(" ")).mkString(" | "))}
              |  bootstrapPlugins    = $bsPluginsStr
              |  plugins             = $appPluginsStr
@@ -214,26 +214,29 @@ object PlanCheck {
              |""".stripMargin
         }
 
-        PlanCheckResult.Incorrect(effectivePlugins, visitedKeys, message, cause)
+        PlanCheckResult.Incorrect(reportingEffectivePlugins, visitedKeys, message, cause)
       }
 
       logger.log(s"Checking with roles=`$chosenRoles` excludedActivations=`$excludedActivations` chosenConfig=`$chosenConfig`")
 
       try {
         val input = app.preparePlanCheckInput(chosenRoles, chosenConfig)
+        val loadedPlugins = input.appPlugins ++ input.bsPlugins
 
-        effectiveRoleNames = input.roleNames.mkString(", ")
-        effectiveRoots = input.roots match {
+        reportingEffectiveRoleNames = input.roleNames.mkString(", ")
+        reportingEffectiveRoots = input.roots match {
           case Roots.Of(roots) => roots.mkString(", ")
           case Roots.Everything => "<Roots.Everything>"
         }
-        effectiveModule = input.module
-        effectiveAppPlugins = input.appPlugins
-        effectiveBsPlugins = input.bsPlugins
-        val loadedPlugins = input.appPlugins ++ input.bsPlugins
-        effectivePlugins = loadedPlugins
+        reportingEffectiveModule = input.module
+        reportingEffectiveAppPlugins = input.appPlugins
+        reportingEffectiveBsPlugins = input.bsPlugins
+        reportingEffectivePlugins = loadedPlugins
+
+        val primaryCheckResult = checkAnyApp[F](planVerifier, excludedActivations, checkConfig, { reportingEffectiveConfig = _ }, input)
+        val additionalCheckResult = app.customCheck(planVerifier, excludedActivations, checkConfig, input)
 
-        checkAnyApp[F](planVerifier, excludedActivations, checkConfig, effectiveConfig = _)(input) match {
+        primaryCheckResult.combine(additionalCheckResult) match {
           case incorrect: PlanVerifierResult.Incorrect => returnPlanCheckError(Right(incorrect))
           case PlanVerifierResult.Correct(visitedKeys, _) => PlanCheckResult.Correct(loadedPlugins, visitedKeys)
         }
@@ -244,12 +247,12 @@ object PlanCheck {
       }
     }
 
-    private[this] def checkAnyApp[F[_]](
+    def checkAnyApp[F[_]](
       planVerifier: PlanVerifier,
       excludedActivations: Set[NESet[AxisPoint]],
       checkConfig: Boolean,
       reportEffectiveConfig: String => Unit,
-    )(planCheckInput: PlanCheckInput[F]
+      planCheckInput: PlanCheckInput[F],
     ): PlanVerifierResult = {
       val PlanCheckInput(effectType, module, roots, _, providedKeys, configLoader, _, _) = planCheckInput
 
@@ -296,20 +299,42 @@ object PlanCheck {
       }
     }
 
-    sealed trait RoleSelection {
-      override final def toString: String = this match {
-        case RoleSelection.Everything => "*"
-        case RoleSelection.OnlySelected(selection) => selection.mkString(" ")
-        case RoleSelection.AllExcluding(excluded) => excluded.map("-" + _).mkString(" ")
-      }
+    private def parseActivations(s: String): Set[NESet[AxisPoint]] = {
+      s.split("\\|").iterator.filter(_.nonEmpty).flatMap {
+          NESet `from` _.split(" ").iterator.filter(_.nonEmpty).map(AxisPoint.parseAxisPoint).toSet
+        }.toSet
     }
-    object RoleSelection {
-      case object Everything extends RoleSelection
-      final case class OnlySelected(selection: Set[String]) extends RoleSelection
-      final case class AllExcluding(excluded: Set[String]) extends RoleSelection
+
+    private def defaultLogger(): TrivialLogger = {
+      TrivialLogger.make[this.type](DebugProperties.`izumi.debug.macro.distage.plancheck`.name)
     }
 
-    private[this] def parseRoles(s: String): RoleSelection = {
+    @tailrec private def cutoffMacroTrace(t: Throwable): Unit = {
+      val trace = t.getStackTrace
+      val cutoffIdx = Some(trace.indexWhere(_.getClassName contains "scala.reflect.macros.runtime.JavaReflectionRuntimes$JavaReflectionResolvers")).filter(_ > 0)
+      t.setStackTrace(cutoffIdx.fold(trace)(trace.take))
+      val suppressed = t.getSuppressed
+      suppressed.foreach(cutSuppressed)
+      if (t.getCause ne null) cutoffMacroTrace(t.getCause)
+    }
+    // indirection for tailrec
+    private def cutSuppressed(t: Throwable): Unit = cutoffMacroTrace(t)
+
+  }
+
+  sealed trait RoleSelection {
+    override final def toString: String = this match {
+      case RoleSelection.Everything => "*"
+      case RoleSelection.OnlySelected(selection) => selection.mkString(" ")
+      case RoleSelection.AllExcluding(excluded) => excluded.map("-" + _).mkString(" ")
+    }
+  }
+  object RoleSelection {
+    case object Everything extends RoleSelection
+    final case class OnlySelected(selection: Set[String]) extends RoleSelection
+    final case class AllExcluding(excluded: Set[String]) extends RoleSelection
+
+    def parseRoles(s: String): RoleSelection = {
       val tokens = s.split(" ").iterator.filter(_.nonEmpty).toList
       tokens match {
         case "*" :: Nil =>
@@ -323,7 +348,7 @@ object PlanCheck {
       }
     }
 
-    private[this] def throwInvalidRoleSelectionError(s: String): Nothing = {
+    private def throwInvalidRoleSelectionError(s: String): Nothing = {
       throw new IllegalArgumentException(
         s"""Invalid role selection syntax in `$s`.
            |
@@ -335,28 +360,6 @@ object PlanCheck {
            |""".stripMargin
       )
     }
-
-    private[this] def parseActivations(s: String): Set[NESet[AxisPoint]] = {
-      s.split("\\|").iterator.filter(_.nonEmpty).flatMap {
-          NESet `from` _.split(" ").iterator.filter(_.nonEmpty).map(AxisPoint.parseAxisPoint).toSet
-        }.toSet
-    }
-
-    private[this] def defaultLogger(): TrivialLogger = {
-      TrivialLogger.make[this.type](DebugProperties.`izumi.debug.macro.distage.plancheck`.name)
-    }
-
-    @tailrec private[this] def cutoffMacroTrace(t: Throwable): Unit = {
-      val trace = t.getStackTrace
-      val cutoffIdx = Some(trace.indexWhere(_.getClassName contains "scala.reflect.macros.runtime.JavaReflectionRuntimes$JavaReflectionResolvers")).filter(_ > 0)
-      t.setStackTrace(cutoffIdx.fold(trace)(trace.take))
-      val suppressed = t.getSuppressed
-      suppressed.foreach(cutSuppressed)
-      if (t.getCause ne null) cutoffMacroTrace(t.getCause)
-    }
-    // indirection for tailrec
-    private[this] def cutSuppressed(t: Throwable): Unit = cutoffMacroTrace(t)
-
   }
 
 }
diff --git a/doc/microsite/src/main/tut/distage/distage-framework.md b/doc/microsite/src/main/tut/distage/distage-framework.md
index ca4800c39..c680f4dbd 100644
--- a/doc/microsite/src/main/tut/distage/distage-framework.md
+++ b/doc/microsite/src/main/tut/distage/distage-framework.md
@@ -187,6 +187,60 @@ object WiringCheck extends SpecWiring(
 `distage-framework`'s Role-based applications are checkable out of the box, but applications assembled directly via `distage-core`'s `distage.Injector` APIs
 must implement the @scaladoc[CheckableApp](izumi.distage.framework.CheckableApp) trait to provide all the data necessary for the checks. You may use @scaladoc[CoreCheckableAppSimple](izumi.distage.framework.CoreCheckableAppSimple) implementation for applications definable by a single collection of modules.
 
+### Adding custom checks
+
+You may add your own custom checks that will be executed at compile-time by overriding `customCheck` method of @scaladoc[CheckableApp](izumi.distage.framework.CheckableApp) or its subtypes @scaladoc[RoleAppMain](izumi.distage.roles.RoleAppMain) or @scaladoc[CoreCheckableAppSimple](izumi.distage.framework.CoreCheckableAppSimple).
+
+For example, you could check that all components that have types with names that contain "Required" – such as `make[RequiredInt].from(RequiredInt(5))` – are used by other components and fail otherwise:
+
+```scala mdoc:reset:to-string
+import distage.{DIKey, PlanVerifier}
+import distage.PlanVerifier.PlanVerifierResult
+import izumi.distage.framework.model.PlanCheckInput
+import izumi.distage.model.planning.AxisPoint
+import izumi.distage.plugins.PluginConfig
+import izumi.distage.roles.RoleAppMain
+import izumi.fundamentals.collections.nonempty.NESet
+
+object CustomCheckLauncher extends RoleAppMain.LauncherCats[cats.effect.IO] {
+
+  // test that all bindings with types that have 'Required'
+  // in their name are used in the application
+  override def customCheck(
+    planVerifier: PlanVerifier,
+    excludedActivations: Set[NESet[AxisPoint]],
+    checkConfig: Boolean,
+    planCheckInput: PlanCheckInput[AppEffectType],
+  ): PlanVerifierResult = {
+    val usedKeys = planVerifier.traceReachables(
+      planCheckInput.module,
+      planCheckInput.roots,
+      planCheckInput.providedKeys,
+      excludedActivations,
+    )
+    val requiredKeys = planCheckInput.module.keys.filter {
+      // filter out any set elements (to remove weak set elements)
+      case _: DIKey.SetElementKey => false
+      case anyKey => anyKey.tpe.tag.shortName.contains("Required")
+    }
+    val unused = requiredKeys -- usedKeys
+    if (unused.nonEmpty) {
+      throw new RuntimeException(
+        s"""Custom check failed, found unused Required bindings:
+           |  ${unused.map(_.tpe.tag.repr).mkString(", ")}""".stripMargin
+      )
+    } else {
+      PlanVerifierResult.empty
+    }
+  }
+
+  override def pluginConfig = PluginConfig.cached(
+    packagesEnabled = Seq("com.example.custom")
+  )
+
+}
+```
+
 ### Low-Level APIs
 
 @scaladoc[PlanCheckMaterializer](izumi.distage.framework.PlanCheckMaterializer),
diff --git a/project/Deps.sc b/project/Deps.sc
index 9d6a84f3c..a6ab9dd05 100644
--- a/project/Deps.sc
+++ b/project/Deps.sc
@@ -239,6 +239,7 @@ object Izumi {
       final val topLevelSettings = Seq()
 
       final val sharedAggSettings = outOfSource ++ Seq(
+        """SettingKey[Boolean]("ide-skip-project")""" := true,
         "crossScalaVersions" := "Nil".raw
       )
 
@@ -855,7 +856,8 @@ object Izumi {
         libs = Seq.empty,
         depends = Seq.empty,
         settings = Projects.sbtplugins.settings ++ Seq(
-          SettingDef.RawSettingDef("""withBuildInfo("izumi.sbt.deps", "Izumi")""")
+          SettingDef.RawSettingDef("""withBuildInfo("izumi.sbt.deps", "Izumi")"""),
+          """SettingKey[Boolean]("ide-skip-project")""" := true,
         ),
         plugins = Plugins(
           enabled = Seq.empty,
diff --git a/project/Versions.scala b/project/Versions.scala
index ebefb7626..51a0f0d56 100644
--- a/project/Versions.scala
+++ b/project/Versions.scala
@@ -15,7 +15,7 @@ object V {
   val discipline = "1.7.0"
   val discipline_scalatest = "2.3.0"
 
-  val zio = "2.1.2"
+  val zio = "2.1.3"
   val zio_interop_cats = "23.1.0.2"
 
   val monix = "3.4.0"
@@ -23,7 +23,7 @@ object V {
 
   val circe = "0.14.7"
   val circe_derivation = "0.13.0-M5"
-  val pureconfig = "0.17.6"
+  val pureconfig = "0.17.7"
   val magnolia = "1.1.10"
   val jawn = "1.6.0"
 
@@ -31,7 +31,7 @@ object V {
   val scala_java_time = "2.5.0"
 
   // java-only dependencies below
-  val classgraph = "4.8.172"
+  val classgraph = "4.8.173"
   val slf4j = "2.0.13"
   val typesafe_config = "1.4.0"
 


Test patch:
diff --git a/distage/distage-framework/.jvm/src/test/scala/izumi/distage/roles/test/CustomCheckEntrypoint.scala b/distage/distage-framework/.jvm/src/test/scala/izumi/distage/roles/test/CustomCheckEntrypoint.scala
new file mode 100644
index 000000000..2d6862dc4
--- /dev/null
+++ b/distage/distage-framework/.jvm/src/test/scala/izumi/distage/roles/test/CustomCheckEntrypoint.scala
@@ -0,0 +1,33 @@
+package izumi.distage.roles.test
+
+import cats.effect.IO
+import distage.DIKey
+import izumi.distage.framework.model.PlanCheckInput
+import izumi.distage.model.planning.AxisPoint
+import izumi.distage.planning.solver.PlanVerifier
+import izumi.distage.planning.solver.PlanVerifier.PlanVerifierResult
+import izumi.fundamentals.collections.nonempty.NESet
+import izumi.fundamentals.platform.strings.IzString.toRichIterable
+
+object CustomCheckEntrypoint extends TestEntrypointPatchedLeakBase {
+  override def customCheck(
+    planVerifier: PlanVerifier,
+    excludedActivations: Set[NESet[AxisPoint]],
+    checkConfig: Boolean,
+    planCheckInput: PlanCheckInput[IO],
+  ): PlanVerifierResult = {
+    val reachable = planVerifier.traceReachables(planCheckInput.module, planCheckInput.roots, planCheckInput.providedKeys, excludedActivations)
+    val conflictKeys = planCheckInput.module.keys.filter {
+      // filter out any set elements (to remove weak set elements)
+      case _: DIKey.SetElementKey => false
+      // make sure that all keys we are checking contain 'Conflict' in their short type name
+      case other => other.tpe.tag.shortName.contains("Conflict")
+    }
+    val unused = conflictKeys -- reachable
+    if (unused.nonEmpty) {
+      throw new RuntimeException(s"Custom check failed, found unused bindings for following keys: ${unused.map(_.tpe.tag.repr).niceList()}")
+    } else {
+      PlanVerifierResult.empty
+    }
+  }
+}
diff --git a/distage/distage-framework/.jvm/src/test/scala/izumi/distage/roles/test/TestEntrypoint.scala b/distage/distage-framework/.jvm/src/test/scala/izumi/distage/roles/test/TestEntrypoint.scala
index 71ce73a87..ac55b3439 100644
--- a/distage/distage-framework/.jvm/src/test/scala/izumi/distage/roles/test/TestEntrypoint.scala
+++ b/distage/distage-framework/.jvm/src/test/scala/izumi/distage/roles/test/TestEntrypoint.scala
@@ -12,7 +12,9 @@ import izumi.fundamentals.platform.language.SourcePackageMaterializer.thisPkg
 object TestEntrypoint extends TestEntrypointBase
 
 // for `CompTimePlanCheckerTest`
-object TestEntrypointPatchedLeak extends TestEntrypointBase {
+object TestEntrypointPatchedLeak extends TestEntrypointPatchedLeakBase
+
+trait TestEntrypointPatchedLeakBase extends TestEntrypointBase {
   override protected def roleAppBootOverrides(argv: RoleAppMain.ArgV): Module = super.roleAppBootOverrides(argv) ++ new ModuleDef {
     modify[Module].named("roleapp") {
       _ ++ new ModuleDef {
diff --git a/distage/distage-testkit-scalatest/src/test/scala/izumi/distage/testkit/distagesuite/compiletime/CompileTimePlanCheckerTest.scala b/distage/distage-testkit-scalatest/src/test/scala/izumi/distage/testkit/distagesuite/compiletime/CompileTimePlanCheckerTest.scala
index 914efd8fd..08db1649c 100644
--- a/distage/distage-testkit-scalatest/src/test/scala/izumi/distage/testkit/distagesuite/compiletime/CompileTimePlanCheckerTest.scala
+++ b/distage/distage-testkit-scalatest/src/test/scala/izumi/distage/testkit/distagesuite/compiletime/CompileTimePlanCheckerTest.scala
@@ -10,7 +10,7 @@ import izumi.distage.framework.model.exceptions.PlanCheckException
 import izumi.distage.framework.{PlanCheck, PlanCheckConfig}
 import izumi.distage.model.planning.{AxisPoint, PlanIssue}
 import izumi.distage.model.reflection.DIKey
-import izumi.distage.roles.test.{TestEntrypoint, TestEntrypointPatchedLeak}
+import izumi.distage.roles.test.{CustomCheckEntrypoint, TestEntrypoint, TestEntrypointPatchedLeak}
 import izumi.fundamentals.collections.nonempty.NESet
 import izumi.fundamentals.platform.language.literals.{LiteralBoolean, LiteralString}
 import logstage.LogIO2
@@ -308,6 +308,30 @@ final class CompileTimePlanCheckerTest extends AnyWordSpec with GivenWhenThen {
     assert(dep != null)
   }
 
+  "Support custom checks" in {
+    val res = PlanCheck.runtime.checkApp(
+      CustomCheckEntrypoint,
+      PlanCheckConfig(
+        roles = "* -failingrole01 -failingrole02",
+        checkConfig = false,
+        excludeActivations = "mode:test",
+      ),
+    )
+    assert(res.maybeErrorMessage.exists(_.contains("Custom check failed")))
+
+    val err = intercept[TestFailedException](assertCompiles("""
+      new PlanCheck.Main(
+        CustomCheckEntrypoint,
+        PlanCheckConfig(
+          roles = "* -failingrole01 -failingrole02",
+          checkConfig = false,
+          excludeActivations = "mode:test",
+        ),
+      ).assertAgainAtRuntime()
+    """))
+    assert(err.getMessage.contains("Custom check failed"))
+  }
+
   "progression test: role app fails check for excluded compound activations that are equivalent to just excluding `mode:test`" in {
     val res = PlanCheck.runtime.checkApp(
       TestEntrypointPatchedLeak,