From 6b22398920037c8ae379e65e6b6b48621bd382f9 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 10:06:28 +0200 Subject: [PATCH 1/9] feat(core): expose bootstrap lifecycle state on ISquidStdBootstrap Moves BootstrapStateType from SquidStd.Services.Core (internal) to SquidStd.Core.Types.Bootstrap (public) and adds a read-only State property so external packages can guard pre-start-only operations. --- .../Bootstrap/ISquidStdBootstrap.cs | 7 ++++++ .../Types/Bootstrap}/BootstrapStateType.cs | 4 +-- .../Services/Bootstrap/SquidStdBootstrap.cs | 5 +++- .../AspNetCore/FakeSquidStdBootstrap.cs | 5 ++++ .../Bootstrap/SquidStdBootstrapStateTests.cs | 25 +++++++++++++++++++ .../ConsoleCommands/BuiltinCommandsTests.cs | 10 +++++++- 6 files changed, 52 insertions(+), 4 deletions(-) rename src/{SquidStd.Services.Core/Types => SquidStd.Core/Types/Bootstrap}/BootstrapStateType.cs (79%) create mode 100644 tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index ebaa0bdc..1e6639f2 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -1,6 +1,7 @@ using DryIoc; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types.Bootstrap; namespace SquidStd.Core.Interfaces.Bootstrap; @@ -19,6 +20,12 @@ public interface ISquidStdBootstrap : IAsyncDisposable /// IContainer Container { get; } + /// + /// Gets the current lifecycle state of the bootstrap. Reads are lock-free and intended for + /// pre-start guards, not for synchronizing concurrent lifecycle transitions. + /// + BootstrapStateType State { get; } + /// /// Applies custom service registrations before the bootstrap lifecycle starts. /// diff --git a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs b/src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs similarity index 79% rename from src/SquidStd.Services.Core/Types/BootstrapStateType.cs rename to src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs index 2b1d72ff..1b58d254 100644 --- a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs +++ b/src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs @@ -1,9 +1,9 @@ -namespace SquidStd.Services.Core.Types; +namespace SquidStd.Core.Types.Bootstrap; /// /// Lifecycle state of the SquidStd bootstrapper. /// -internal enum BootstrapStateType +public enum BootstrapStateType { /// Created but not started. Created, diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index 031f1c22..e082a909 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -14,10 +14,10 @@ using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Lifecycle; using SquidStd.Core.Types; +using SquidStd.Core.Types.Bootstrap; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Extensions.Logger; using SquidStd.Services.Core.Services.Lifecycle; -using SquidStd.Services.Core.Types; namespace SquidStd.Services.Core.Services.Bootstrap; @@ -43,6 +43,9 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap /// public IContainer Container { get; } + /// + public BootstrapStateType State => _state; + /// /// Initializes a bootstrapper with default options. /// diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs index 38d4380a..817fcfee 100644 --- a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -2,6 +2,7 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types.Bootstrap; namespace SquidStd.Tests.AspNetCore; @@ -17,6 +18,8 @@ internal sealed class FakeSquidStdBootstrap : ISquidStdBootstrap public IContainer Container { get; } = new Container(); + public BootstrapStateType State { get; set; } = BootstrapStateType.Created; + public ISquidStdBootstrap ConfigureService(Func configure) => ConfigureServices(configure); @@ -64,6 +67,7 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); StartCount++; + State = BootstrapStateType.Started; return ValueTask.CompletedTask; } @@ -72,6 +76,7 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); StopCount++; + State = BootstrapStateType.Stopped; return ValueTask.CompletedTask; } diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs new file mode 100644 index 00000000..2a14fa3a --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs @@ -0,0 +1,25 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Types.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +public class SquidStdBootstrapStateTests +{ + [Fact] + public async Task State_TransitionsAcrossLifecycle() + { + using var temp = new TempDirectory(); + await using var bootstrap = + SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "statetest", RootDirectory = temp.Path }); + + Assert.Equal(BootstrapStateType.Created, bootstrap.State); + + await bootstrap.StartAsync(); + Assert.Equal(BootstrapStateType.Started, bootstrap.State); + + await bootstrap.StopAsync(); + Assert.Equal(BootstrapStateType.Stopped, bootstrap.State); + } +} diff --git a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs index 303a21a5..233e02a2 100644 --- a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs +++ b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs @@ -4,6 +4,7 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types.Bootstrap; using SquidStd.Services.Core.Services.Lifecycle; namespace SquidStd.Tests.ConsoleCommands; @@ -83,6 +84,8 @@ private sealed class StopSpyBootstrap : ISquidStdBootstrap public IContainer Container { get; } = new Container(); + public BootstrapStateType State { get; private set; } = BootstrapStateType.Created; + public StopSpyBootstrap(Action onStop) { _onStop = onStop; @@ -111,10 +114,15 @@ public Task RunAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + State = BootstrapStateType.Started; + + return ValueTask.CompletedTask; + } public ValueTask StopAsync(CancellationToken cancellationToken = default) { + State = BootstrapStateType.Stopped; _onStop(); return ValueTask.CompletedTask; From 522586de1eb0d167823db508c47d7df8c1db7077 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 10:20:36 +0200 Subject: [PATCH 2/9] feat(plugin): add SquidStd.Plugin package with collection builder and context keys New loader package skeleton referencing Core and Plugin.Abstractions: PluginCollectionBuilder (internal plugins by type or instance, external directories), PluginLoadException, standard PluginContextKeys, and host-agnostic wording in the abstractions docs. --- SquidStd.slnx | 1 + .../Data/PluginContextKeys.cs | 18 ++++++ .../Data/PluginMetadata.cs | 4 +- .../Interfaces/Plugins/ISquidStdPlugin.cs | 2 +- .../Exceptions/PluginLoadException.cs | 15 +++++ .../PluginCollectionBuilder.cs | 58 +++++++++++++++++++ src/SquidStd.Plugin/SquidStd.Plugin.csproj | 19 ++++++ .../Plugin/PluginCollectionBuilderTests.cs | 52 +++++++++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 9 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs create mode 100644 src/SquidStd.Plugin/Exceptions/PluginLoadException.cs create mode 100644 src/SquidStd.Plugin/PluginCollectionBuilder.cs create mode 100644 src/SquidStd.Plugin/SquidStd.Plugin.csproj create mode 100644 tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index 9e3564e7..3dfb51c6 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -23,6 +23,7 @@ + diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs new file mode 100644 index 00000000..b05d486d --- /dev/null +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs @@ -0,0 +1,18 @@ +namespace SquidStd.Plugin.Abstractions.Data; + +/// +/// Well-known keys the SquidStd plugin loader populates in . +/// +public static class PluginContextKeys +{ + /// + /// Application root directory (string): the bootstrap RootDirectory option. + /// + public const string RootDirectory = "squidstd:rootDirectory"; + + /// + /// Application name (string): the bootstrap AppName option when set, otherwise the entry + /// assembly name, otherwise the bootstrap ConfigName. + /// + public const string AppName = "squidstd:appName"; +} diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs index eeffa2f3..b365bddc 100644 --- a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs @@ -1,11 +1,11 @@ namespace SquidStd.Plugin.Abstractions.Data; /// -/// Describes a Moongate plugin. This is the source of truth for plugin identity. +/// Describes a SquidStd plugin. This is the source of truth for plugin identity. /// public sealed class PluginMetadata { - /// Stable lowercase dotted plugin identifier, for example moongate.weather. + /// Stable lowercase dotted plugin identifier, for example myapp.weather. public required string Id { get; init; } /// Human-readable plugin name. diff --git a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs index d761f5a0..f09c6227 100644 --- a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs +++ b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs @@ -4,7 +4,7 @@ namespace SquidStd.Plugin.Abstractions.Interfaces.Plugins; /// -/// Implemented by trusted .NET plugins loaded by Moongate during server startup. +/// Implemented by trusted .NET plugins loaded by the SquidStd plugin loader during application startup. /// public interface ISquidStdPlugin { diff --git a/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs b/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs new file mode 100644 index 00000000..88628cec --- /dev/null +++ b/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs @@ -0,0 +1,15 @@ +namespace SquidStd.Plugin.Exceptions; + +/// +/// Raised when the plugin loader cannot discover, order, or instantiate plugins. +/// +public class PluginLoadException : Exception +{ + /// Initializes the exception with a message describing the failure. + public PluginLoadException(string message) + : base(message) { } + + /// Initializes the exception with a message and the underlying cause. + public PluginLoadException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/SquidStd.Plugin/PluginCollectionBuilder.cs b/src/SquidStd.Plugin/PluginCollectionBuilder.cs new file mode 100644 index 00000000..28fdd8c2 --- /dev/null +++ b/src/SquidStd.Plugin/PluginCollectionBuilder.cs @@ -0,0 +1,58 @@ +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +namespace SquidStd.Plugin; + +/// +/// Collects internal plugin registrations and external plugin directories for the loader. +/// Collection only: nothing is loaded or validated until the loader runs. +/// +public sealed class PluginCollectionBuilder +{ + private readonly List _plugins = []; + private readonly List _directories = []; + + internal IReadOnlyList Plugins => _plugins; + + internal IReadOnlyList Directories => _directories; + + /// + /// Registers an internal plugin by type; the loader instantiates it via the public + /// parameterless constructor. + /// + /// The plugin type. + /// The same builder for chaining. + public PluginCollectionBuilder Add() + where TPlugin : ISquidStdPlugin, new() + { + _plugins.Add(new TPlugin()); + + return this; + } + + /// + /// Registers an internal plugin instance. + /// + /// The plugin instance. + /// The same builder for chaining. + public PluginCollectionBuilder Add(ISquidStdPlugin plugin) + { + ArgumentNullException.ThrowIfNull(plugin); + _plugins.Add(plugin); + + return this; + } + + /// + /// Adds a directory to scan for external plugin assemblies (*.dll, non-recursive). + /// Relative paths are resolved against the bootstrap root directory. + /// + /// Absolute or root-relative directory path. + /// The same builder for chaining. + public PluginCollectionBuilder FromDirectory(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _directories.Add(path); + + return this; + } +} diff --git a/src/SquidStd.Plugin/SquidStd.Plugin.csproj b/src/SquidStd.Plugin/SquidStd.Plugin.csproj new file mode 100644 index 00000000..84d93eb2 --- /dev/null +++ b/src/SquidStd.Plugin/SquidStd.Plugin.csproj @@ -0,0 +1,19 @@ + + + + true + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs b/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs new file mode 100644 index 00000000..4fb3fbd6 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs @@ -0,0 +1,52 @@ +using DryIoc; +using SquidStd.Plugin; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +namespace SquidStd.Tests.Plugin; + +public class PluginCollectionBuilderTests +{ + private sealed class AlphaPlugin : ISquidStdPlugin + { + public PluginMetadata Metadata { get; } = new() + { + Id = "tests.alpha", + Name = "Alpha", + Version = new(1, 0, 0), + Author = "Tests" + }; + + public void Configure(IContainer container, PluginContext context) { } + } + + [Fact] + public void Add_ByTypeAndInstance_CollectsInOrder() + { + var builder = new PluginCollectionBuilder(); + var instance = new AlphaPlugin(); + + builder.Add().Add(instance); + + Assert.Equal(2, builder.Plugins.Count); + Assert.Same(instance, builder.Plugins[1]); + } + + [Fact] + public void Add_NullInstance_Throws() + => Assert.Throws(() => new PluginCollectionBuilder().Add(null!)); + + [Fact] + public void FromDirectory_CollectsPaths() + { + var builder = new PluginCollectionBuilder(); + + builder.FromDirectory("plugins").FromDirectory("extra"); + + Assert.Equal(["plugins", "extra"], builder.Directories); + } + + [Fact] + public void FromDirectory_BlankPath_Throws() + => Assert.Throws(() => new PluginCollectionBuilder().FromDirectory(" ")); +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index e926d6f9..657604cc 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -43,6 +43,7 @@ + From 65b7afae9aafcc82477638ce95a20d5b34234c02 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 10:29:47 +0200 Subject: [PATCH 3/9] feat(plugin): add stable case-insensitive plugin dependency resolver Topological sort across the whole plugin set with fail-fast errors for duplicate ids, missing dependencies, and cycles (the message names the cycle members). Plugins with no dependency relation keep registration order. --- .../Internal/PluginDependencyResolver.cs | 81 +++++++++++++++++++ .../Plugin/PluginDependencyResolverTests.cs | 80 ++++++++++++++++++ .../Plugin/Support/StubPlugin.cs | 24 ++++++ 3 files changed, 185 insertions(+) create mode 100644 src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs create mode 100644 tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs create mode 100644 tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs diff --git a/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs b/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs new file mode 100644 index 00000000..ac3a8aa0 --- /dev/null +++ b/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs @@ -0,0 +1,81 @@ +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Exceptions; + +namespace SquidStd.Plugin.Internal; + +/// +/// Orders plugins by dependency: a plugin always appears after every plugin it depends on. +/// Plugin ids are compared case-insensitively. Fails fast on duplicate ids, missing +/// dependencies, and dependency cycles. +/// +internal static class PluginDependencyResolver +{ + public static IReadOnlyList Sort(IReadOnlyList plugins) + { + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var plugin in plugins) + { + if (!byId.TryAdd(plugin.Metadata.Id, plugin)) + { + throw new PluginLoadException($"Duplicate plugin id '{plugin.Metadata.Id}'."); + } + } + + var sorted = new List(plugins.Count); + var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var path = new List(); + + foreach (var plugin in plugins) + { + Visit(plugin, byId, visiting, visited, path, sorted); + } + + return sorted; + } + + private static void Visit( + ISquidStdPlugin plugin, + Dictionary byId, + HashSet visiting, + HashSet visited, + List path, + List sorted + ) + { + var id = plugin.Metadata.Id; + + if (visited.Contains(id)) + { + return; + } + + if (!visiting.Add(id)) + { + var start = path.FindIndex(p => string.Equals(p, id, StringComparison.OrdinalIgnoreCase)); + var cycle = string.Join(" -> ", path.Skip(start).Append(id)); + + throw new PluginLoadException($"Plugin dependency cycle detected: {cycle}."); + } + + path.Add(id); + + foreach (var dependencyId in plugin.Metadata.Dependencies) + { + if (!byId.TryGetValue(dependencyId, out var dependency)) + { + throw new PluginLoadException( + $"Plugin '{id}' depends on '{dependencyId}', which is not registered." + ); + } + + Visit(dependency, byId, visiting, visited, path, sorted); + } + + path.RemoveAt(path.Count - 1); + visiting.Remove(id); + visited.Add(id); + sorted.Add(plugin); + } +} diff --git a/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs b/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs new file mode 100644 index 00000000..bb15fd12 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs @@ -0,0 +1,80 @@ +using SquidStd.Plugin.Exceptions; +using SquidStd.Plugin.Internal; +using SquidStd.Tests.Plugin.Support; + +namespace SquidStd.Tests.Plugin; + +public class PluginDependencyResolverTests +{ + private static string[] Ids(IEnumerable plugins) + => plugins.Select(p => p.Metadata.Id).ToArray(); + + [Fact] + public void Sort_NoDependencies_KeepsRegistrationOrder() + { + var sorted = PluginDependencyResolver.Sort([new StubPlugin("c"), new StubPlugin("a"), new StubPlugin("b")]); + + Assert.Equal(["c", "a", "b"], Ids(sorted)); + } + + [Fact] + public void Sort_DependencyComesFirst() + { + var sorted = PluginDependencyResolver.Sort([new StubPlugin("app", "core"), new StubPlugin("core")]); + + Assert.Equal(["core", "app"], Ids(sorted)); + } + + [Fact] + public void Sort_TransitiveChain_IsOrdered() + { + var sorted = PluginDependencyResolver.Sort( + [new StubPlugin("web", "app"), new StubPlugin("app", "core"), new StubPlugin("core")] + ); + + Assert.Equal(["core", "app", "web"], Ids(sorted)); + } + + [Fact] + public void Sort_DependencyIdsAreCaseInsensitive() + { + var sorted = PluginDependencyResolver.Sort([new StubPlugin("App", "CORE"), new StubPlugin("core")]); + + Assert.Equal(["core", "App"], Ids(sorted)); + } + + [Fact] + public void Sort_DuplicateId_Throws() + { + var ex = Assert.Throws( + () => PluginDependencyResolver.Sort([new StubPlugin("dup"), new StubPlugin("DUP")]) + ); + + Assert.Contains("dup", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Sort_MissingDependency_ThrowsNamingBoth() + { + var ex = Assert.Throws( + () => PluginDependencyResolver.Sort([new StubPlugin("app", "ghost")]) + ); + + Assert.Contains("app", ex.Message); + Assert.Contains("ghost", ex.Message); + } + + [Fact] + public void Sort_Cycle_Throws() + { + var ex = Assert.Throws( + () => PluginDependencyResolver.Sort([new StubPlugin("a", "b"), new StubPlugin("b", "a")]) + ); + + Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Sort_SelfDependency_Throws() + => Assert.Throws(() => PluginDependencyResolver.Sort([new StubPlugin("a", "a")])); +} diff --git a/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs b/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs new file mode 100644 index 00000000..abaa5633 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs @@ -0,0 +1,24 @@ +using DryIoc; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +namespace SquidStd.Tests.Plugin.Support; + +internal sealed class StubPlugin : ISquidStdPlugin +{ + public PluginMetadata Metadata { get; } + + public StubPlugin(string id, params string[] dependencies) + { + Metadata = new() + { + Id = id, + Name = id, + Version = new(1, 0, 0), + Author = "Tests", + Dependencies = dependencies + }; + } + + public void Configure(IContainer container, PluginContext context) { } +} From 50639116bc796ebcaa2e90d1fafa111a56430bcf Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 10:39:59 +0200 Subject: [PATCH 4/9] feat(plugin): add external plugin assembly scanner Scans a directory for *.dll files, loads them into the default AssemblyLoadContext (trusted plugins, shared type identity), and instantiates every concrete ISquidStdPlugin. Assemblies without plugins are skipped with a debug log; load and instantiation failures and a missing directory fail fast with PluginLoadException. Tests emit plugin assemblies at run time with Roslyn, so no binary fixtures are committed. --- .../Internal/PluginAssemblyScanner.cs | 80 +++++++++++++++++++ .../Plugin/PluginAssemblyScannerTests.cs | 74 +++++++++++++++++ .../Plugin/Support/PluginAssemblyFactory.cs | 68 ++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs create mode 100644 tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs create mode 100644 tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs diff --git a/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs b/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs new file mode 100644 index 00000000..c3a5d8a0 --- /dev/null +++ b/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using Serilog; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Exceptions; + +namespace SquidStd.Plugin.Internal; + +/// +/// Scans a directory for external plugin assemblies, loads each into the default +/// AssemblyLoadContext, and instantiates every concrete found. +/// +internal static class PluginAssemblyScanner +{ + private static readonly ILogger Logger = Log.ForContext(typeof(PluginAssemblyScanner)); + + /// + /// Scans for *.dll files and returns every concrete + /// instantiated from them. + /// + /// The directory to scan for plugin assemblies. + /// + /// The directory does not exist, an assembly failed to load, or a plugin type failed to instantiate. + /// + public static IReadOnlyList Scan(string directory) + { + if (!Directory.Exists(directory)) + { + throw new PluginLoadException($"Plugin directory '{directory}' does not exist."); + } + + var plugins = new List(); + var dllPaths = Directory.EnumerateFiles(directory, "*.dll") + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); + + foreach (var dllPath in dllPaths) + { + List pluginTypes; + + try + { + // Default AssemblyLoadContext: plugins are trusted and share type identity + // with the host (ISquidStdPlugin, DryIoc). No unload, no version isolation. + var assembly = Assembly.LoadFrom(dllPath); + + pluginTypes = assembly.GetTypes() + .Where(type => typeof(ISquidStdPlugin).IsAssignableFrom(type) && + type is { IsClass: true, IsAbstract: false }) + .ToList(); + } + catch (Exception ex) + { + throw new PluginLoadException($"Failed to load plugin assembly '{dllPath}'.", ex); + } + + if (pluginTypes.Count == 0) + { + Logger.Debug("No plugins found in {AssemblyPath:l}", dllPath); + + continue; + } + + foreach (var pluginType in pluginTypes) + { + try + { + plugins.Add((ISquidStdPlugin)Activator.CreateInstance(pluginType)!); + } + catch (Exception ex) + { + throw new PluginLoadException( + $"Failed to instantiate plugin type '{pluginType.FullName}' from '{dllPath}'.", + ex + ); + } + } + } + + return plugins; + } +} diff --git a/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs b/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs new file mode 100644 index 00000000..d017450e --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs @@ -0,0 +1,74 @@ +using SquidStd.Plugin.Exceptions; +using SquidStd.Plugin.Internal; +using SquidStd.Tests.Plugin.Support; + +namespace SquidStd.Tests.Plugin; + +public class PluginAssemblyScannerTests +{ + [Fact] + public void Scan_MissingDirectory_Throws() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + var ex = Assert.Throws(() => PluginAssemblyScanner.Scan(missing)); + + Assert.Contains(missing, ex.Message); + } + + [Fact] + public void Scan_DirectoryWithPluginAssembly_LoadsAndInstantiates() + { + var directory = Directory.CreateTempSubdirectory(); + + try + { + PluginAssemblyFactory.CompilePluginAssembly(directory.FullName, "tests.generated"); + + var plugins = PluginAssemblyScanner.Scan(directory.FullName); + + Assert.Single(plugins); + Assert.Equal("tests.generated", plugins[0].Metadata.Id); + } + finally + { + directory.Delete(true); + } + } + + [Fact] + public void Scan_AssemblyWithoutPlugins_IsSkipped() + { + var directory = Directory.CreateTempSubdirectory(); + + try + { + PluginAssemblyFactory.CompileNonPluginAssembly(directory.FullName); + + var plugins = PluginAssemblyScanner.Scan(directory.FullName); + + Assert.Empty(plugins); + } + finally + { + directory.Delete(true); + } + } + + [Fact] + public void Scan_CorruptDll_Throws() + { + var directory = Directory.CreateTempSubdirectory(); + + try + { + File.WriteAllBytes(Path.Combine(directory.FullName, "broken.dll"), [1, 2, 3, 4]); + + Assert.Throws(() => PluginAssemblyScanner.Scan(directory.FullName)); + } + finally + { + directory.Delete(true); + } + } +} diff --git a/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs b/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs new file mode 100644 index 00000000..ee3444d4 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs @@ -0,0 +1,68 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace SquidStd.Tests.Plugin.Support; + +internal static class PluginAssemblyFactory +{ + public static string CompilePluginAssembly(string directory, string pluginId) + { + var source = $$""" + using DryIoc; + using SquidStd.Plugin.Abstractions.Data; + using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + + public sealed class GeneratedPlugin : ISquidStdPlugin + { + public PluginMetadata Metadata { get; } = new() + { + Id = "{{pluginId}}", + Name = "Generated Plugin", + Version = new(1, 0, 0), + Author = "Tests" + }; + + public void Configure(IContainer container, PluginContext context) + => container.RegisterInstance("{{pluginId}}", serviceKey: "plugin-marker"); + } + """; + + return Emit(directory, "generated-plugin.dll", source); + } + + public static string CompileNonPluginAssembly(string directory) + => Emit(directory, "not-a-plugin.dll", "public sealed class NotAPlugin { }"); + + private static string Emit(string directory, string fileName, string source) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + // Earlier tests in this run may have loaded plugin assemblies from temp + // directories that have since been deleted (Scan uses Assembly.LoadFrom into + // the default ALC, which never unloads). Skip those so a stale Location doesn't + // blow up MetadataReference.CreateFromFile for unrelated tests. + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location) && + File.Exists(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast() + .ToList(); + + var compilation = CSharpCompilation.Create( + $"GeneratedPluginAssembly_{Guid.NewGuid():N}", + [CSharpSyntaxTree.ParseText(source)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var path = Path.Combine(directory, fileName); + var result = compilation.Emit(path); + + if (!result.Success) + { + throw new InvalidOperationException( + string.Join(Environment.NewLine, result.Diagnostics.Select(d => d.ToString())) + ); + } + + return path; + } +} From 60dae7a0f18e529707f067148710a801a6d53106 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 10:53:49 +0200 Subject: [PATCH 5/9] feat(plugin): add UsePlugins bootstrap extension and loader sample UsePlugins collects internal and external plugins, sorts them across the whole set, and configures each against the bootstrap container before startup, populating the standard PluginContext keys. Guarded to the Created bootstrap state. The plugins sample now boots through the loader. --- samples/SquidStd.Samples.Plugins/Program.cs | 17 ++- .../SquidStd.Samples.Plugins.csproj | 2 + .../SquidStdBootstrapPluginExtensions.cs | 84 +++++++++++ .../SquidStdBootstrapPluginExtensionsTests.cs | 130 ++++++++++++++++++ 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs create mode 100644 tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs diff --git a/samples/SquidStd.Samples.Plugins/Program.cs b/samples/SquidStd.Samples.Plugins/Program.cs index 5822c920..56610617 100644 --- a/samples/SquidStd.Samples.Plugins/Program.cs +++ b/samples/SquidStd.Samples.Plugins/Program.cs @@ -1,6 +1,9 @@ using DryIoc; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Plugin.Abstractions.Data; using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; namespace SquidStd.Samples.Plugins; @@ -41,16 +44,16 @@ private static void Main() { #region step-2 - var container = new Container(); - var context = new PluginContext(); - context.Data["startedAt"] = DateTimeOffset.UtcNow; - - ISquidStdPlugin plugin = new WeatherPlugin(); + var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugins-sample", RootDirectory = Directory.GetCurrentDirectory() } + ); + var plugin = new WeatherPlugin(); Console.WriteLine($"Loading {plugin.Metadata.Name} v{plugin.Metadata.Version} by {plugin.Metadata.Author}"); - plugin.Configure(container, context); - var greeter = container.Resolve(); + bootstrap.UsePlugins(plugins => plugins.Add(plugin)); + + var greeter = bootstrap.Resolve(); Console.WriteLine(greeter.Greet("squid")); #endregion diff --git a/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj index 900e30a0..665d155e 100644 --- a/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj +++ b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs new file mode 100644 index 00000000..947b1723 --- /dev/null +++ b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs @@ -0,0 +1,84 @@ +using System.Reflection; +using Serilog; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Types.Bootstrap; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Internal; + +namespace SquidStd.Plugin.Extensions; + +/// +/// Connects the plugin loader to the SquidStd bootstrap. +/// +public static class SquidStdBootstrapPluginExtensions +{ + /// + /// Collects internal plugins and external plugin directories, resolves the dependency order + /// across the whole set, and invokes for each plugin + /// in order against the bootstrap container. Must be called before the bootstrap starts, so + /// plugins can register configuration sections before the configuration is loaded. Relative + /// directories are resolved against the bootstrap root directory. Any failure aborts startup: + /// loader problems raise and plugin exceptions + /// propagate unchanged. + /// + /// The bootstrap to configure. + /// Callback that registers plugins and directories. + /// The same bootstrap for chaining. + public static ISquidStdBootstrap UsePlugins( + this ISquidStdBootstrap bootstrap, + Action configure + ) + { + ArgumentNullException.ThrowIfNull(bootstrap); + ArgumentNullException.ThrowIfNull(configure); + + if (bootstrap.State != BootstrapStateType.Created) + { + throw new InvalidOperationException( + "Plugins must be registered before the bootstrap starts." + ); + } + + var logger = Log.ForContext(typeof(SquidStdBootstrapPluginExtensions)); + var builder = new PluginCollectionBuilder(); + + configure(builder); + + var plugins = new List(builder.Plugins); + + foreach (var directory in builder.Directories) + { + var resolved = Path.IsPathRooted(directory) + ? directory + : Path.Combine(bootstrap.Options.RootDirectory, directory); + + plugins.AddRange(PluginAssemblyScanner.Scan(resolved)); + } + + var ordered = PluginDependencyResolver.Sort(plugins); + var appName = bootstrap.Options.AppName + ?? Assembly.GetEntryAssembly()?.GetName().Name + ?? bootstrap.Options.ConfigName; + + foreach (var plugin in ordered) + { + var context = new PluginContext(); + context.Data[PluginContextKeys.RootDirectory] = bootstrap.Options.RootDirectory; + context.Data[PluginContextKeys.AppName] = appName; + + plugin.Configure(bootstrap.Container, context); + + logger.Information( + "Loaded plugin {PluginId:l} v{PluginVersion} by {PluginAuthor:l}", + plugin.Metadata.Id, + plugin.Metadata.Version, + plugin.Metadata.Author + ); + } + + logger.Information("{PluginCount} plugin(s) loaded", ordered.Count); + + return bootstrap; + } +} diff --git a/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs b/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs new file mode 100644 index 00000000..f9e159f9 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs @@ -0,0 +1,130 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Plugin.Support; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Plugin; + +public class SquidStdBootstrapPluginExtensionsTests +{ + private sealed class RecorderPlugin : ISquidStdPlugin + { + private readonly List _order; + + public PluginMetadata Metadata { get; } + + public IContainer? SeenContainer { get; private set; } + + public PluginContext? SeenContext { get; private set; } + + public RecorderPlugin(string id, List order, params string[] dependencies) + { + _order = order; + Metadata = new() + { + Id = id, + Name = id, + Version = new(1, 0, 0), + Author = "Tests", + Dependencies = dependencies + }; + } + + public void Configure(IContainer container, PluginContext context) + { + SeenContainer = container; + SeenContext = context; + _order.Add(Metadata.Id); + } + } + + private sealed class ThrowingPlugin : ISquidStdPlugin + { + public PluginMetadata Metadata { get; } = new() + { + Id = "tests.throwing", + Name = "Throwing", + Version = new(1, 0, 0), + Author = "Tests" + }; + + public void Configure(IContainer container, PluginContext context) + => throw new InvalidOperationException("boom"); + } + + [Fact] + public async Task UsePlugins_ConfiguresInDependencyOrder_WithBootstrapContainerAndContext() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path, AppName = "PluginHost" } + ); + + var order = new List(); + var app = new RecorderPlugin("tests.app", order, "tests.core"); + var core = new RecorderPlugin("tests.core", order); + + bootstrap.UsePlugins(plugins => plugins.Add(app).Add(core)); + + Assert.Equal(["tests.core", "tests.app"], order); + Assert.Same(bootstrap.Container, app.SeenContainer); + Assert.Equal(root.Path, core.SeenContext!.GetData(PluginContextKeys.RootDirectory)); + Assert.Equal("PluginHost", core.SeenContext!.GetData(PluginContextKeys.AppName)); + } + + [Fact] + public async Task UsePlugins_LoadsExternalPluginsFromRelativeDirectory() + { + using var root = new TempDirectory(); + var pluginsDir = Directory.CreateDirectory(root.Combine("plugins")); + PluginAssemblyFactory.CompilePluginAssembly(pluginsDir.FullName, "tests.external"); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path } + ); + + bootstrap.UsePlugins(plugins => plugins.FromDirectory("plugins")); + + Assert.Equal("tests.external", bootstrap.Container.Resolve(serviceKey: "plugin-marker")); + } + + [Fact] + public async Task UsePlugins_AfterStart_Throws() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path } + ); + + await bootstrap.StartAsync(); + + try + { + Assert.Throws(() => bootstrap.UsePlugins(_ => { })); + } + finally + { + await bootstrap.StopAsync(); + } + } + + [Fact] + public async Task UsePlugins_ConfigureException_Propagates() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path } + ); + + Assert.Throws( + () => bootstrap.UsePlugins(plugins => plugins.Add(new ThrowingPlugin())) + ); + } +} From 5f494a49897c3a8f816ef91c031b507a4de6d45b Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 11:01:11 +0200 Subject: [PATCH 6/9] fix(plugin): treat blank AppName like null in plugin context fallback --- .../Extensions/SquidStdBootstrapPluginExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs index 947b1723..e86cc733 100644 --- a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs +++ b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs @@ -57,9 +57,9 @@ Action configure } var ordered = PluginDependencyResolver.Sort(plugins); - var appName = bootstrap.Options.AppName - ?? Assembly.GetEntryAssembly()?.GetName().Name - ?? bootstrap.Options.ConfigName; + var appName = !string.IsNullOrWhiteSpace(bootstrap.Options.AppName) + ? bootstrap.Options.AppName + : Assembly.GetEntryAssembly()?.GetName().Name ?? bootstrap.Options.ConfigName; foreach (var plugin in ordered) { From 743c485c901eb14b4b56bbde5276b7315a2e5071 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 11:05:20 +0200 Subject: [PATCH 7/9] docs(plugin): document the SquidStd.Plugin loader Package README with the UsePlugins example, loading rules, context keys and the trusted-plugin contract; docs article and toc entry; pointer from the abstractions README. --- docs/articles/plugin.md | 1 + docs/articles/toc.yml | 2 + docs/tutorials/plugins.md | 1 + src/SquidStd.Plugin.Abstractions/README.md | 1 + src/SquidStd.Plugin/README.md | 58 ++++++++++++++++++++++ 5 files changed, 63 insertions(+) create mode 100644 docs/articles/plugin.md create mode 100644 src/SquidStd.Plugin/README.md diff --git a/docs/articles/plugin.md b/docs/articles/plugin.md new file mode 100644 index 00000000..589956b6 --- /dev/null +++ b/docs/articles/plugin.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Plugin/README.md)] diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 14ced565..ba8e7e9d 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -46,6 +46,8 @@ href: network.md - name: SquidStd.Plugin.Abstractions href: plugin-abstractions.md +- name: SquidStd.Plugin + href: plugin.md - name: SquidStd.Database.Abstractions href: database-abstractions.md - name: SquidStd.Database diff --git a/docs/tutorials/plugins.md b/docs/tutorials/plugins.md index 1cff0815..24244d78 100644 --- a/docs/tutorials/plugins.md +++ b/docs/tutorials/plugins.md @@ -52,3 +52,4 @@ one plugin by hand so the contract is fully runnable without external assemblies ## See also - [SquidStd.Plugin.Abstractions reference](../articles/plugin-abstractions.md) +- [SquidStd.Plugin reference](../articles/plugin.md) - the loader that discovers and configures plugins in dependency order diff --git a/src/SquidStd.Plugin.Abstractions/README.md b/src/SquidStd.Plugin.Abstractions/README.md index 4506a924..09eb5adc 100644 --- a/src/SquidStd.Plugin.Abstractions/README.md +++ b/src/SquidStd.Plugin.Abstractions/README.md @@ -45,6 +45,7 @@ public sealed class MyPlugin : ISquidStdPlugin ## Related - Tutorial: [Plugins](https://tgiachi.github.io/squid-std/tutorials/plugins.html) +- Loader: these contracts are loaded by the [SquidStd.Plugin](https://tgiachi.github.io/squid-std/articles/plugin.html) package via `UsePlugins`. ## License diff --git a/src/SquidStd.Plugin/README.md b/src/SquidStd.Plugin/README.md new file mode 100644 index 00000000..6dcfd475 --- /dev/null +++ b/src/SquidStd.Plugin/README.md @@ -0,0 +1,58 @@ +

SquidStd.Plugin

+ +Loader for SquidStd plugins. Wires internal and external `ISquidStdPlugin` implementations into the +bootstrap: it collects them, resolves the dependency order across the whole set, and calls `Configure` +on each one before the bootstrap starts. + +## Install + +```bash +dotnet add package SquidStd.Plugin +``` + +## Usage + +```csharp +using SquidStd.Plugin.Extensions; + +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "myapp", + RootDirectory = "./data" +}); + +bootstrap.UsePlugins(plugins => +{ + plugins.Add(); // internal, by type + plugins.Add(new MetricsPlugin()); // internal, by instance + plugins.FromDirectory("plugins"); // external assemblies (*.dll) +}); + +await bootstrap.RunAsync(); +``` + +## How loading works + +- Plugins are ordered by dependency: `PluginMetadata.Dependencies` (plugin ids, compared + case-insensitively) is resolved across internal and external plugins together, so an external plugin + can depend on an internal one and vice versa. +- `Configure` runs before the configuration load, so plugins can register their own configuration + sections and services ahead of time. +- Each plugin receives a `PluginContext` populated with the standard keys: `PluginContextKeys.RootDirectory` + (the bootstrap root directory) and `PluginContextKeys.AppName` (the bootstrap app name). + +## Trusted plugins + +External assemblies load into the default `AssemblyLoadContext`: there is no unloading and no +per-plugin version isolation, so plugins are expected to be trusted. A failing plugin stops startup, +either with a `PluginLoadException` (discovery, ordering, or instantiation failure) or with the +plugin's own exception from `Configure`. + +## Related + +- Contracts: [SquidStd.Plugin.Abstractions](https://tgiachi.github.io/squid-std/articles/plugin-abstractions.html) +- [SquidStd.Core](https://tgiachi.github.io/squid-std/articles/core.html) + +## License + +MIT - part of [SquidStd](https://github.com/tgiachi/squid-std). From 0c5141c0fa0870bfad5f5d4aa2bf59bff5d426bc Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 11:15:02 +0200 Subject: [PATCH 8/9] docs(plugin): add missing usings to the README usage example --- src/SquidStd.Plugin/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SquidStd.Plugin/README.md b/src/SquidStd.Plugin/README.md index 6dcfd475..122f12a9 100644 --- a/src/SquidStd.Plugin/README.md +++ b/src/SquidStd.Plugin/README.md @@ -13,7 +13,9 @@ dotnet add package SquidStd.Plugin ## Usage ```csharp +using SquidStd.Core.Data.Bootstrap; using SquidStd.Plugin.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions { From cab036dedad15632f16ca7173f4569ba7f695c1c Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Jul 2026 11:20:17 +0200 Subject: [PATCH 9/9] docs(plugin): clarify trust contract, logging visibility and Add semantics --- .../Extensions/SquidStdBootstrapPluginExtensions.cs | 5 ++++- src/SquidStd.Plugin/PluginCollectionBuilder.cs | 2 +- src/SquidStd.Plugin/README.md | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs index e86cc733..9de57327 100644 --- a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs +++ b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs @@ -20,7 +20,10 @@ public static class SquidStdBootstrapPluginExtensions /// plugins can register configuration sections before the configuration is loaded. Relative /// directories are resolved against the bootstrap root directory. Any failure aborts startup: /// loader problems raise and plugin exceptions - /// propagate unchanged. + /// propagate unchanged. Plugin assemblies load into the default AssemblyLoadContext and are + /// fully trusted: there is no unloading and no version isolation. Note that plugin load + /// logging is emitted before the bootstrap configures Serilog, so it is only visible when + /// a logger is configured beforehand. /// /// The bootstrap to configure. /// Callback that registers plugins and directories. diff --git a/src/SquidStd.Plugin/PluginCollectionBuilder.cs b/src/SquidStd.Plugin/PluginCollectionBuilder.cs index 28fdd8c2..324601cb 100644 --- a/src/SquidStd.Plugin/PluginCollectionBuilder.cs +++ b/src/SquidStd.Plugin/PluginCollectionBuilder.cs @@ -16,7 +16,7 @@ public sealed class PluginCollectionBuilder internal IReadOnlyList Directories => _directories; /// - /// Registers an internal plugin by type; the loader instantiates it via the public + /// Registers an internal plugin by type; it is instantiated immediately via the public /// parameterless constructor. /// /// The plugin type. diff --git a/src/SquidStd.Plugin/README.md b/src/SquidStd.Plugin/README.md index 122f12a9..ec54bac8 100644 --- a/src/SquidStd.Plugin/README.md +++ b/src/SquidStd.Plugin/README.md @@ -50,6 +50,10 @@ per-plugin version isolation, so plugins are expected to be trusted. A failing p either with a `PluginLoadException` (discovery, ordering, or instantiation failure) or with the plugin's own exception from `Configure`. +Call `UsePlugins` before `ConfigureLogging()` or any start method: the bootstrap loads its +configuration during startup, and config sections registered by plugins after that point are never +loaded. + ## Related - Contracts: [SquidStd.Plugin.Abstractions](https://tgiachi.github.io/squid-std/articles/plugin-abstractions.html)