Class PluginManager

java.lang.Object
com.ultikits.ultitools.manager.PluginManager

public class PluginManager extends Object
UltiTools plugin manager.
  • Field Details

    • LEGACY_PLUGIN_LOADING_PROPERTY

      private static final String LEGACY_PLUGIN_LOADING_PROPERTY
      The name of the JVM system property that opts back into the pre-6.3.0 degraded load order (D-10): every module in discovery order (file-name order, #476), with no dependency resolution at all. Modeled on Paper's own -Dpaper.useLegacyPluginLoading=true precedent -- a one-shot, consumed-at-bootstrap decision, which is why it is a system property rather than a reloadable config.yml key. The literal name is repeated (rather than referenced only through this constant) at every call site below, so the property is legible directly in the operator-facing message that names it, not only in code that reads it.
      See Also:
    • RELOAD_SUMMARY_ALL_KEY

      static final String RELOAD_SUMMARY_ALL_KEY
      Framework i18n key: the /ul reload summary when every module reloaded (#509).
      See Also:
    • RELOAD_SUMMARY_FAILED_KEY

      static final String RELOAD_SUMMARY_FAILED_KEY
      Framework i18n key: the /ul reload summary naming the modules that failed (#509).
      See Also:
    • RELOAD_SUMMARY_MIXED_PARTIAL_KEY

      static final String RELOAD_SUMMARY_MIXED_PARTIAL_KEY
      Framework i18n key: the line after RELOAD_SUMMARY_FAILED_KEY when, besides the failed modules, some reloaded only partly; the partial modules' own lines follow it (#529; Codex review of #564, round 2).
      See Also:
    • RELOAD_SUMMARY_PARTIAL_KEY

      static final String RELOAD_SUMMARY_PARTIAL_KEY
      Framework i18n key: the /ul reload summary when no module failed but some reloaded only partly (#529).
      See Also:
    • RELOAD_PARTIAL_LINE_KEY

      static final String RELOAD_PARTIAL_LINE_KEY
      Framework i18n key: one partially reloaded module and the parts that did not reload (#529). The same text /ul reload <name> replies; the literal is repeated in that command.
      See Also:
    • MISSING_REQUIRED_PLUGIN_LOG_KEY

      static final String MISSING_REQUIRED_PLUGIN_LOG_KEY
      Framework i18n key for the one line logged when a module cannot load because a plugin its plugin.yml lists under depend: is not installed or not enabled (#554). Arguments: the module, the missing plugins joined by ", ". Package-private so a test can assert both shipped catalogues translate it.
      See Also:
    • pluginList

      private final List<UltiToolsPlugin> pluginList
      The loaded modules, in load order. Only this manager changes it: a module is listed by onPluginRegistered(com.ultikits.ultitools.abstracts.UltiToolsPlugin) and delisted by unregister(UltiToolsPlugin).

      A CopyOnWriteArrayList because it is read from other threads (the asynchronous /upm list, the economy facade's module attribution) while the main thread loads and unloads modules. Every mutation replaces the backing array under the list's own lock, and both getPluginList()'s snapshot and every loop in this class read one whole array, so a reader never sees a half-applied change and a loop that unloads modules while it iterates cannot fail (#507).

    • pluginClassList

      private final List<Class<? extends UltiToolsPlugin>> pluginClassList
    • discoveryRefusals

      private int discoveryRefusals
      Module JARs the last discovery tried and could not turn into a module class (18-41): refused while its declared main class was read -- no main:, not an UltiToolsPlugin, a main class from another JAR, a linkage failure, a failed JAR validation. A second copy of a module whose classes load from another JAR is not one of them; the duplicate-copies warning reports it.
    • duplicateCopiesSkipped

      private int duplicateCopiesSkipped
      Copies skipped by discovery because their module's classes load from another JAR.
    • moduleJarIndex

      private final ModuleJarIndex moduleJarIndex
      What the start-up scan read: per declared main class, every JAR in the modules folder that declares it, and the JAR its class was loaded from (#516). /upm uninstall reads it to find every file of a loaded module.
    • moduleBeingRegistered

      private final ThreadLocal<Map.Entry<String,Class<? extends UltiToolsPlugin>>> moduleBeingRegistered
      The module being registered on each thread, name and main class (#483); see getModuleBeingRegistered(). A ThreadLocal so it can never be read by, or leak into, another thread.
    • supersedeHandovers

      private final Map<UltiToolsPlugin,List<Runnable>> supersedeHandovers
      Incoming copy -> the actions that give the copies it supersedes back their registrations (#562). Filled by releaseSupersededRegistrations(UltiToolsPlugin) before the incoming copy's container refresh, emptied by discardSupersededRegistrations(UltiToolsPlugin) when it activates or by restoreSupersededRegistrations(UltiToolsPlugin) when it fails. Keyed by identity: two copies of one module may be equal to nothing but themselves.
    • classLoader

      private ClassLoader classLoader
    • taskManager

      private TaskManager taskManager
    • playerCacheManager

      private PlayerCacheManager playerCacheManager
    • entityOwnership

      private final Map<Class<?>,String> entityOwnership
      Entity class -> owning plugin name, populated every time a DataScope is minted (both internal modules and external plugins). Lets a refusal built by DataScope.refusalFor(Class) say "belongs to module X" instead of only "not registered to you" -- D-15 requires the two to be distinguishable. First registration wins; a genuine collision has never been observed across this project's 21 @Table classes / 17 repositories (02-CONTEXT.md), so this is a defensive tie-break, not a load-bearing one.
    • externalScopesByFolder

      private final Map<String,DataScope> externalScopesByFolder
      External plugin data folder (canonical path) -> the DataScope minted for it, populated by registerExternal(ExternalPluginAdapter, Class[]). Lets DataStore.getOperator(File, Class) resolve its caller back to a scope (D-18), for the default body a third-party DataStore that does not override that method inherits.
    • connectedExternalScanPackages

      private final Map<String,String> connectedExternalScanPackages
      Connected External Plugin API consumers, plugin name to scan package (#462): added when registerExternal(ExternalPluginAdapter, Class[]) completes, removed by unregisterExternal(ExternalPluginAdapter). Read through getConnectedExternalScanPackages() by economy attribution, which otherwise could not name a caller that is not a module.
    • FRAMEWORK_SCHEDULED_OWNER_TYPES

      static final Set<Class<?>> FRAMEWORK_SCHEDULED_OWNER_TYPES
      The framework-owned types whose Scheduled methods this manager registers.

      This set is the wiring, not a description of it. registerFrameworkScheduledOwners() registers exactly the instances whose types appear here and fails fast if the two disagree, and FrameworkScheduledWiringTest fails the build if a framework class carries @Scheduled without appearing here. Adding a type to this set and wiring its task are therefore the same act -- which is the point.

      The defect this prevents (#384): TaskManager's two original entry points both iterate a SimpleContainer's beans, so an object the framework constructs directly was reached by neither. PlayerCacheManager.sweepExpiredEntries() carried a @Scheduled(period = 5 minutes) annotation, and its javadoc explained at length why a clock-driven sweep was chosen over a hand-rolled BukkitRunnable -- and it never ran once. Nothing reported an error, because nothing had looked.

      Since:
      6.3.0
    • FRAMEWORK_PACKAGE

      private static final String FRAMEWORK_PACKAGE
      The framework's own package, in both the dotted and the internal (slash) spelling.
      See Also:
    • FRAMEWORK_PACKAGE_INTERNAL

      private static final String FRAMEWORK_PACKAGE_INTERNAL
      See Also:
    • INTERNAL_CLASS_NAME_PATTERN

      private static final Pattern INTERNAL_CLASS_NAME_PATTERN
      A bare JVM internal (slash-separated) binary class name: one or more identifier segments joined by /, each starting with a letter, _ or $. Deliberately rejects anything carrying a space, parenthesis or colon -- which is exactly the shape of the JVM-specific " (wrong name: ...)" detail some JVMs append to a NoClassDefFoundError message for a class/file name mismatch (a jar-content problem, not "a type this class references is absent") and of any non-NoClassDefFoundError LinkageError's prose message (Codex review, PR #551 round 1, P2).
  • Constructor Details

    • PluginManager

      public PluginManager()
  • Method Details

    • getPluginList

      public List<UltiToolsPlugin> getPluginList()
      Returns the modules currently loaded, in load order, as an unmodifiable snapshot.

      The returned list is a copy taken at the moment of the call. It does not change when a module is loaded or unloaded afterwards, and every attempt to add, remove or clear throws UnsupportedOperationException. Only this manager changes which modules are loaded: register(...) lists a module and unregister(UltiToolsPlugin) delists it.

      Since 6.3.0 (#507). Before, this returned the manager's live internal ArrayList, so callers delisted unloaded modules by removing from it, and a reader on another thread could fail with a ConcurrentModificationException or see a trailing null while the main thread unloaded a module. The signature and return type are unchanged.

      Returns:
      an unmodifiable snapshot of the loaded modules
    • init

      public void init(ClassLoader classLoader) throws IOException
      Initialize plugin manager. Please do not call this method manually.
      Throws:
      IOException - IO exception
    • loadDiscoveredModules

      void loadDiscoveredModules()
      The part of init(ClassLoader) after discovery: orders the discovered module classes by their dependencies, registers each, and logs the load summary. Package-private so a test can drive it after discoverModuleClasses(File) over a folder of its own.

      The summary's Failed is every module JAR of this start that did not end up loaded (18-41): the JARs discovery refused, the module classes dependency resolution excluded (a cycle or a missing required plugin -- absent from the sorted list), and those whose registration failed. Before, it was sorted - success, which counted none of the first two, so a boot that refused a module printed Failed 0. A folder whose every JAR was refused still prints the summary; No UltiTools plugin found. is kept for a folder with no module JAR at all.

    • discoverModuleClasses

      boolean discoverModuleClasses(File pluginFolder)
      Lists the modules folder once and reads each module JAR's declared main class, adding every class that loads to pluginClassList. Package-private so a test can drive the scan over a folder of its own.
      Parameters:
      pluginFolder - the modules folder
      Returns:
      false when the folder could not be listed
    • warnOnDuplicateModuleJars

      private void warnOnDuplicateModuleJars()
      One warning per module main class that two or more JARs in the modules folder declare, naming every one of them and the JAR the module's classes load from (UltiTools-Dev-Doc#96).

      All module JARs share one class loader, so the copies are not loaded side by side and no version comparison runs between them: the class comes from the first JAR on the class path that carries it -- in file-name order (#476) -- and the other copies are refused. Without this line an operator who dropped a new version next to the old one would see the old one keep running with nothing saying why.

      A main class none of whose copies loaded gets no line here: each copy has already been refused with its own line saying why.

    • isDuplicateCopy

      private static boolean isDuplicateCopy(File pluginJar, File supplier, String mainClassName)
      Whether a JAR whose declared main class was loaded from another JAR is another copy of that module: the other JAR is in the same modules folder and its own plugin.yml declares the same main:. Anything else is a class borrowed from a JAR that is not this module, which stays refused with its own SEVERE line. Reads only plugin.yml.
      Parameters:
      pluginJar - the JAR being scanned
      supplier - the JAR its declared main class was loaded from
      mainClassName - the main class it declares
      Returns:
      true when it is a duplicate copy
    • register

      public boolean register(Class<? extends UltiToolsPlugin> pluginClass)
      Register plugin.
      Parameters:
      pluginClass - Plugin class
      Returns:
      Register result
    • registerClass

      private boolean registerClass(Class<? extends UltiToolsPlugin> pluginClass)
    • register

      public boolean register(UltiToolsPlugin plugin)
      Parameters:
      plugin - UltiTools plugin instance
      Returns:
      Register result
    • registerInstance

      private boolean registerInstance(UltiToolsPlugin plugin)
    • refusesForUninstalledRequiredPlugin

      private static boolean refusesForUninstalledRequiredPlugin(PluginYmlReader.PluginYmlInfo pluginYml, String fallbackName)
      Refuses a module before anything of it runs when a plugin its plugin.yml lists under depend: is not installed on this server (#554): logs the one WARNING naming the module and those plugins and returns true; the caller returns without initializing, constructing, extracting or scanning anything of the module, so a class that belongs to the missing plugin is never loaded and never logs a trace.

      Only a plugin that is not installed at all counts here. A plugin that is installed but not enabled yet may still be enabled after UltiTools -- Bukkit enables in its own order, and only the plugins UltiTools itself lists under depend: or softdepend: are guaranteed to come first -- so refusing it here would refuse a module that loads today. That case keeps the late check in logPluginInitializationFailure(Class, String, Throwable).

      Parameters:
      pluginYml - the module's own plugin.yml
      fallbackName - the name to log when the plugin.yml has no name:
      Returns:
      true when the module was refused and the line logged
    • logMissingRequiredPlugins

      private static void logMissingRequiredPlugins(String moduleName, List<String> missing)
      Logs the one MISSING_REQUIRED_PLUGIN_LOG_KEY line, without a trace (#554).
    • logPluginInitializationFailure

      static void logPluginInitializationFailure(Class<?> pluginClass, String moduleName, Throwable thrown)
      Logs a module's initialization failure (#554): when the failure is a class-not-found kind (NoClassDefFoundError or ClassNotFoundException anywhere in the cause chain) and at least one plugin the module lists under depend: is not installed or not enabled, one WARNING names the module and those plugins, without the raw trace -- the missing class belongs to the missing plugin and the trace says nothing more. In every other case, including a class-not-found failure while every depend: plugin is present, logPluginInitializationFailure(String, Throwable) logs the existing message and trace unchanged.

      A plugin that is not installed at all is refused earlier, before construction, by refusesForUninstalledRequiredPlugin(com.ultikits.ultitools.utils.PluginYmlReader.PluginYmlInfo, java.lang.String); this late check still serves a required plugin that is installed but not enabled, which the early check deliberately leaves alone.

      Parameters:
      pluginClass - the module's main class, whose own jar's plugin.yml is read
      moduleName - the module refusing to load, however the caller identifies it
      thrown - the throwable caught at the registration boundary
    • isClassNotFoundKind

      private static boolean isClassNotFoundKind(Throwable thrown)
      Whether thrown's cause chain holds a NoClassDefFoundError or a ClassNotFoundException. Bounded by identity-based cycle detection, like rootCauseMessage(Throwable).
    • beginRegistration

      private Map.Entry<String,Class<? extends UltiToolsPlugin>> beginRegistration(String name, Class<? extends UltiToolsPlugin> pluginClass)
      Records the module being registered on this thread (#483) and returns the record it replaces (a registration started from inside another module's registration), for endRegistration(java.util.Map.Entry<java.lang.String, java.lang.Class<? extends com.ultikits.ultitools.abstracts.UltiToolsPlugin>>) to restore.
    • endRegistration

      private void endRegistration(Map.Entry<String,Class<? extends UltiToolsPlugin>> previous)
    • getModuleBeingRegistered

      @Internal public Map.Entry<String,Class<? extends UltiToolsPlugin>> getModuleBeingRegistered()
      The module being registered on the calling thread, as name and main class, or null (#483). Set by both register entry points before the module is constructed or assembled and cleared when the attempt ends, successful or not, so a module requesting the economy from its constructor, a @PostConstruct method or registerSelf() -- before it is in getPluginList() -- can still be named. Per thread: another thread, even one the module starts during registration, never sees it. Not part of the module-facing API; public only because economy attribution lives in another package.
      Returns:
      the module being registered on this thread, or null when none is
      Since:
      6.3.0
    • findRegistrationOwner

      @Internal public UltiToolsPlugin findRegistrationOwner(String moduleName)
      The module copy a registration filed under moduleName only belongs to when it is made outside every module's load scope -- for example from a module's scheduled task after it loaded (#562 item 1): the copy listed as loaded under that name; when more than one copy is listed under it, the first listed, the one loaded earliest. A copy the framework is still loading is not listed yet; what it registers on the loading thread is attributed by its load scope instead, which the registries consult first. So while a newer copy replaces an older one, a registration made by name from any other thread belongs to the older copy and leaves with it. Not part of the module-facing API; public only because the registries live in other packages.
      Parameters:
      moduleName - the name the registration is filed under; null matches nothing
      Returns:
      the listed copy, or null when no copy is listed under moduleName, in which case the registration stays filed under the name only, as before 6.3.0
      Since:
      6.3.0
    • logPluginInitializationFailure

      static void logPluginInitializationFailure(String moduleName, Throwable thrown)
      Logs one refusal WARNING for a module that failed to initialize, surfacing the innermost UltiToolsException's message (module/file/field/value/constraint, per e.g. ConfigurationException.validationFailed(...)) instead of an outer wrapper's generic text (04-REVIEW.md WR-02). All three refusal-log sites in this class route through here so the message shape has exactly one place it is built.

      Package-private, not private: PluginManagerTest lives in this same package and drives this method directly with a real multi-level chain, without reflection or setAccessible(true). This is a test-seam choice, not an API widening - it is not public, so it never enters the published surface.

      Parameters:
      moduleName - the module refusing to load, however the caller identifies it
      thrown - the throwable caught at the registration boundary
    • logPluginUnregistrationFailure

      static void logPluginUnregistrationFailure(String moduleName, Throwable thrown)
      Logs one WARNING for a module whose unregister(UltiToolsPlugin) call threw, mirroring logPluginInitializationFailure(String, Throwable)'s message shape so both refusal classes (load-time, unload-time) read the same way in the console. Package-private for the same reason as that method: a test-seam choice, not a widened API surface (WR-01, 16-REVIEW-lifecycle.md).
      Parameters:
      moduleName - the module whose unregistration failed, however the caller identifies it
      thrown - the throwable caught at the close() loop boundary
    • rootCauseMessage

      static String rootCauseMessage(Throwable thrown)
      Walks thrown's cause chain for the deepest UltiToolsException, returning its message - or thrown.getMessage() if the chain holds none. Bounded via identity-based cycle detection (IdentityHashMap) so a self-referential or cyclic cause chain terminates instead of spinning.
      Parameters:
      thrown - the throwable whose cause chain is walked
      Returns:
      the deepest UltiToolsException's message, or thrown's own message
    • unregister

      public void unregister(UltiToolsPlugin plugin)
      Unloads a module: releases everything the framework registered for it, runs its unload hook through UltiToolsPlugin.unregisterSelf(), closes its container and removes it from the loaded modules.

      The last two always run, in that order, even when the unload hook throws; the hook's failure is then rethrown to the caller. Since 6.3.0 (#507) this method delists the module itself, by identity, so no caller has to. After the container is closed and before the module is delisted, the configuration layer releases the module's configuration entities through ConfigManager.unregisterAll(UltiToolsPlugin) (#507); when the unload is part of close(), their never-saved changes are first named in one warning and dropped - nothing is written at stop, unload or replacement as of 6.3.0.

      The three registries a module can file registrations in by name -- tab-completion completers, EventBus handlers and panel responders -- are released by instance first: every registration recorded against this module instance goes, whatever name it was filed under. As of 6.3.0 that covers what the module registered while it loaded (its load scope, #506) and what was registered by its name after it loaded, while it was the copy listed under that name (#562 item 1, see findRegistrationOwner(String)). Registrations filed under the module's name only -- made while no copy was listed under it -- are then released by name, unless another loaded copy of the module shares that name; they cannot be told apart from that copy's, so they stay until the last copy of the name is unloaded (#506).

      Parameters:
      plugin - UltiTools plugin instance
    • isNameInUseByAnotherLoadedCopy

      private boolean isNameInUseByAnotherLoadedCopy(UltiToolsPlugin plugin)
      Whether a loaded module other than plugin itself carries plugin's name.
      Parameters:
      plugin - the module about to be unloaded
      Returns:
      true if releasing by name would also release another loaded copy's registrations
    • unregister

      private void unregister(UltiToolsPlugin plugin, boolean nameStillInUse, boolean reportUnsaved)
      Parameters:
      plugin - the module to unload
      nameStillInUse - whether another copy of the module -- listed, or being activated as this copy's replacement -- shares its name, so that name-only registrations must not be released by name (#506)
      reportUnsaved - whether the module's never-saved configuration changes are named, once, before its entities are released - at shutdown and at a normal unload or uninstall; a superseded copy passes false because the replacement has already named them as dropped. Nothing is written either way (17-65; review round 1 R65-I5)
    • releaseCompleters

      private static void releaseCompleters(UltiToolsPlugin plugin, boolean nameStillInUse)
      Releases plugin's tab-completion completers: those recorded against the instance, then -- unless another copy of the module shares its name -- those recorded by name only (#506).
    • releaseEventHandlers

      private static void releaseEventHandlers(UltiToolsPlugin plugin, boolean nameStillInUse)
      Releases plugin's EventBus handlers, by instance and then, unless another copy of the module shares its name, by name (#506).
    • releasePanelResponders

      private static void releasePanelResponders(UltiToolsPlugin plugin, boolean nameStillInUse)
      Releases plugin's panel responders, by instance and then, unless another copy of the module shares its name, by name (#506).
    • releasePlayerLinkHandler

      private static void releasePlayerLinkHandler(UltiToolsPlugin plugin)
      Removes the player-link handler plugin set, if it is the owner (plan 22-21). Null-safe while the framework has no instance or no player-link API yet (an enable that failed before creating it).
    • beginLoadScopes

      private static void beginLoadScopes(UltiToolsPlugin plugin)
      Opens, on this thread, the registration scopes of the three registries a module files registrations in by name -- tab-completion completers, EventBus handlers, panel responders -- so that what the module registers while the framework loads it (its container refresh and its registerSelf()) is recorded against this instance (#506; Codex review of #564, round 3). Always paired with endLoadScopes() in a finally.
    • endLoadScopes

      private static void endLoadScopes()
      Closes the scopes beginLoadScopes(UltiToolsPlugin) opened on this thread.
    • runUnregisterStep

      private static void runUnregisterStep(UltiToolsPlugin plugin, String stepDescription, Runnable step)
      Runs one unregister(UltiToolsPlugin) best-effort registry-cleanup step in isolation: a throw from step is logged via logPluginUnregistrationFailure(String, Throwable), not rethrown, so it can neither skip nor mask any of unregister's other steps (Codex review on #457, across four rounds on the same method: listener cleanup surviving command-cleanup failure, the context close surviving the unload hook throwing, mandatory cleanup surviving an early registry failure, and finally every registry step surviving every other one).
      Parameters:
      plugin - the plugin being unregistered, for the log message and step context
      stepDescription - a short, human-readable name for step, folded into the WARNING log line if it throws
      step - the cleanup action to attempt
    • close

      public void close()
      Unregister all plugins.
    • registerFrameworkScheduledOwners

      private void registerFrameworkScheduledOwners()
      Register the @Scheduled methods of every framework-owned object.

      Called from init(ClassLoader) once taskManager and the owner instances exist. Cancellation is the mirror of this, in close().

      Throws:
      IllegalStateException - if the instances offered do not match FRAMEWORK_SCHEDULED_OWNER_TYPES. This is reachable only by editing one of the two without the other, which the guard test already fails the build for; it is a belt-and-braces check on an invariant, and cannot be triggered by user configuration. Failing loudly here is deliberate -- degrading quietly to "some of the tasks got registered" is the exact behaviour this whole change exists to remove.
      Since:
      6.3.0
    • frameworkScheduledOwners

      private List<Object> frameworkScheduledOwners()
      The live instances corresponding to FRAMEWORK_SCHEDULED_OWNER_TYPES, in the same order.
      Returns:
      the framework-owned objects to scan for @Scheduled methods
      Since:
      6.3.0
    • registerPlayerQuitListener

      private void registerPlayerQuitListener()
      Register a Bukkit listener for PlayerQuitEvent to clean up @PlayerCache maps.
    • registerPluginDisableListener

      private void registerPluginDisableListener()
      Register a Bukkit listener for PluginDisableEvent to auto-disconnect external plugins.
      Since:
      6.2.2
    • applyReloadedConfigBindings

      @Internal public void applyReloadedConfigBindings(UltiToolsPlugin plugin, ReloadReport report)
      Applies a module's reloaded configuration to its config-bound @Scheduled tasks and @CmdCD cooldowns (#531). Called by UltiToolsPlugin.reloadSelf() after its configuration reload succeeded and before the module's own onReload(); a refused configuration reload throws before this step, so the running timings stay.

      Bound @Scheduled tasks are rescheduled by TaskManager.rescheduleBound(com.ultikits.ultitools.abstracts.UltiToolsPlugin) (see there for the phase-preserving rule). Each bound @CmdCD value is re-read from the config field it was resolved to at load -- never resolved again -- and merged into every CooldownValidator that holds it. That cache changes only here after load, so a panel save, or a refused reload that left a new value in the config field, is never enforced until a reload succeeds. An out-of-range value keeps the running one and logs one WARNING. A cooldown already running keeps the end time it was stamped with.

      Failure isolation (gate-1 IN-01, IN-02). This step runs only on the main thread, where the task registry lives; called from any other thread it touches nothing and logs a WARNING, because a deferred copy could apply a value a later, refused reload wrote. Each half's own failure is logged against the module and swallowed, so one module's binding problem never aborts /ul reload for the modules after it, and never skips this module's onReload().

      Reported, not only logged (#595). Every value kept in place of a refused one, every half that failed, and a call refused off the main thread is also recorded as one partial reason in report, so /ul reload, /ul reload <name> and a module's own reload command do not reply plain success while a timing was not updated.

      Parameters:
      plugin - the module whose configuration was just reloaded
      report - the module's reload report, receiving one partial reason per value not applied
      Since:
      6.3.0
    • applyReloadedConfigBindings

      @Internal public void applyReloadedConfigBindings(UltiToolsPlugin plugin)
      applyReloadedConfigBindings(UltiToolsPlugin, ReloadReport) with a report nobody reads: the values not applied are only logged.
      Parameters:
      plugin - the module whose configuration was just reloaded
      Since:
      6.3.0
    • runReloadHalf

      private static void runReloadHalf(UltiToolsPlugin plugin, String what, ReloadReport report, Runnable half)
    • refreshCooldownBindings

      private static void refreshCooldownBindings(UltiToolsPlugin plugin, ReloadReport report)
    • refreshOne

      private static void refreshOne(UltiToolsPlugin plugin, ReloadReport report, String bindingKey, Supplier<Long> source, Integer before, Map<String,Integer> next, Set<String> reported)
      Re-reads one bound cooldown into next: the new value when it is valid, otherwise the running one with one WARNING per binding key -- also when the config's own reload failed.
    • keepRunning

      private static void keepRunning(UltiToolsPlugin plugin, ReloadReport report, String bindingKey, Integer before, Map<String,Integer> next, Set<String> reported, String why)
    • describeBindingKey

      private static String describeBindingKey(String bindingKey)
      Renders com.example.EssentialsConfig#features.wild.cooldown as EssentialsConfig key 'features.wild.cooldown'.
    • validateConfigBindings

      static void validateConfigBindings(UltiToolsPlugin plugin, SimpleContainer pluginContext)
      Load-time check of a module's config-bound @Scheduled and @CmdCD declarations (#531), run right after validateCommandExecutorContracts(com.ultikits.ultitools.context.SimpleContainer) and before any Bukkit side effect, so a refusal is module-granular in exactly the same way.

      Refuses the module when it uses any binding while declaring a plugin.yml api-version below 630; when a binding names a config class not registered exactly once for it, a key that matches no @ConfigEntry path, a field that is not an int, long, Integer or Long, a key without a config class or a config class without a key, or sets the annotation literal as well as the key; when a bound value is out of range (a period or delay below 1 second or above the ceiling, a cooldown below 0 or above Integer.MAX_VALUE, or null); when the bound config's own load did not complete (for example, a failed write-back), so its values were never validated. Resolves every bound @CmdCD once and, only when every executor resolved, stores its value and load-time source on the executor itself -- its exact owner -- through ConfigBoundCooldownState; no CooldownValidator holds any of it.

      Parameters:
      plugin - the module being assembled
      pluginContext - its just-refreshed container
      Throws:
      PluginModuleException - for a binding that cannot work
      ConfigurationException - for an out-of-range value
    • firstBinding

      private static String firstBinding(Collection<Object> beans, List<BaseCommandExecutor> executors)
      Returns:
      the first config-bound declaration in the container, for messages, or null
    • refuseConfigBindingsOutsideModules

      static void refuseConfigBindingsOutsideModules(SimpleContainer context)
      Refuses config-bound @Scheduled/@CmdCD declarations in an external plugin's container, which has no module config registry to bind against (#531). Run before any Bukkit side effect.
      Parameters:
      context - an external plugin's just-refreshed container
      Throws:
      PluginModuleException - naming the first bound declaration found
    • baseCommandExecutors

      private static List<BaseCommandExecutor> baseCommandExecutors(SimpleContainer context)
    • cooldownDeclarations

      private static List<Map.Entry<String,CmdCD>> cooldownDeclarations(BaseCommandExecutor executor)
      Every @CmdCD the executor's validator can meet, each paired with a display owner for messages: the class-level annotation, and for each @CmdMapping method the annotation CooldownValidator itself resolves (most-derived-wins), in the same way.

      A list, not a map keyed by the owner: an owner string is for messages only, and keying by it let one declaration replace another whenever two owners rendered alike -- overloads sharing a name (Codex round 2 on #536), then overloads whose parameter types share a simple name (round 4). With no key there is nothing to collide; a binding key declared twice is resolved once by the callers.

    • parameterList

      private static String parameterList(Method method)
      (Player, String) -- simple parameter type names, for a readable message owner.
    • resolveCooldownBindings

      private static void resolveCooldownBindings(UltiToolsPlugin plugin, BaseCommandExecutor executor, Map<String,Integer> resolved, Map<String,Supplier<Long>> sources)
      Resolves every bound @CmdCD of executor once, at load: shape, key, field, and range (an out-of-range value refuses the module).
      Parameters:
      resolved - receives seconds keyed by CooldownValidator.bindingKey(CmdCD)
      sources - receives the load-time value source for each binding key
    • reload

      public void reload()
      Reload all plugins. This operation only reload plugin configuration.

      Since 6.3.0 each module is reloaded in isolation (#509): a module whose reload throws is logged by its own reloadSelf(), the remaining modules are still reloaded, and the closing summary names every module that failed instead of an unconditional "all reloaded". See reloadAllAndReport().

    • reloadAllAndReport

      @Internal public List<String> reloadAllAndReport()
      Reloads every loaded module, each isolated from the others' failures, then logs a summary and returns it (#509). /ul reload sends the returned lines to its sender. The summary also lists every module whose reload hook reported parts that did not reload, with those parts (#529).

      A module whose reload throws has already logged a failure line naming itself and its cause; it is counted as failed and the next module is reloaded. The isolation is close()'s: every Exception or Error one module throws is that module's failure, including a StackOverflowError from a recursive hook.

      Returns:
      the summary lines, already localized, in the order they were logged
      Since:
      6.3.0
    • loadPluginMainClass

      private Class<? extends UltiToolsPlugin> loadPluginMainClass(ClassLoader classLoader, File pluginJar)
      Load module main class.

      Resolves the main class from the module jar's own plugin.yml main: entry -- the same shape Bukkit/Paper's own PluginDescriptionFile uses to find a plugin's main class -- and loads exactly that one declared class, rather than the pre-6.3.0-plan-17-30 approach of loading every class entry in the jar, in entry order, until one happened to be assignable to UltiToolsPlugin. That approach linked classes with nothing to do with main-class discovery: a class that exists only for an optional soft-dependency plugin (e.g. a PlaceholderAPI expansion) failed to link on every server where that optional plugin is absent, logging a misleading "skipped class...built against an older API" SEVERE line for a module that was never actually built against an older API. See the framework issue this fixes for the full real-machine reproduction.

      Every refusal path here logs a specific, actionable SEVERE naming the module and the exact reason (no plugin.yml/no main:, the declared class failed to load, the declared class is not a concrete UltiToolsPlugin, or -- because every module shares one classloader over the whole module directory -- the declared class resolved but actually belongs to a different, already-installed module's jar) and returns null -- never a silent skip, consistent with this milestone's core value that a declared surface must either work or say plainly that it does not. null is not fatal to the caller: init(ClassLoader) simply omits the module from pluginClassList, so one refused jar never prevents any other jar in the same directory from loading.

      Parameters:
      classLoader - Class loader
      pluginJar - Plugin jar file
      Returns:
      Plugin main class, or null if the module is refused
    • frameworkLinkageFailure

      static Throwable frameworkLinkageFailure(Throwable failure)
      Decides whether a failure to load a module's declared main class means the module was built against a different UltiTools-API than the one installed -- usually an older one, the case an operator meets after upgrading the framework but not the module (6.3.0 made UltiToolsPlugin.unregisterSelf() and reloadSelf() final, and every released module overrides at least the first). The failure alone cannot say which side is older; frameworkApiMismatchRefusal(java.io.File, com.ultikits.ultitools.utils.PluginYmlReader.PluginYmlInfo, java.lang.String, java.lang.Throwable) decides that from the module's declared api-version.

      Two families qualify, and each only when the failure names a type in the framework package com.ultikits.ultitools:

      • IncompatibleClassChangeError and its subclasses (NoSuchMethodError, NoSuchFieldError, AbstractMethodError, IllegalAccessError, InstantiationError). The JVM throws these when a class was compiled against a different version of another class than the one present at run time -- a method made final (the "overrides final method" case), a member removed or narrowed, a method made abstract. They are the definition of a binary-incompatible API change; when the type they name is a framework type, the module was compiled against a different framework. A NoSuchMethodError naming a Bukkit or library member is the same defect against that other API, and blaming the framework would send the operator to replace the wrong thing.
      • A missing class: NoClassDefFoundError, or ClassNotFoundException anywhere in the cause chain, whose missing class is a framework class (a class this release removed). A missing third-party class -- an absent optional plugin, an unshaded library -- is a different defect with a different fix, so it is not attributed to the framework's age. A JAR whose declared main class is itself absent names the module's own class, so it does not qualify either. A NoClassDefFoundError reading "Could not initialize class ..." reports a failed static initializer, not a missing class, so its own message never qualifies.
      The cause chain is walked (up to eight levels), so a wrapper never qualifies by itself but its causes are judged by the same rules: a "Could not initialize class" error or an ExceptionInInitializerError whose underlying cause is a qualifying failure -- a static initializer that died on a removed framework class -- does qualify, because that is the same version mismatch. One whose cause is anything else keeps the generic refusal, as do VerifyError and ClassFormatError (a corrupt or hand-made class file) and UnsupportedClassVersionError (built for a NEWER Java, the opposite remedy).

      This is the cause the entity scan's per-module summary in ModuleScanDiagnostics reports, in the same words (ModuleScanDiagnostics.OLDER_API_CAUSE and ModuleScanDiagnostics.COMPATIBILITY_POINTER). That summary covers any class skipped during the scan and therefore says "usually"; this method accepts only failures that name the framework, because here the module is refused outright and the line tells the operator what to replace.

      Parameters:
      failure - the exception caught while loading the declared main class
      Returns:
      the link of the cause chain that names a framework type the module was compiled against -- the one the refusal quotes -- or null when none does
    • frameworkApiMismatchRefusal

      private static String frameworkApiMismatchRefusal(File pluginJar, PluginYmlReader.PluginYmlInfo ymlInfo, String mainClassName, Throwable failure)
      The one SEVERE line for a module refused by frameworkLinkageFailure(java.lang.Throwable): which JAR, what it declares itself to be (only the keys its plugin.yml declares), why it cannot load, what to do, and the JVM's own message for whoever investigates further.

      The direction of the advice comes from the same comparison isUltiToolsVersionCompatible(com.ultikits.ultitools.abstracts.UltiToolsPlugin) applies after instantiation (requiresNewerFramework(int, int)): a module whose declared api-version is above the installed framework's was built for a newer UltiTools-API, so the line says to upgrade the framework. In every other case -- an api-version equal to or below the framework's, an absent or non-integer api-version, or a framework level that cannot be read -- no comparison says otherwise, and the line keeps the older-module wording, the cause in practice (an operator upgrading the framework but not the module).

    • requiresNewerFramework

      static boolean requiresNewerFramework(int moduleApiVersion, int frameworkApiVersion)
      Whether a module declaring moduleApiVersion needs a newer framework than one at frameworkApiVersion. The single definition of that comparison: both the post-load gate isUltiToolsVersionCompatible(com.ultikits.ultitools.abstracts.UltiToolsPlugin) and the load-failure wording use it.
    • parseDeclaredApiVersion

      private static Integer parseDeclaredApiVersion(String raw)
      A plugin.yml api-version as an integer API level, or null when absent or not an integer.
    • installedFrameworkApiVersion

      private static Integer installedFrameworkApiVersion()
      The installed framework's API level, or null when it cannot be read (no env.yml, malformed version).
    • scanEntitiesInJar

      private Set<Class<?>> scanEntitiesInJar(File pluginJar)
      Scans a plugin's own JAR for classes carrying @Table. This pass visits every entry so a truncated result never makes a legitimate entity look unowned -- under D-15's fail-closed rule that would refuse a module that did nothing wrong. Its own 1,000-class cap (below) is reported, not enforced: crossing it produces one WARNING naming the jar and the count, and the scan continues to the end of the jar regardless.

      Independent of loadPluginMainClass(java.lang.ClassLoader, java.io.File) (plan 17-30): that method no longer scans the jar at all -- it loads exactly the one class plugin.yml's main: key names. This method still must load every class in the jar to see whether it carries @Table, so a class that exists only for an optional soft-dependency plugin (a PlaceholderAPI expansion, say) still fails to link here, on a server where that optional plugin is absent, and is still recorded as one "skipped class" the same way it always was -- plan 17-30 removes the main-class-discovery instance of that noise, not this one. A module whose only jar-wide scan is this one may still log a "skipped N class(es)...built against an older API" SEVERE line for a class that has nothing to do with age; narrowing that further needs a decision on whether an entity scan may also skip classes outside a declared scan root, which plan 17-30 does not make.

      Parameters:
      pluginJar - the module's own jar file
      Returns:
      every @Table-annotated class found, never null
    • classNameForEntityScan

      private static String classNameForEntityScan(JarEntry entry)
      Converts one JarEntry from scanEntitiesInJar(java.io.File) into a dotted class name, or null when the entry is not a class file the entity scan cares about (a META-INF entry, or anything not ending in .class).
      Parameters:
      entry - the jar entry under inspection
      Returns:
      the dotted class name, or null to skip the entry
    • resolveEntityClass

      private static Optional<Class<?>> resolveEntityClass(String className, String moduleName, List<String> softDepend)
      Loads className and returns it wrapped in an Optional when it carries @Table, for scanEntitiesInJar(java.io.File)'s per-entry step. Never throws: a class that cannot be loaded, or is loaded but is not an entity, both resolve to Optional.empty().
      Parameters:
      className - the dotted class name to load
      moduleName - the D-19 diagnostic identifier for the enclosing scanEntitiesInJar(java.io.File)'s jar, threaded through so this method's own catch block can accumulate into the correct module
      softDepend - the scanning module's own declared plugin.yml softdepend: list (UltiEconomy#20); may be empty, never null
      Returns:
      the loaded class if it is a @Table entity, otherwise empty
    • isAbsentSoftDependClasspathGap

      private static boolean isAbsentSoftDependClasspathGap(Throwable failure, List<String> softDepend)
      UltiEconomy#20: decides whether failure, caught while loading a class during a module's entity scan, is fully explained by an optional integration the module itself declared -- a class whose declared type hierarchy references a plugin API type, where that plugin (a) is named in the module's own plugin.yml softdepend: list and (b) is not currently installed, or installed but not enabled, on this server.

      Bukkit's own softdepend: contract is precisely "this plugin may legitimately be absent; the dependent must tolerate it" (see PluginDescriptionFile#getSoftDepend()); Spring Boot's @ConditionalOnClass/ClassUtils#isPresent(String, ClassLoader) treats an optional integration class's absence the same way -- a routing condition, not a defect. Neither precedent attributes an arbitrary missing-class failure to a specific optional dependency by guessing at a hardcoded package-name convention, and this method does not either -- that kind of fragile string-matching heuristic is exactly what GEN-07 removed from SecurityPolicy. Instead this only ever downgrades a failure when ALL of:

      1. failure is specifically a NoClassDefFoundError naming a well-formed internal class name -- the scanned class itself WAS found (physically present in this module's own jar) and failed only because a type it references could not be found. Codex review (PR #551, round 1, P2): LinkageError is a much broader family -- VerifyError, ClassFormatError, UnsupportedClassVersionError and others all indicate corrupt or incompatible bytecode, not a missing optional type, and their messages are prose, not a class name -- extractMissingClassName(java.lang.Throwable) would misparse the first word of that prose as a "missing class", almost certainly fail the availability check, and silently swallow a genuinely broken module into FINE. Narrowing to NoClassDefFoundError (and, inside extractMissingClassName(java.lang.Throwable), further rejecting a "wrong name" mismatch message) keeps every other LinkageError subtype at SEVERE. A bare ClassNotFoundException for the scanned class's own name always means the module's own jar entry could not be loaded at all, never "an optional dependency is absent", and is never downgraded either;
      2. the module declares at least one plugin under softdepend:;
      3. at least one of those declared plugins is not currently installed, or installed but not enabled; and
      4. the specific missing type failure names is not otherwise resolvable through any OTHER plugin that is currently installed and enabled on this server -- guarding against misclassifying a class that in fact belongs to something that IS present.
      A module that declares no softdepend: at all, or whose every declared softdepend: plugin is actually present and enabled, always stays SEVERE via ModuleScanDiagnostics -- this cannot silently swallow a module's own broken reference merely because it happens to run alongside some unrelated, genuinely-installed optional plugin.
      Parameters:
      failure - the exception caught while loading the scanned class
      softDepend - the scanning module's own declared softdepend: list; may be empty, never null
      Returns:
      true when this failure should be logged at FINE and excluded from the module's SEVERE skipped-class summary
    • isPluginAbsentOrDisabled

      private static boolean isPluginAbsentOrDisabled(String pluginName)
      Parameters:
      pluginName - a name from a module's declared softdepend: list
      Returns:
      true if that plugin is not currently installed, or installed but disabled
    • extractMissingClassName

      private static String extractMissingClassName(Throwable failure)
      Extraction of the internal (dot-qualified) name of the class failure reports missing -- only for a NoClassDefFoundError whose message is a well-formed bare internal class name; every other case (a different LinkageError subtype, a blank message, or a message shaped like the "wrong name" mismatch detail) returns null rather than guess, per INTERNAL_CLASS_NAME_PATTERN's own javadoc.
      Parameters:
      failure - the exception caught while loading a scanned class
      Returns:
      the missing class's dotted name, or null if it cannot be determined
    • isClassAvailableFromAnyEnabledPlugin

      private static boolean isClassAvailableFromAnyEnabledPlugin(String missingClassName)
      Whether missingClassName is physically present in any currently installed AND enabled plugin's own jar -- i.e. whether the type a LinkageError named as missing is, in fact, available somewhere on this server right now. Reads each candidate plugin's own jar entries directly via resolveOwnJarFile(Class) rather than attempting to load the class through that plugin's own classloader, which would require assuming a specific cross-plugin classloader delegation topology this check has no need to depend on.
      Parameters:
      missingClassName - the dotted class name reported missing
      Returns:
      true if some other enabled plugin's own jar carries that class
    • resolveOwnJarFile

      private static File resolveOwnJarFile(Class<?> aClass)
      Best-effort resolution of the jar file aClass was loaded from, via its CodeSource. Never throws; returns null when the code source is unavailable or does not point at a real jar (e.g. exploded test classes) -- the same "best effort, class-name fallback" posture UltiToolsPlugin.resolveJarFileNameForError() already uses for the same lookup.
      Parameters:
      aClass - the class whose jar should be resolved
      Returns:
      the jar file, or null if it cannot be resolved
    • scanPluginEntities

      private Set<Class<?>> scanPluginEntities(String pluginName, Class<? extends UltiToolsPlugin> pluginClass)
      Builds the entity set a DataScope for plugin should carry (D-19): every @Table class found by scanning the module's own jar, plus anything declared via @UltiToolsModule#additionalEntities() for entities that legitimately live elsewhere. Never throws -- a plugin whose jar cannot be resolved (e.g. a directly-constructed test instance with no real jar) simply gets an entity set built from its declared additionalEntities() alone, which is empty by default.

      Takes the plugin's class rather than an instance -- everything this reads (the JAR's own CodeSource and the class-level @UltiToolsModule annotation) is a class-level fact, and initializePlugin already has pluginClass in hand before the instance exists.

      Every class named in additionalEntities() is validated (02-14) before being folded in -- see the private provenance-check method below this one. A plugin can declare an entity that legitimately lives outside its own JAR (D-19's purpose), but not one that structurally belongs to a different, already-known module.

      Parameters:
      pluginName - the plugin's own name, for the refusal message and the ownership-conflict check
      pluginClass - the plugin whose scope is being minted
      Returns:
      the entity set for that plugin's scope
      Throws:
      PluginModuleException - fail-closed, when additionalEntities() names an entity that does not live on this plugin's own classpath (02-14)
    • scanExternalEntities

      private Set<Class<?>> scanExternalEntities(ExternalPluginAdapter adapter, Class<?>[] additionalEntities)
      Builds the entity set a DataScope for an external plugin should carry (D-19): every @Table class found by scanning the plugin's own jar, plus whatever it declared via UltiToolsAPI.connect(plugin, additionalEntities).

      Every class named in additionalEntities is validated (02-14) before being folded in -- see the private provenance-check method below scanPluginEntities. This is the public, reachable side of D-19: UltiToolsAPI.connect(plugin, additionalEntities...) lands here with zero prior trust established, so the validation matters most on this path.

      Parameters:
      adapter - the external plugin adapter
      additionalEntities - entity classes declared via connect(...)
      Returns:
      the entity set for that external plugin's scope
      Throws:
      PluginModuleException - fail-closed, when additionalEntities names an entity that does not live on this plugin's own classpath (02-14)
    • validateAdditionalEntity

      private void validateAdditionalEntity(String declaringPluginName, Class<?> declaringClass, File declaringOwnJarFile, Class<?> entityClass)
      Validates that entityClass, declared via additionalEntities (D-19) by declaringPluginName, does not structurally belong to a DIFFERENT, already-known plugin before it is folded into that plugin's DataScope (02-14 finding: this attribute previously accepted any Class<?> the caller named, with zero validation -- letting any plugin claim ownership of any other module's real @Table entity through the public UltiToolsAPI.connect(plugin, additionalEntities...) surface, or the internal @UltiToolsModule#additionalEntities() equivalent).

      The discriminator is "does this class belong to a DIFFERENT plugin", not "is this class mine" -- deliberately, since D-19 exists precisely so a module can declare an entity that lives outside its own JAR:

      • entityClass living in the SAME physical jar as declaringOwnJarFile (a shared library shaded into the declaring plugin's own artifact, or a harmless redundant re-declaration of an entity the automatic scan already found) -- legitimate, accept.
      • entityClass already recorded in entityOwnership under a DIFFERENT plugin name -- refuse, naming the confirmed owner (matches DataScope.refusalFor's message shape).
      • entityClass's own jar is itself the own jar of a DIFFERENT, already-discovered UltiToolsPlugin module (pluginClassList, populated by init()'s upfront jar scan BEFORE any individual plugin registers) or of a currently-loaded Bukkit plugin (Bukkit.getPluginManager().getPlugins(), populated by Bukkit's own plugin loading, independent of whether that plugin has called UltiToolsAPI.connect(...) yet) -- structurally confirmed as belonging to a different plugin, refuse. This is what closes the first-registration-wins window entityOwnership's putIfAbsent semantics alone cannot: it does not depend on which plugin happened to register first, only on which jar actually defines the class.
      • Anything else -- entityClass lives outside the declarer's own jar but is not structurally tied to any other known plugin, and no registry conflict exists -- a genuine shared-library / multi-module common artifact, D-19's stated purpose. Accept.
      Deliberately does not compare classloaders: every internal UltiToolsPlugin module is loaded through ONE shared URLClassLoader covering the whole plugins/ folder (see init(ClassLoader)), so two different internal modules' classes always report the same classloader -- using that as a signal would make this check a no-op for the internal path, silently failing to close the exact vulnerability it exists to close. Jar identity (via java.security.CodeSource) remains meaningful even when many jars share one classloader, which is why it is the signal used here instead.

      A residual gap, disclosed rather than silently accepted: an attacker who can obtain a byte-identical copy of another plugin's entity class (e.g. by depending on its published jar at build time) and shade that copy into their own jar would pass this check -- from the JVM's perspective it genuinely is a different, attacker-owned Class object, structurally indistinguishable from a legitimate shared-library entity. This method validates class PROVENANCE (which jar/module actually defined this exact class), not table name collision; a plugin that simply writes its own class with a matching @Table name never reaches this method at all (no additionalEntities declaration involved), and is a pre-existing, out-of-scope risk inherent to a single shared MySQL DataSource across all plugins -- not the vulnerability 02-SECURITY.md described or this plan closes.

      Parameters:
      declaringPluginName - the plugin declaring entityClass
      declaringClass - the declaring plugin's own main/adapter class, used to look it up in pluginClassList and excluded from the "belongs to another known plugin" check
      declaringOwnJarFile - the declaring plugin's own resolved jar, or null when unresolvable (e.g. a directly-constructed test instance)
      entityClass - the entity class named in additionalEntities
      Throws:
      PluginModuleException - fail-closed (D-15), naming the declarer, the entity, and the actual owner where one is known
    • belongsToAnotherKnownPlugin

      private boolean belongsToAnotherKnownPlugin(File entityJarFile, Class<?> declaringClass)
      Whether entityJarFile is itself the own jar of some plugin OTHER than declaringClass -- checked against every internal module init() has already discovered (pluginClassList, regardless of load order) and every Bukkit plugin currently loaded (Bukkit.getPluginManager().getPlugins(), regardless of whether it has called UltiToolsAPI.connect(...) yet). See the caller directly above this method for the full reasoning.
      Parameters:
      entityJarFile - the entity's resolved own jar, never null
      declaringClass - the declaring plugin's own class, excluded from the comparison
      Returns:
      true if a different, already-known plugin's own jar matches
    • additionalEntityRefusalMessage

      private static String additionalEntityRefusalMessage(String declaringPluginName, Class<?> entityClass, String owner)
      Builds the refusal message for an additionalEntities declaration that does not live on the declarer's own classpath -- the same "confirmed owner" vs. "no confirmed owner" distinction DataScope.refusalFor(Class) makes (D-15), applied at declaration time instead of data-access time.
      Parameters:
      declaringPluginName - the plugin whose declaration is refused
      entityClass - the entity class that was declared
      owner - the confirmed owning plugin's name, or null when none is known
      Returns:
      the refusal message
    • registerEntityOwnership

      private void registerEntityOwnership(DataScope scope)
      Records scope's owned entities in entityOwnership, so a later ownership refusal (D-15) can name the actual owning module instead of only saying "not registered to you". Called every time a DataScope is minted, for both internal modules and external plugins.
      Parameters:
      scope - the scope just minted
    • findOwningPlugin

      public String findOwningPlugin(Class<?> entityClass)
      Looks up which loaded plugin owns entityClass, per entityOwnership.
      Parameters:
      entityClass - the entity class to look up
      Returns:
      the owning plugin's name, or null if no loaded scope owns it
    • registerExternalScope

      private void registerExternalScope(File dataFolder, DataScope scope)
      Records an external plugin's DataScope under its data folder's canonical path, so findScopeForDataFolder(java.io.File) can resolve the caller of DataStore.getOperator(File, Class) back to a scope (D-18) -- the same canonical-path keying 02-02's Task 2 used for the SQLite pool map, so two spellings of the same folder resolve to one entry.

      Refuses (02-14 Task 2) rather than silently replacing when the canonical path already maps to a DIFFERENT plugin's scope -- externalScopesByFolder used a plain .put() before this, so wrapping another plugin (all public calls -- new ExternalPluginAdapter(Bukkit.getPluginManager().getPlugin("Victim"))) and calling the public registerExternal would displace the victim's legitimate scope here, poisoning the D-18 reverse lookup checkOwnership(File, Class) depends on. Re-registration by the SAME plugin (matched by name) is idempotent, not refused -- a plugin reconnecting after unregisterExternal(ExternalPluginAdapter) must not be permanently locked out.

      Parameters:
      dataFolder - the external plugin's own data folder
      scope - the scope just minted for it
      Throws:
      PluginModuleException - fail-closed, when dataFolder already maps to a DIFFERENT plugin's registered scope (02-14)
    • findScopeForDataFolder

      public DataScope findScopeForDataFolder(File dataFolder)
      Resolves a data folder back to the registered external plugin scope that owns it (D-18).
      Parameters:
      dataFolder - the folder to resolve
      Returns:
      the registered scope, or null if the folder matches no registered adapter
    • unregisterExternalScope

      private void unregisterExternalScope(File dataFolder)
      Removes a data folder's registered external plugin scope (02-14 Task 2), so a subsequent registerExternal(ExternalPluginAdapter, Class[]) for the same folder -- a legitimate reconnect -- is not refused by registerExternalScope(java.io.File, com.ultikits.ultitools.manager.DataScope)'s displacement guard as if it were a different plugin still holding the folder.
      Parameters:
      dataFolder - the folder whose registration should be cleared
    • canonicalPath

      private static String canonicalPath(File folder)
    • passesCompatibilityGates

      private boolean passesCompatibilityGates(UltiToolsPlugin plugin)
      Compatibility gates that must run before the module's bean graph is built.

      These two gates originally ran after initializePlugin, i.e. after the container's refresh() -- the whole bean graph plus @PostConstruct -- had already completed. The consequence of that inversion landed exactly on the one scenario where it mattered: a module compiled against a newer API, precisely the kind of module whose beans reference nonexistent methods, would die inside refresh() first and get reported by the generic catch block as a plain initialization failure -- so the friendly "UltiTools version too old" message never got the chance to be said. See issue #184.

      The gates only read metadata sourced from plugin.yml (api-version / main / version), which is already in place once the instance is constructed and needs no container.

      Parameters:
      plugin - freshly constructed plugin instance, context not yet attached
      Returns:
      true if the module may proceed to context construction
    • hasNewerVersionLoaded

      private boolean hasNewerVersionLoaded(UltiToolsPlugin plugin)
      Determines whether a newer version of the same module is already loaded. **Determination only -- never modifies an already-loaded module.**

      Unloading the old version this load supersedes is a separate concern, see unregisterSupersededVersions(com.ultikits.ultitools.abstracts.UltiToolsPlugin). The two are split apart because the determination must happen ahead of bean construction, while the unload cannot -- unloading early would turn "the new version failed to initialize" into "the server is left with neither version".

    • unregisterSupersededVersions

      private void unregisterSupersededVersions(UltiToolsPlugin plugin)
      Unloads the old version this load supersedes, through the full unregister(UltiToolsPlugin) path (#506): its tasks are cancelled, its container closed, its registrations released and it is delisted. When the incoming copy shares its name, it has already registered under it, so only the registrations recorded against the old instance are released and name-only ones stay with the incoming copy; a successor under a different name leaves the old name unused, and its name-only registrations are released too.

      Each old copy's unload is isolated (#528): a failure thrown by its unload hook is logged against that copy's name and version and does not abort the incoming registration. The isolation is close()'s: every Exception or Error counts. Rethrowing an Error here would not abort anything cleanly -- attemptPluginRegistration's own handler would take it, blame the incoming version and leave neither copy listed.

      May only be called after the new module's registerSelf() returns true -- not "after the container is built". The two are one step apart: the container being built only means the bean graph could be constructed, the module is not yet active; and when registerSelf() returns false or throws, the caller closes the new container. If the old version had already been unloaded before that point, the module would be left with neither version -- while it was originally running fine.

      Registrations (#562): every registration recorded against an old copy -- panel responders, EventBus handlers, tab completers -- was already released before the new copy's container refresh, by releaseSupersededRegistrations(UltiToolsPlugin), so the new copy could claim the types and keys the old copy held; had the new copy failed, they would have been given back to the old copy, which keeps running. The order is therefore: release the old copies' registrations, refresh the new copy's container and run its registerSelf(), then either unload the old copies here or, on failure, release what the new copy registered and restore the old copies' registrations. At no point do two copies hold the same type or key, and a copy never keeps a registration recorded against another copy: every release and restore matches the recorded instance, not the shared name. Which copy a registration is recorded against is decided when it is made: a registration made by name outside every load scope -- in the new copy's constructor, for example -- is recorded against the copy listed under that name at the time, which during a replacement is the old copy (see findRegistrationOwner(String)).

    • copiesSupersededBy

      private List<UltiToolsPlugin> copiesSupersededBy(UltiToolsPlugin plugin)
      The loaded copies plugin supersedes: same main class, older version. The same determination unregisterSupersededVersions(UltiToolsPlugin) unloads by.
    • releaseSupersededRegistrations

      private void releaseSupersededRegistrations(UltiToolsPlugin incoming)
      Releases every registration recorded against each copy incoming supersedes -- panel responders, EventBus handlers, tab completers -- before incoming's container refresh and registerSelf() (#562). Called only after the compatibility gates accepted incoming. From here until incoming activates or fails, the superseded copies hold no registration, so incoming can claim the panel message types and completer keys they held, and nothing it publishes while it loads reaches their code. Each registry hands back the action that restores what it released; they are kept until discardSupersededRegistrations(UltiToolsPlugin) or restoreSupersededRegistrations(UltiToolsPlugin). Registrations filed under a module name only are not touched: they belong to whichever copy is listed under that name.
    • discardSupersededRegistrations

      private void discardSupersededRegistrations(UltiToolsPlugin incoming)
      incoming activated: the registrations released from the copies it supersedes stay released, and those copies are unloaded next (#562).
    • restoreSupersededRegistrations

      private void restoreSupersededRegistrations(UltiToolsPlugin incoming)
      incoming failed to load: first releases every registration recorded against incoming itself in the three registries -- what it registered during its container refresh and registerSelf() must not stay behind, answering from its closed container -- then, when it was superseding loaded copies, gives them back the registrations released before its container refresh (#562). Those copies were never unloaded, so they keep running as before. Each step is isolated: a failure is logged and the next step still runs. Safe to call more than once and for a copy that superseded nothing.
    • isUltiToolsVersionCompatible

      private boolean isUltiToolsVersionCompatible(UltiToolsPlugin plugin)
    • attemptPluginRegistration

      private boolean attemptPluginRegistration(UltiToolsPlugin plugin)
    • onPluginRegistered

      private void onPluginRegistered(UltiToolsPlugin plugin)
    • registerModuleEventHandlers

      private void registerModuleEventHandlers(EventBus eventBus, UltiToolsPlugin plugin, Object bean)
      Scan a bean for @ModuleEventHandler methods and register them with the EventBus.
    • wireAop

      static void wireAop(SimpleContainer context, DataScope scope)
      Attaches AOP to a plugin container.

      Must be called before SimpleContainer.refresh(): the resolver participates in bean instantiation, so beans created earlier are never proxied.

      @ExceptionCatch and @Transactional are both wired in this release, on all three backends. DataStore now exposes the plugin's DataSource via getDataSource(DataScope), keyed by database file rather than by entity class, and this method builds one TransactionManager per plugin container from it and registers a TransactionInterceptor advisor for that manager, for the two JDBC backends (SQLite, MySQL). Issues #195 and #196 are both addressed for the JDBC backends. The JSON backend is served by the per-plugin JSON transaction manager instead (02-05, D-03), obtained from JsonStore when getDataSource(DataScope) throws. As of 6.3.0 it stages each transaction's writes apart from committed data, isolates them with table locks, and commits them atomically and durably through the plugin's journal, so the writes a @Transactional method makes to several of the plugin's tables commit or roll back as one unit, with the propagation rules of the JDBC backends. One difference is documented: table locks belong to the thread, so a same-thread REQUIRES_NEW or NOT_SUPPORTED body does not wait for the locks of its suspended caller, and when it writes a key the caller has staged, the caller's later commit is the value that stays. Only a DataStore that can supply neither a DataSource nor its own TransactionManager still declares @Transactional unavailable.

      Scope limit 1 — registerSingleton objects are never proxied, but no longer silently unprotected. Only beans the container constructs itself (through registerBean or component scanning) are ever offered to the resolver, because AopProxyResolver.resolve(Class) only runs on the constructor branch of bean creation. The plugin instance itself, an @ContextEntry bean (built by hand with reflection), config entities, @Configuration classes, and the beans their @Bean methods produce are all registered with registerSingleton instead, so none of them can ever be proxied: a ByteBuddy proxy is the bean itself in this framework's design, and it cannot be retrofitted onto an object the caller already constructed. Unlike the first three, @Configuration classes and @Bean methods are written by module authors rather than the framework. As of 6.3.0 they are no longer constructed before wireAop runs: assemblePluginContainer wires AOP before the component scan (P-06). They are still never proxied, because they reach the container through registerSingleton.

      As of 6.3.0, registerSingleton itself refuses to register an instance whose class carries @Transactional or @ExceptionCatch — method-level or class-level — for exactly that reason: an annotation that can never take effect must fail the load rather than silently do nothing (D-15). An instance that is already a generated proxy is exempt, since it already honours its own annotations. registerSingleton also now fully assembles (autowires, invokes @PostConstruct, runs the BeanPostProcessor chain) whatever it does accept, unconditionally and regardless of SimpleContainer.refresh() state (D-14) — it is only proxying, not assembly, that remains structurally impossible here. See issue #308.

      Class-level scope. A class-level annotation is a default for the methods the annotated class declares, and for its subclasses. It does not apply to ancestors, so a bean does not extend its own annotation over everything it inherits - in particular not over the framework base classes it extends, where a swallowed exception would become a null that resurfaces as an unrelated failure far from its cause. Inherited methods must be locally redeclared to pick up a subclass's annotation. This is the rule Spring documents for a class-level @Transactional. A method-level annotation is unaffected: it applies wherever the method is declared.

      Scope limit 2 - inside that scope, some methods are skipped silently. Two sets. First, methods this proxy cannot both override and reach through super: private, static, final, package-private ones declared in another package, and the erased half of a generic override that a bridge method shadows. Second, equals(Object) / hashCode() / canEqual(Object), where swallowing an exception would replace it with a silent wrong answer - Lombok emits all three onto the annotated class itself, so class-level scope does not keep them out. Both skips are silent by deliberate choice and leave no diagnostic. A method-level annotation is skipped only when the proxy cannot reach the method at all, and never quietly: it is named in a startup warning, the way Spring ignores an annotation on a method it cannot advise. The one thing that still fails the load is a final class, which cannot be subclassed at all.

      Scope limit 3 - one kind of annotation is never seen. An annotation on an interface default method. The scan walks getSuperclass() only, so @ExceptionCatch silently does nothing there and @Transactional is not even refused. Unchanged from 6.2.x, and not fixed here.

      Parameters:
      context - the plugin container, before refresh
      scope - the identity token minted for the caller, used to resolve that plugin's DataSource for the @Transactional advisor
    • wireTransactional

      private static void wireTransactional(SimpleContainer context, DataScope scope, AopProxyResolver resolver)
      Builds and registers the @Transactional advisor for one plugin container. For a backend whose DataStore exposes a JDBC DataSource (SQLite, MySQL), the advisor is bound to a DataSourceTransactionManager -- resolved via resolveJdbcTransactionManager(com.ultikits.ultitools.interfaces.DataStore, com.ultikits.ultitools.manager.DataScope, javax.sql.DataSource), which for these two shipped backends is the SAME instance the store itself wires onto the DataOperators it hands out (02-09, T-02-TAM-11), not an independent second one. For the JSON backend, it is bound to the per-plugin JsonTransactionManager instead (02-05, D-03), obtained from the store itself, which stages writes per transaction, isolates them with table locks, and commits them atomically and durably through a journal. A DataStore that can supply neither still declares the annotation unavailable, naming the configured backend.
      Parameters:
      context - the plugin container, before refresh
      scope - the identity token used to resolve the plugin's DataSource
      resolver - the resolver wireAop is assembling
    • resolveJdbcTransactionManager

      private static JdbcTransactionManager resolveJdbcTransactionManager(DataStore dataStore, DataScope scope, DataSource dataSource)
      Resolves the JdbcTransactionManager wireTransactional(com.ultikits.ultitools.context.SimpleContainer, com.ultikits.ultitools.manager.DataScope, com.ultikits.ultitools.aop.AopProxyResolver) should bind the AOP interceptor to, for a JDBC-capable dataStore (02-09, T-02-TAM-11).

      For the two backends this framework ships, dataStore itself owns a per-identity registry of these managers -- SQLiteDataStore.transactionManagerFor(DataScope) / MysqlDataStore.transactionManagerFor(DataScope) -- and every getOperator call on that same store resolves a manager through the identical registry before handing an operator to a module author. Calling that method here, instead of constructing a fresh new DataSourceTransactionManager(dataSource), is what makes the two paths converge on one shared instance: a @Transactional method that calls DataOperator.insertAll joins the same transaction the interceptor already opened, rather than opening an independent second one on its own connection (a defect worse than no transaction at all, because it looks correct).

      A third-party DataStore that supplies a JDBC DataSource but is neither of the two concrete types this framework ships has no such registry to share -- this method falls back to a manager scoped only to the AOP interceptor, exactly wireTransactional's pre-6.3.0 behavior. A DataOperator that store hands out separately (if any) would not join this manager's transactions; there is no seam on the DataStore interface (out of this plan's file scope) to close that gap generically.

      Parameters:
      dataStore - the store wireTransactional(com.ultikits.ultitools.context.SimpleContainer, com.ultikits.ultitools.manager.DataScope, com.ultikits.ultitools.aop.AopProxyResolver) resolved a DataSource from
      scope - the identity token used to resolve the manager
      dataSource - the DataSource dataStore.getDataSource(scope) returned, used only for the third-party fallback
      Returns:
      the JdbcTransactionManager to bind the interceptor to
    • wireJsonTransactional

      private static void wireJsonTransactional(SimpleContainer context, DataScope scope, AopProxyResolver resolver, JsonStore jsonStore)
      Builds and registers the @Transactional advisor for a plugin container backed by the JSON store, once wireTransactional(com.ultikits.ultitools.context.SimpleContainer, com.ultikits.ultitools.manager.DataScope, com.ultikits.ultitools.aop.AopProxyResolver) has determined dataStore cannot supply a JDBC DataSource but is a JsonStore (02-05, D-03).

      The advisor is bound to the store's JsonTransactionManager for this plugin, the JSON transaction engine: it stages a transaction's writes per operator, locks each table the transaction writes against other writers until it ends, and, at a commit that changed something, forces one journal record to disk before it makes every staged write visible at once. The same manager serves every operator the store hands out for the plugin, so a @Transactional method's writes to several of the plugin's tables form one transaction.

      Parameters:
      context - the plugin container, before refresh
      scope - the identity token used to resolve the manager
      resolver - the resolver wireAop is assembling
      jsonStore - the JSON store to obtain the per-identity manager from
    • releaseConfigEntities

      private static void releaseConfigEntities(UltiToolsPlugin plugin)
    • initializePlugin

      private UltiToolsPlugin initializePlugin(ClassLoader classLoader, Class<? extends UltiToolsPlugin> pluginClass)
      Initialize module using its default (zero-argument) constructor. This is the live, undeprecated construction path -- register(Class) calls it directly.
      Parameters:
      classLoader - Class loader
      pluginClass - Plugin class
      Returns:
      the initialized module, or null if a compatibility gate rejected it
    • finishInitializingPlugin

      private UltiToolsPlugin finishInitializingPlugin(ClassLoader classLoader, Class<? extends UltiToolsPlugin> pluginClass, UltiToolsPlugin plugin)
      Run the construction-independent second half after initializePlugin completes construction: the compatibility gate, then container assembly.
      Parameters:
      classLoader - Class loader to attach to the assembled container
      pluginClass - Plugin class
      plugin - the already-constructed plugin instance
      Returns:
      the initialized module, or null if a compatibility gate rejected it
    • assemblePluginContainer

      private void assemblePluginContainer(SimpleContainer pluginContext, UltiToolsPlugin plugin, Class<? extends UltiToolsPlugin> pluginClass, ClassLoader loader)
      Assemble a plugin's container. Both register(UltiToolsPlugin) and initializePlugin(java.lang.ClassLoader, java.lang.Class<? extends com.ultikits.ultitools.abstracts.UltiToolsPlugin>) route through this one method, so a capability added to either path is never silently missing from the other (WIRE-05, WIRE-06, #203/#326).

      First-assembly order (decision P-06):

      1. container setup: setContext, display name, parent, shutdown hook, class loader;
      2. registerType(UltiToolsPlugin), which the component scan's conditional evaluation reads;
      3. mint the DataScope and hand it to the plugin, then wireAop(com.ultikits.ultitools.context.SimpleContainer, com.ultikits.ultitools.manager.DataScope);
      4. open the load scopes and, inside them: the config entities made resolvable by type, the component scan, the config entities assembled and registered by name, the plugin's own singleton registration, the static instance field, the global entity-ownership record, refresh(), and @ContextEntry bean registration with autowireBean; then close the load scopes;
      5. the command-executor contract and config-binding validations.
      Why this order: registering a config entity, the component scan, registering the plugin singleton and refresh() can each create beans -- registerSingleton fully assembles its argument (D-14), and autowiring creates whatever it injects. So the data scope, AOP and the load scopes are in place before the first of them runs, and the config entities are resolvable by type before the scan, so a configuration class or @Bean product the scan creates can inject them. A config entity itself is assembled (autowired, @PostConstruct) only after the scan, once every definition of the module exists, so it can inject the module's own services, as it could in 6.2.5. A bean created before wireAop could never be proxied afterwards (C08); one created before the config entities or the data scope could not see them (C09); a registration made outside the load scopes, the @ContextEntry bean's included, would not be released with this module instance (C62). The static instance field and the global entity-ownership record keep their place after the scan and the plugin singleton and before refresh(), so the window in which a refused module leaves them behind is not widened.
      Parameters:
      pluginContext - a freshly created, not-yet-refreshed container
      plugin - the plugin instance
      pluginClass - the plugin's class
      loader - the classloader to attach to pluginContext -- deliberately a parameter rather than reading the PluginManager.classLoader field directly, so initializePlugin can pass ITS OWN classLoader parameter (which may differ from the field, e.g. under test) exactly as it did before this method was extracted
    • registerByOwnType

      private static <T> void registerByOwnType(SimpleContainer context, T instance)
      Binds instance to its own class in context's type registry, without assembling it, so getBean(Class) and by-type @Autowired resolve it.
    • validateCommandExecutorContracts

      static void validateCommandExecutorContracts(SimpleContainer pluginContext)
      Refuses to load a module whose BaseCommandExecutor bean declares @CmdCD or @UsageLimit -- on a @CmdMapping method or on the executor class itself -- while its OWN validator chain (the exact chain onCommand dispatches through, not a freshly built default one) holds no instance of the corresponding validator type (SILENT-11 / D-01 half 2, D-04). Every CommandExecutor bean in pluginContext is checked; a bean that is not a BaseCommandExecutor (the legacy AbstractCommandExecutor generation -- removed in 6.3.0 -- never had a ValidatorChain at all) is skipped.

      This is a STRUCTURAL check only -- it asks whether the required validator TYPE is present in the chain, never whether a given invocation would actually be blocked. No opt-out exists (D-04): a declared cooldown or usage limit that cannot be enforced is always a module-author bug, and Phase 3 D-08's module-granularity isolation -- this refusal alone fails the offending module, every other module in the same load pass still completes -- is the accepted escape hatch.

      Parameters:
      pluginContext - the just-assembled, just-refresh()ed container to scan for CommandExecutor beans
      Throws:
      PluginModuleException - fail-closed (D-01, D-04), naming the offending class and, when known, the offending mapping method
    • validateCommandExecutorContract

      static void validateCommandExecutorContract(BaseCommandExecutor executor)
      The per-executor half of validateCommandExecutorContracts(SimpleContainer) -- package-private, not private, so PluginManagerCommandContractTest (same package) can drive it directly with a hand-built fixture, without reflection or setAccessible(true). Mirrors the test-seam rationale already used for logPluginInitializationFailure(String, Throwable): this is not public, so it never enters the published surface.

      @UsageLimit(LimitType.NONE) is exempt -- it declares no limit to enforce, so requiring UsageLockValidator for it would refuse a legitimate no-op declaration.

      Parameters:
      executor - the already-constructed executor instance
      Throws:
      PluginModuleException - fail-closed (D-01, D-04), naming the offending class and, when known, the offending mapping method
    • unenforceableCommandAnnotationMessage

      private static String unenforceableCommandAnnotationMessage(Class<?> executorClass, Method method, Class<? extends Annotation> annotationType, Class<? extends CommandValidator> requiredValidatorType)
      Builds the refusal message for validateCommandExecutorContract(BaseCommandExecutor), naming the offending class and, when method is non-null, the offending mapping method -- mirroring additionalEntityRefusalMessage(String, Class, String)'s message-builder shape.
      Parameters:
      executorClass - the offending executor class
      method - the offending mapping method, or null for a class-level declaration
      annotationType - the declared annotation (@CmdCD or @UsageLimit)
      requiredValidatorType - the validator type the chain is missing
      Returns:
      the refusal message
    • validateSuggestKeysForMethod

      private static void validateSuggestKeysForMethod(Class<?> executorClass, Method method)
      Validate constructor arguments for security. /** D-07's load-time half of @CmdParam.suggest (05-06 Task 2) -- for method's @CmdParam parameters, a suggest() value beginning with @ must name a key already registered in TabCompletionManager at the moment this check runs, i.e. AFTER the module's own pluginContext.refresh() (and therefore after any completer the module registers during its own @PostConstruct). Called from inside validateCommandExecutorContract(BaseCommandExecutor)'s existing @CmdMapping method walk -- not a second pass -- so the two checks' ordering stays in one place (D-01, D-04, D-07).

      A suggest() value that does NOT start with @ is out of scope here entirely -- including one naming a method that does not exist -- because that case keeps the published i18n hint-text fallback (CmdParam.java's javadoc), which D-07 leaves deliberately unchanged. No opt-out (D-04): an unknown @key is always a module-author bug, and module-granularity isolation -- this refusal alone fails the offending module, every other module in the same load pass still completes -- is the accepted escape hatch.

      Parameters:
      executorClass - the executor class method belongs to, for the refusal message
      method - the @CmdMapping method whose parameters are checked
      Throws:
      PluginModuleException - fail-closed (D-07), naming the class, the method and the key
    • unknownSuggestKeyMessage

      private static String unknownSuggestKeyMessage(Class<?> executorClass, Method method, String key)
      Builds the refusal message for validateSuggestKeysForMethod(Class, Method), naming the offending class, method and key -- mirroring unenforceableCommandAnnotationMessage(Class, Method, Class, Class)'s message-builder shape.
      Parameters:
      executorClass - the offending executor class
      method - the offending mapping method
      key - the unknown @key
      Returns:
      the refusal message
    • validateSuggestMethodSignatureForMethod

      private static void validateSuggestMethodSignatureForMethod(BaseCommandExecutor executor, Class<?> executorClass, Method method)
      T-05-fix Part 2's load-time half of method-name @CmdParam.suggest() resolution: a suggest() value that does NOT start with @ names a method looked up EXACTLY as MethodInvocationCompleter.complete(TabCompletionContext) looks it up at completion time -- MethodInvocationCompleter.getSuggestMethodsByName(Object, String) checks the executor's own class first, then any @CmdSuggest-referenced classes.

      A real-machine UAT run on Paper 1.21.11 caught MethodInvocationCompleter .invokeSuggestMethod falling into a final else branch that invoked ANY unrecognized signature with ZERO arguments -- silently wrong, not a failure, so it surfaced only as IllegalArgumentException the first time a player pressed Tab. 16 of 24 real downstream @CmdParam(suggest=) call sites (every UltiWorlds suggest method, shaped (Player, String)) were broken this way. This check closes the gap at its source: if the resolved suggest method's signature is not one of the five shapes MethodInvocationCompleter.isInvocableSuggestSignature(Class[]) accepts, the module is refused at load -- naming the class, the mapping method and the offending signature -- instead of loading cleanly and failing only on first invocation.

      A suggest() value that resolves to NO method at all is out of scope here, exactly as it is for validateSuggestKeysForMethod(Class, Method) -- that keeps the published i18n hint-text fallback, deliberately unchanged (D-07). MethodInvocationCompleter.complete(TabCompletionContext) only ever invokes the FIRST method getSuggestMethodsByName returns when a name resolves to more than one overload, so only that first method's signature is validated here -- validating every overload would refuse a module for an overload that is never actually called.

      Parameters:
      executor - the already-constructed executor instance, needed for the SAME class hierarchy / @CmdSuggest lookup MethodInvocationCompleter performs at completion time
      executorClass - the executor class method belongs to, for the refusal message
      method - the @CmdMapping method whose parameters are checked
      Throws:
      PluginModuleException - fail-closed, naming the class, the method and the offending signature
    • uninvocableSuggestMethodMessage

      private static String uninvocableSuggestMethodMessage(Class<?> executorClass, Method mappingMethod, Method suggestMethod)
      Builds the refusal message for validateSuggestMethodSignatureForMethod(BaseCommandExecutor, Class, Method), naming the offending class, the mapping method and the offending suggest method's declaring class, name and parameter signature -- mirroring unknownSuggestKeyMessage(Class, Method, String)'s message-builder shape.
      Parameters:
      executorClass - the offending executor class
      mappingMethod - the offending @CmdMapping method
      suggestMethod - the resolved suggest method with the uninvocable signature
      Returns:
      the refusal message
    • formatParameterTypes

      private static String formatParameterTypes(Class<?>[] paramTypes)
      Formats paramTypes as a Java-source-like parameter list, e.g. "(int)" or "(Player, String)", for uninvocableSuggestMethodMessage(Class, Method, Method).
      Parameters:
      paramTypes - the parameter types to format
      Returns:
      the formatted parameter list, including the enclosing parentheses
    • registerBukkit

      private void registerBukkit(UltiToolsPlugin plugin)
      Register bukkit commands or listeners.

      Both registration entry points (register(Class) and register(UltiToolsPlugin)) now resolve commands and listeners as beans from the module's own container -- there is no longer a package-scanning branch here. manualRegister() and @ConditionalOnConfig are therefore honoured identically no matter which entry point loaded the module (WIRE-05 differences #6-#9, plan 04-08), and a command class extending the current BaseCommandExecutor can no longer reach CommandManager's legacy, casting registerAll(UltiToolsPlugin, String) overload on either path (issue #272). The package-scanning overloads on CommandManager and ListenerManager are retained -- both @Deprecated(forRemoval = true) -- for downstream callers only.

      Parameters:
      plugin - UltiTools module instance
    • setPluginStaticInstance

      private void setPluginStaticInstance(Class<? extends UltiToolsPlugin> pluginClass, UltiToolsPlugin plugin)
      Set the static instance field on a plugin class before @PostConstruct runs. Most modules follow the pattern: private static XxxPlugin instance; This enables @PostConstruct methods to call Xxx.getInstance().getDataOperator() etc.
      Parameters:
      pluginClass - the plugin class
      plugin - the plugin instance
    • getPluginScanPackages

      public String[] getPluginScanPackages(Class<? extends UltiToolsPlugin> pluginClass)
      Get scan packages for a plugin class. Reads from @UltiToolsModule or @ComponentScan annotations, defaults to plugin class package.

      Widened from private to public by 16-06-PLAN.md (D-08): EconomyUtils' module-attribution helper needs the same real scan-package resolution this class already uses at registration time, rather than duplicating (and risking drifting from) this logic in another package. A new public method is additive, not a binary-compatibility break — japicmp reports only removed or changed members, never a widened one.

      Parameters:
      pluginClass - plugin class
      Returns:
      scan packages
    • sortPluginsByDependencies

      private List<Class<? extends UltiToolsPlugin>> sortPluginsByDependencies(List<Class<? extends UltiToolsPlugin>> plugins)
      Sort plugins by their dependencies using Kahn's algorithm (topological sort).

      A dependency cycle or a missing hard dependency no longer degrades every module to filesystem order (SILENT-08/D-10): only the refused modules are absent from the returned list; every other module still loads. A missing hard dependency refuses the declaring module and every module that transitively depends on it (#676), never a module that only softDepends on, is named by a loadBefore of, or lists in plugin.yml loadAfter a refused module. Anything the sort cannot place - a cycle plus every module ordered after it - is refused as a cycle. When both occur in one start, the console names the missing dependencies, each cycle as a Loop path and every module not loaded. The pre-6.3.0 all-unsorted behaviour survives only as an explicit, cost-stating opt-in via ultitools.useLegacyPluginLoading.

      Parameters:
      plugins - list of plugin classes to sort
      Returns:
      sorted list of plugin classes
    • logCycleDiagnosis

      private void logCycleDiagnosis(List<List<String>> cyclePaths)
      Logs the cycle detection line, then one Loop line and one author-hint line per cycle path. Shared by the circular-dependency handler and the mixed missing-plus-cycle branch of the missing-dependency handler so both print the same lines in the same order. Module names are concatenated, never used as a format string.
      Parameters:
      cyclePaths - each cycle as a list of plugin names whose first and last entries match
    • registerExternal

      public void registerExternal(ExternalPluginAdapter adapter)
      Register an external Bukkit plugin adapter with the framework. Creates an IoC container, scans for annotated components, and registers commands/listeners/tasks.
      Parameters:
      adapter - the external plugin adapter
      Since:
      6.2.2
    • registerExternal

      public void registerExternal(ExternalPluginAdapter adapter, Class<?>[] additionalEntities)
      Register an external Bukkit plugin adapter with the framework, declaring entity classes that legitimately live outside the plugin's own JAR (D-19), in addition to the entities found by scanning the plugin's own JAR for @Table classes.
      Parameters:
      adapter - the external plugin adapter
      additionalEntities - entity classes owned by this plugin that live outside its own JAR
      Since:
      6.3.0
    • getConnectedExternalScanPackages

      @Internal public Map<String,String> getConnectedExternalScanPackages()
      The connected External Plugin API consumers, plugin name to scan package (#462) -- a copy, safe to read from any thread. Not part of the module-facing API; public only because economy attribution lives in another package.
      Returns:
      plugin name to scan package, for every external plugin registered and not yet unregistered
      Since:
      6.3.0
    • registerOwnType

      private static <T> void registerOwnType(SimpleContainer context, T instance)
      Register an object into context keyed by its own concrete runtime class, in addition to whatever declared-type registration the caller has already done. Needed because SimpleContainer.registerType(Class, T) keys by exact Class: a constructor parameter declared as the connector's own concrete plugin class (rather than the general JavaPlugin type) would otherwise never resolve (SILENT-16, #331).

      The unchecked cast is safe: instance.getClass() is always assignable to itself, so capturing it as Class<T> for the same instance of static type T never mismatches at runtime.

      Type Parameters:
      T - the object's static type
      Parameters:
      context - the container to register into
      instance - the object to register under its own concrete class
    • recordNewEntityOwnership

      private void recordNewEntityOwnership(DataScope scope, List<Class<?>> added)
      Records scope's owned entities in entityOwnership where no owner is recorded yet, adding each entity it recorded to added -- the records a refused registration may undo (#537, gate 1). Adds to the caller's list as it goes, so an exception midway still leaves the caller knowing what was recorded.
    • unwindRefusedExternal

      private void unwindRefusedExternal(ExternalPluginAdapter adapter, SimpleContainer context, DataScope scope, List<Class<?>> ownershipAdded)
      Undoes what registerExternal(ExternalPluginAdapter, Class[]) registered before a refusal after the folder scope was recorded (#537): closes the context (if refresh() got as far as building one), clears the adapter's context and data scope, removes the entity-ownership records this attempt inserted, and removes the folder scope -- only if it is still this attempt's own scope instance. A record or scope this attempt found already in place (a live registration of the same plugin, for example) is left alone.
      Parameters:
      adapter - the refused adapter
      context - the container this attempt built
      scope - the scope this attempt minted and registered
      ownershipAdded - the entities whose ownership record this attempt inserted
    • unregisterExternal

      public void unregisterExternal(ExternalPluginAdapter adapter)
      Unregister an external Bukkit plugin adapter from the framework. Tears down the IoC container, unregisters commands/listeners, and cleans up resources.
      Parameters:
      adapter - the external plugin adapter
      Since:
      6.2.2
    • registerModuleEventHandlersExternal

      private void registerModuleEventHandlersExternal(EventBus eventBus, String pluginName, Object bean)
      Scan a bean for @ModuleEventHandler methods and register them with EventBus for an external plugin.