diff --git a/scripts/type-forwards.allow b/scripts/type-forwards.allow index 348cb103e9..f1aed140c7 100644 --- a/scripts/type-forwards.allow +++ b/scripts/type-forwards.allow @@ -26,12 +26,3 @@ # pre-split). A forwarder is impossible here: the base would have to reference the AI assembly, # which references the base (the documented reference-cycle case). -# #2771 — ObservableAwait leaves MeshWeaver.Fixture for MeshWeaver.Messaging.Hub (namespace -# MeshWeaver.Fixture → MeshWeaver.Messaging), so product code gets the deadlock-safe bridge. No -# shipped package can hold the TypeRef: the type was added 2026-08-30 by #2750, one day AFTER the -# last release (v3.0.0-rc8, 2026-08-29), and the shipped MeshWeaver.Fixture 3.0.0-rc8 nupkg was -# pulled from nuget.org and inspected — it contains no ObservableAwait (control: HubTestBase, -# TestBase, ConfigureMesh all present in the same DLL). Fixture is in no module bundle or bake -# surface; its only consumers are TestBase assemblies and test projects that compile from source. -# A forwarder is impossible without keeping `namespace MeshWeaver.Fixture` inside a product -# assembly, which would tell product code to `using MeshWeaver.Fixture` — the wrong direction. diff --git a/src/MeshWeaver.Documentation/Data/Architecture/BuildProcess.md b/src/MeshWeaver.Documentation/Data/Architecture/BuildProcess.md new file mode 100644 index 0000000000..8e2688c2ec --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/Architecture/BuildProcess.md @@ -0,0 +1,94 @@ +--- +Name: The Build Process — compile and test as a dependency cascade +Category: Architecture +Description: How a node repo is built — per package, compile AND run tests, as a reactive cascade over the package dependency network, inside the container the platform build produced, with no mesh import and no core source checkout. The design, the invariants, the timings it reports, and what the mesh lane still owns. +Icon: Hammer +--- + +# The Build Process — compile and test as a dependency cascade + +**Decision (maintainer, 2026-08-30).** A node repo is built by *our own* build runner and test +runner, not by `dotnet test` over xUnit projects that reference a core source checkout. The runner +is the platform's own container (`mw-plugin-test`, the image the platform BUILD produces), the +verb is `build`, and the lane that starts it is one small workflow. + +``` +mw-plugin-test build [... | all] [--module ]... [--out ] \ + [--report ] [--max-parallel ] [--case-timeout ] [--no-tests] [--source-sha ] +``` + +## The invariants + +1. **Build means compile AND run tests.** They are one unit per package; there is no "it compiled" + verdict without the tests, and no test run without the compile that produced its assembly. +2. **A cascade, not a schedule.** Every package has a result stream. A package *observes* the + streams of the packages it `requires` and starts itself the moment the last one completes + green. No level table, no topological list: the graph is the schedule, so packages with no + edge between them build at the same time (bounded by `--max-parallel`), and a package with + three dependencies starts the instant the slowest lands. +3. **On red we break; on green we continue.** A package whose dependency did not end green never + starts: it completes at once as *blocked by \*, and that verdict cascades. A failure + is reported exactly once, where it happened; everything above it reads "blocked by X", never a + second, derived failure. A cycle is refused up front and named. +4. **One execution per package, however many dependents.** A package's stream is shared + (`PublishLast`): the first subscriber starts the work, every later subscriber gets the same + single result, and nothing is rebuilt because two packages both required it. +5. **No mesh, no import.** Sources are read from the git checkout on disk and composed into Roslyn + compilations *exactly as the portal composes them* — the same skeleton, the same options, the + image's `/app` as the reference set (`NodeSetCompiler`, the code path the portals run at + runtime). Each package additionally compiles against the assemblies its dependency packages + just emitted. Grains cannot carry a Roslyn workload; a build process can. +6. **Input is one or many packages, or `all`.** Naming packages selects them *and* their transitive + requirements inside the repo, so a single package builds on freshly built dependencies rather + than on nothing. `all` (the default) is the full rebuild — the platform-rebuild case, where + every package must be re-proven against the new image. +7. **Timings are part of the result.** Every package carries when it became *ready* (its last + dependency finished), when it *started* (a slot was free) and when it *finished*, plus the + compile and test splits and per-type compile times. The report prints them, the **critical + path** (the chain whose serial length is the wall-clock floor) and the parallel speed-up; + `--report` writes it all as JSON, and the lane renders it into the job summary. +8. **Fails red, never skips.** The lane asserts its inputs first; an image that does not know the + verb exits 2 and the step names the fix. Nobody reads a green that ran nothing. + +## Tests without a mesh + +The in-mesh test convention is `/Test/*.cs`: **static classes whose public static +parameterless methods throw on failure**, listed by a `Tests` layout area. The runner executes +those methods straight from the emitted assembly in a collectible load context, each case timed +and capped by `--case-timeout`. A case that takes a host (the layout-area aggregator, anything +needing a hub) is **counted and named as `needs-mesh`**, never dropped; the gate +(`mw-plugin-test --seed `) still runs those, seeded from the build's `--out` so +nothing is compiled twice. + +## The parity flag + +The portal reaches other packages' types by `shared=` *source inclusion*, never by referencing +their emitted assemblies. This build references them (the maintainer's instruction: use the +references the dependency packages produce). A type whose emitted assembly turns out to *bind* a +dependency package's assembly is therefore green here on grounds the portal does not have, and the +report marks it `binds-dependency-assembly` — visible in the table, not discovered as a +`CompileError` in production. + +## What this replaces, and what it retires + +- **`MeshWeaver.Fixture` and the two TestBase assemblies are the platform's own test support.** + They live under `test/` and are never packed or published (they were a NuGet package only + because they once lived under `src/`). A node repo does not reference them and does not need a + `$(MeshWeaverRoot)` source checkout to test: the platform is the image, pinned by digest. +- **xUnit projects in node repos** are the migration's subject. The measured shapes (core: + 1,205 test files, 6,295 facts; 535 of those files mesh- or hub-based) convert mechanically once + the runner offers one primitive — a per-class mesh factory replacing the `MonolithMeshTestBase` + constructor — plus a log sink for `ITestOutputHelper`, loops for `[Theory]` rows and a classified + skip. Substrate suites (Postgres via Testcontainers, Orleans TestingHost, `HtmlRenderer`) run in + the runner *process*, never in a grain. Browser E2E stays a browser lane. + +## Where the pieces are + +| piece | where | +|---|---| +| the cascade (generic, unit-tested) | `tools/MeshWeaver.PluginTester/Cascade.cs` | +| the `build` verb | `tools/MeshWeaver.PluginTester/CascadeBuild.cs`, `Program.cs` | +| static test execution | `tools/MeshWeaver.PluginTester/StaticTestRunner.cs` | +| the compile composition it reuses | `MeshWeaver.Compiler` — `NodeSetCompiler`, `CompileReferences` | +| the lane (node repos) | `.github/workflows/build-cascade.yml` in each node repo | +| the tester's README | `tools/MeshWeaver.PluginTester/README.md` (`build`) | diff --git a/src/MeshWeaver.Documentation/Data/Architecture/WritingTests.md b/src/MeshWeaver.Documentation/Data/Architecture/WritingTests.md index a7eec3b512..b59a6bdc01 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/WritingTests.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/WritingTests.md @@ -101,6 +101,28 @@ Before writing a test, review the invariants every test must respect: --- +## In-mesh tests and the build process (node repos) + +A NodeType in a node repo ships its tests as `/Test/*.cs` — **static classes whose public +static parameterless methods throw on failure** — and a `Tests` layout area that lists them. Since +2026-08-30 those run through the container the platform build produced, not through xUnit: + +``` +mw-plugin-test build [... | all] +``` + +**Build means compile and run tests**, per package, as a dependency cascade: a package observes +the result streams of the packages it requires and starts itself when the last one is green; on +red the dependents are blocked by name, on green they continue; independent packages build in +parallel; every package reports its timings. Sources come from the checkout on disk and compile +against the image's `/app` plus the assemblies the dependency packages just emitted — no mesh +import, no `$(MeshWeaverRoot)` source checkout, no `MeshWeaver.Fixture`. Cases that need a host +are counted as `needs-mesh` and run by the gate, seeded from the build's output. See +`tools/MeshWeaver.PluginTester/README.md` (`build`). + +`MeshWeaver.Fixture` and the two TestBase assemblies are this repo's OWN test support: they live +under `test/` and are never packed or published. + ## The Canonical Test Base Every monolith test inherits `MonolithMeshTestBase`. The shape is always the same: diff --git a/test/MeshWeaver.PluginTester.Test/CascadeTest.cs b/test/MeshWeaver.PluginTester.Test/CascadeTest.cs new file mode 100644 index 0000000000..89b3672a7b --- /dev/null +++ b/test/MeshWeaver.PluginTester.Test/CascadeTest.cs @@ -0,0 +1,190 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Reactive.Linq; +using MeshWeaver.Messaging; +using MeshWeaver.PluginTester; +using Xunit; + +namespace MeshWeaver.PluginTester.Test; + +/// +/// The cascade's contract, tested without a compiler: the graph is ids, the work is a delegate. +/// Every invariant the maintainer stated on 2026-08-30 has a case here — build = one unit per +/// node; on red we break; on green we continue; a node observes its dependencies and starts +/// itself; independent nodes run in parallel; every node runs exactly once; timings are real. +/// +public class CascadeTest +{ + private static readonly IReadOnlyDictionary Diamond = + new Dictionary(StringComparer.Ordinal) + { + ["Store"] = [], + ["AI"] = ["Store"], + ["Observability"] = ["Store"], + ["Edu"] = ["Store", "AI"], + }; + + private static IReadOnlyList DepsOf(string id) => + Diamond.TryGetValue(id, out var d) ? d : []; + + [Fact] + public async Task ANodeStartsOnlyAfterEveryDependencyIsGreen_AndDependentsSeeTheirResults() + { + var seen = new ConcurrentDictionary(StringComparer.Ordinal); + var results = await Cascade.Run( + Diamond.Keys.ToArray(), DepsOf, + (id, deps) => + { + seen[id] = deps.Select(d => d.Id).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + return ($"built {id}", true); + }, + maxParallel: 4).Await(TestContext.Current.CancellationToken); + + Assert.Equal(4, results.Length); + Assert.All(results, r => Assert.True(r.IsGreen, $"{r.Id}: every node's work returned green, so nothing is red or blocked")); + Assert.Empty(seen["Store"]); + Assert.Equal(["Store"], seen["AI"]); + Assert.Equal(["AI", "Store"], seen["Edu"]); // Edu observes both dependencies' streams and starts only after both completed + + var byId = results.ToDictionary(r => r.Id, StringComparer.Ordinal); + Assert.True(byId["AI"].Ready >= byId["Store"].Finished, "a node is READY no earlier than its last dependency FINISHED — that is the cascade"); + Assert.True(byId["Edu"].Ready >= byId["AI"].Finished); + Assert.Equal("built Edu", byId["Edu"].Result); + } + + [Fact] + public async Task OnRedWeBreak_TheFailureIsReportedOnceAndEveryDependentIsBlockedByName() + { + var ran = new ConcurrentBag(); + var results = await Cascade.Run( + Diamond.Keys.ToArray(), DepsOf, + (id, _) => + { + ran.Add(id); + return (id, id != "AI"); // AI fails + }, + maxParallel: 4).Await(TestContext.Current.CancellationToken); + + var byId = results.ToDictionary(r => r.Id, StringComparer.Ordinal); + Assert.Equal(Cascade.NodeOutcome.Green, byId["Store"].Outcome); + Assert.Equal(Cascade.NodeOutcome.Red, byId["AI"].Outcome); // AI's own work failed + Assert.Equal(Cascade.NodeOutcome.Blocked, byId["Edu"].Outcome); // Edu requires AI; on red we break + Assert.Equal("AI", byId["Edu"].BlockedBy); // the block names the dependency that stopped it + Assert.Equal(Cascade.NodeOutcome.Green, byId["Observability"].Outcome); // requires only Store: on green we continue + Assert.DoesNotContain("Edu", ran); // a blocked node's work must never run + Assert.Equal(TimeSpan.Zero, byId["Edu"].Work); // a derived verdict costs no work time + } + + [Fact] + public async Task AFaultingWorkFunctionIsReportedAsFaulted_NotAsACrashOfTheWholeBuild() + { + var results = await Cascade.Run( + Diamond.Keys.ToArray(), DepsOf, + (id, _) => id == "Store" ? throw new InvalidOperationException("disk on fire") : (id, true), + maxParallel: 2).Await(TestContext.Current.CancellationToken); + + var byId = results.ToDictionary(r => r.Id, StringComparer.Ordinal); + Assert.Equal(Cascade.NodeOutcome.Faulted, byId["Store"].Outcome); + Assert.Contains("InvalidOperationException", byId["Store"].Error); Assert.Contains("disk on fire", byId["Store"].Error); + Assert.All(results.Where(r => r.Id != "Store"), r => { Assert.Equal(Cascade.NodeOutcome.Blocked, r.Outcome); Assert.Equal("Store", r.BlockedBy); }); // all require Store + } + + [Fact] + public async Task EveryNodeRunsExactlyOnce_HoweverManyDependentsShareIt() + { + var runs = new ConcurrentDictionary(StringComparer.Ordinal); + var wide = new Dictionary(StringComparer.Ordinal) { ["Base"] = [] }; + for (var i = 0; i < 20; i++) + wide[$"Leaf{i:D2}"] = ["Base"]; + + await Cascade.Run( + wide.Keys.ToArray(), id => wide[id], + (id, _) => + { + runs.AddOrUpdate(id, 1, (_, n) => n + 1); + return (0, true); + }, + maxParallel: 8).Await(TestContext.Current.CancellationToken); + + Assert.Equal(21, runs.Count); + Assert.All(runs.Values, n => Assert.Equal(1, n)); // twenty dependents, ONE execution of Base + } + + [Fact] + public async Task IndependentNodesRunInParallel_BoundedByTheSlotCount() + { + const int nodes = 6; + const int slots = 3; + var inFlight = 0; + var peak = 0; + var flat = Enumerable.Range(0, nodes).Select(i => $"N{i}").ToArray(); + + var results = await Cascade.Run( + flat, _ => [], + (_, _) => + { + var now = Interlocked.Increment(ref inFlight); + int seen; + do + { + seen = Volatile.Read(ref peak); + } while (now > seen && Interlocked.CompareExchange(ref peak, now, seen) != seen); + Thread.Sleep(80); + Interlocked.Decrement(ref inFlight); + return (0, true); + }, + maxParallel: slots).Await(TestContext.Current.CancellationToken); + + Assert.Equal(nodes, results.Length); Assert.All(results, r => Assert.True(r.IsGreen)); + Assert.True(peak > 1, "nodes with no edge between them run at the same time"); + Assert.True(peak <= slots, $"peak {peak} exceeded the slot cap {slots}"); + Assert.Contains(results, r => r.Queued > TimeSpan.Zero); // six nodes on three slots: somebody waited + } + + [Fact] + public async Task ACycleIsRefusedUpFront_NamingIt() + { + var cyclic = new Dictionary(StringComparer.Ordinal) + { + ["A"] = ["B"], + ["B"] = ["C"], + ["C"] = ["A"], + }; + var ex = await Assert.ThrowsAsync( + () => Cascade.Run(cyclic.Keys.ToArray(), id => cyclic[id], (_, _) => (0, true), 2) + .Await(TestContext.Current.CancellationToken)); + Assert.Contains("cycle", ex.Message); Assert.Contains("A", ex.Message); Assert.Contains("C", ex.Message); + } + + [Fact] + public async Task DependenciesOutsideTheSelectionAreExternal_AndDoNotBlock() + { + // 'AI' requires 'Store', but only 'AI' is in the cascade: Store is somebody else's + // (the image, --module, another repo). AI must start on its own. + var results = await Cascade.Run( + ["AI"], DepsOf, (_, _) => (1, true), 2).Await(TestContext.Current.CancellationToken); + Assert.True(Assert.Single(results).IsGreen); + } + + [Fact] + public async Task CriticalPathIsTheChainThatEndsLast_LeafFirst() + { + var results = await Cascade.Run( + Diamond.Keys.ToArray(), DepsOf, + (id, _) => + { + Thread.Sleep(id == "AI" ? 120 : 20); + return (0, true); + }, + maxParallel: 4).Await(TestContext.Current.CancellationToken); + + var path = Cascade.CriticalPath(results, DepsOf); + Assert.Equal(["Store", "AI", "Edu"], path); // Edu finishes last; waited for AI (slow), which waited for Store + } + + [Fact] + public async Task AnEmptySelectionIsAnEmptyReport() + { + Assert.Empty(await Cascade.Run([], _ => [], (_, _) => (0, true), 1).Await(TestContext.Current.CancellationToken)); + } +} diff --git a/tools/MeshWeaver.PluginTester/Cascade.cs b/tools/MeshWeaver.PluginTester/Cascade.cs new file mode 100644 index 0000000000..90f92e55ec --- /dev/null +++ b/tools/MeshWeaver.PluginTester/Cascade.cs @@ -0,0 +1,272 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace MeshWeaver.PluginTester; + +/// +/// The dependency cascade behind mw-plugin-test build: every node OBSERVES the result +/// streams of the nodes it depends on and starts itself the moment the last of them completes +/// green. Nothing is scheduled by a level table or a topological list; the graph itself is the +/// schedule, so two packages with no edge between them build at the same time and a package with +/// three dependencies starts the instant the slowest one lands — the natural cascade the +/// maintainer asked for (2026-08-30: "we always observe stream for dependencies then start +/// ourselves"). +/// +/// Red breaks, green continues. A node whose dependency did not end green never +/// starts: it completes at once as , naming the dependency that +/// stopped it, and that verdict cascades to its own dependents. A failure is therefore reported +/// exactly once at the node that produced it, and every node above it reads "blocked by X" rather +/// than a second, derived failure. +/// +/// One execution per node, however many dependents. A node's stream is +/// -shared: the first subscriber +/// starts the work, every later subscriber receives the same single result, and nobody ever +/// re-runs a package because two packages both required it. +/// +/// Bounded parallelism without a blocking wait. Work is handed to one queue and +/// executed through Merge(maxParallel): Rx subscribes at most that many work items at a +/// time and takes the next when one completes. No semaphore, no thread parked on a wait — a +/// blocking bridge is exactly what the production inventory refuses. +/// +/// Timings are part of the result. Each outcome carries when the node became +/// READY (its last dependency completed), when it STARTED (a slot was granted) and when it +/// FINISHED, so a report can show queue time separately from work time and compute the critical +/// path — the maintainer wants the numbers, not a green wall. +/// +/// The cascade is generic and pure so it can be tested without a compiler: the work +/// function is injected and the graph is plain ids. +/// +public static class Cascade +{ + /// The verdict of one node of the cascade. + public enum NodeOutcome + { + /// The work ran and succeeded; dependents may start. + Green, + /// The work ran and failed; dependents are blocked. + Red, + /// The work never ran because a dependency was not green. + Blocked, + /// The work threw — treated as red for dependents, but reported apart. + Faulted, + } + + /// One node's result: the verdict, the work's own payload, and the three timestamps. + /// The payload the work function produces on a run (null when blocked). + /// The node id. + /// The verdict. + /// The work's payload, when the work ran. + /// The dependency that stopped this node, when blocked. + /// The exception text, when faulted. + /// When the last dependency completed (relative to the cascade's start). + /// When the work began — equals when no slot wait. + /// When the work ended (or the block was decided). + public sealed record NodeResult( + string Id, + NodeOutcome Outcome, + T? Result, + string? BlockedBy, + string? Error, + TimeSpan Ready, + TimeSpan Started, + TimeSpan Finished) + { + /// Time spent waiting for a slot after every dependency was in. + public TimeSpan Queued => Started - Ready; + + /// Time the work itself took. + public TimeSpan Work => Finished - Started; + + /// True when dependents may start. + public bool IsGreen => Outcome == NodeOutcome.Green; + } + + /// + /// Runs the cascade over , where names + /// each id's direct dependencies (ids outside the set are ignored — they are external, and + /// whoever built the set decided how they are satisfied) and does one + /// node's work, returning true for green. Emits every node's result once all have settled. + /// + /// The node ids in the cascade. + /// Direct dependencies per id. + /// The work: given the id and its dependencies' results, produce the payload + /// and whether it is green. Runs on the scheduler, at most at a + /// time. + /// Concurrency cap for the work function. Blocking a node costs no slot. + /// Where the work runs; the task pool by default. + /// The work's payload type. + public static IObservable>> Run( + IReadOnlyCollection ids, + Func> dependenciesOf, + Func>, (T Result, bool Green)> run, + int maxParallel, + IScheduler? scheduler = null) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(dependenciesOf); + ArgumentNullException.ThrowIfNull(run); + if (maxParallel < 1) + throw new ArgumentOutOfRangeException(nameof(maxParallel), maxParallel, "at least one slot"); + + return Observable.Defer(() => + { + var idSet = ids.ToImmutableHashSet(StringComparer.Ordinal); + if (idSet.IsEmpty) + return Observable.Return(ImmutableArray>.Empty); + if (FindCycle(idSet, dependenciesOf) is { } cycle) + return Observable.Throw>>(new InvalidOperationException( + $"the dependency graph has a cycle: {cycle} — a cascade over it would never " + + "start, because every node in the cycle waits for another one in it")); + + var clock = Stopwatch.StartNew(); + var pool = scheduler ?? TaskPoolScheduler.Default; + + // The concurrency gate. Every node hands its work here when it becomes ready; + // Merge(maxParallel) subscribes at most that many at a time and takes the next as one + // completes. The gate is subscribed BEFORE any node, so nothing is handed to a queue + // nobody is listening to. + var queue = new Subject>(); + var gate = queue.Merge(maxParallel).Subscribe(_ => { }, _ => { }); + + var nodes = new Dictionary>>(StringComparer.Ordinal); + + // Build the streams lazily and memoise them: a node's stream is created once and the + // same instance is handed to every dependent, so PublishLast shares one execution. + IObservable> NodeOf(string id) + { + if (nodes.TryGetValue(id, out var existing)) + return existing; + + var deps = dependenciesOf(id).Where(idSet.Contains).Distinct(StringComparer.Ordinal).ToArray(); + var upstream = deps.Length == 0 + ? Observable.Return>>(Array.Empty>()) + : deps.Select(NodeOf).CombineLatest(); + + var node = upstream + .Take(1) + .SelectMany(depResults => + { + var ready = clock.Elapsed; + var stopper = depResults.FirstOrDefault(d => !d.IsGreen); + if (stopper is not null) + { + // Blocked at once, no slot: the verdict is derived, not earned. + return Observable.Return(new NodeResult( + id, NodeOutcome.Blocked, default, stopper.Id, null, + ready, ready, ready)); + } + var ordered = depResults.ToArray(); + // The result travels back through its own subject; the work item handed + // to the gate is deferred so it starts when the gate subscribes it — + // that moment is 'Started', and Started - Ready is the slot wait. + var result = new AsyncSubject>(); + var work = Observable.Defer(() => Observable.Start(() => + { + var started = clock.Elapsed; + try + { + var (payload, green) = run(id, ordered); + return new NodeResult( + id, green ? NodeOutcome.Green : NodeOutcome.Red, payload, null, null, + ready, started, clock.Elapsed); + } + catch (Exception ex) + { + return new NodeResult( + id, NodeOutcome.Faulted, default, null, + $"{ex.GetType().Name}: {ex.Message}", ready, started, clock.Elapsed); + } + }, pool)) + .Do(r => + { + result.OnNext(r); + result.OnCompleted(); + }) + .Select(_ => Unit.Default); + queue.OnNext(work); + return result; + }) + .PublishLast() + .AutoConnect(); + nodes[id] = node; + return node; + } + + var all = idSet.OrderBy(i => i, StringComparer.Ordinal).Select(NodeOf).ToArray(); + return all.CombineLatest() + .Take(1) + .Select(results => results.OrderBy(r => r.Id, StringComparer.Ordinal).ToImmutableArray()) + .Finally(() => + { + queue.OnCompleted(); + gate.Dispose(); + }); + }); + } + + /// + /// The longest chain of work in the results (by finish time of the last node on it) — the + /// wall-clock floor no amount of parallelism can beat. Returned leaf-first. + /// + public static ImmutableArray CriticalPath( + ImmutableArray> results, Func> dependenciesOf) + { + if (results.IsEmpty) + return []; + var byId = results.ToDictionary(r => r.Id, StringComparer.Ordinal); + var last = results.MaxBy(r => r.Finished)!; + var path = new List(); + var current = last; + while (true) + { + path.Add(current.Id); + var deps = dependenciesOf(current.Id) + .Where(byId.ContainsKey) + .Select(d => byId[d]) + .ToArray(); + if (deps.Length == 0) + break; + // The dependency that finished last is the one this node waited for. + current = deps.MaxBy(d => d.Finished)!; + } + path.Reverse(); + return [.. path]; + } + + private static string? FindCycle( + ImmutableHashSet ids, Func> dependenciesOf) + { + var state = new Dictionary(StringComparer.Ordinal); // 1 visiting, 2 done + var stack = new List(); + string? Visit(string id) + { + if (state.TryGetValue(id, out var s)) + { + if (s != 1) + return null; + var at = stack.IndexOf(id); + return string.Join(" → ", stack.Skip(at).Append(id)); + } + state[id] = 1; + stack.Add(id); + foreach (var dep in dependenciesOf(id).Where(ids.Contains)) + { + if (Visit(dep) is { } found) + return found; + } + stack.RemoveAt(stack.Count - 1); + state[id] = 2; + return null; + } + foreach (var id in ids) + { + if (Visit(id) is { } found) + return found; + } + return null; + } +} diff --git a/tools/MeshWeaver.PluginTester/CascadeBuild.cs b/tools/MeshWeaver.PluginTester/CascadeBuild.cs new file mode 100644 index 0000000000..f8479ed97d --- /dev/null +++ b/tools/MeshWeaver.PluginTester/CascadeBuild.cs @@ -0,0 +1,577 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Reactive.Linq; +using System.Text.Json; +using MeshWeaver.Compiler; +using MeshWeaver.GitSync; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.NuGet; +using MeshWeaver.Plugin.Packaging; +using MeshWeaver.PluginCatalog; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MeshWeaver.PluginTester; + +/// +/// mw-plugin-test build <repo-root> [<package>… | all] — the new build process +/// for node repos (maintainer, 2026-08-30): build always means compile AND run tests, per +/// package, over the package dependency network, as a reactive cascade, inside the container the +/// platform build produced. Nothing here imports a node into a mesh. The sources are read from the +/// checkout on disk, composed into Roslyn compilations exactly as the portal composes them +/// ( — the same skeleton, the same options, the same +/// /app reference set), and each package compiles against the assemblies its dependency +/// packages just emitted. +/// +/// The cascade. gives every package a result stream; a package +/// subscribes to its dependencies' streams and starts the moment the last one completes green. +/// Independent packages build in parallel up to . A red package +/// (compile error, failed test, load failure) stops there: every package that requires it is +/// reported blocked by <it> and never starts — on red we break, on green we continue. +/// +/// Selection. Naming packages selects them AND their transitive requirements inside +/// this repo, so a single package builds on freshly built dependencies rather than on nothing. +/// all (the default) is the full rebuild — the platform-rebuild case, where every package +/// must be re-proven against the new image. +/// +/// Timings. Every package carries ready/started/finished, the compile and test +/// splits, and per-type compile times; the report prints them and the critical path — the chain +/// of packages whose serial length is the wall-clock floor. +/// +/// What the mesh lane still owns. Test cases that take a host (a layout-area +/// Tests aggregator, anything needing a hub) are counted and named as needs-mesh +/// rather than run; the gate () runs them, seeded from this build's +/// so nothing is compiled twice. Reported, never hidden. +/// +/// Parity flag. The portal compiles a type against the framework and its modules and +/// reaches other packages' types by shared= source inclusion — never by referencing their +/// emitted assemblies. This build references them (the maintainer's instruction: use the +/// references the dependency packages produce). A type whose emitted assembly turns out to BIND a +/// dependency package's assembly is therefore green here on grounds the portal does not have, and +/// the report marks it binds-dependency-assembly so that difference is visible rather than +/// discovered as a CompileError in production. +/// +public static class CascadeBuild +{ + /// The CLI verb. + public const string Verb = "build"; + + /// The all selector. + public const string AllSelector = "all"; + + /// Options for one cascade build. + public sealed record Options + { + /// The checkout root holding the node-repo packages. + public required string RepoRoot { get; init; } + + /// The packages to build; empty or all means every package. + public IReadOnlyList Packages { get; init; } = []; + + /// External module assemblies composed into the reference set (--module). + public IReadOnlyList ModuleAssemblyPaths { get; init; } = []; + + /// Where per-package prebuilt bundles are written; null keeps the build verdict-only. + public string? OutputDirectory { get; init; } + + /// Where the JSON report is written; null prints only the table. + public string? ReportPath { get; init; } + + /// Concurrency cap for package work (compile + tests). + public int MaxParallel { get; init; } = Math.Max(1, Environment.ProcessorCount); + + /// Hard cap per test case. + public TimeSpan CaseTimeout { get; init; } = TimeSpan.FromSeconds(60); + + /// False compiles only — for a type-check lane; the default builds tests too. + public bool RunTests { get; init; } = true; + + /// The source commit recorded in the bundles; defaults to the snapshot's. + public string? SourceSha { get; init; } + + /// Where progress lines go. + public TextWriter Output { get; init; } = Console.Out; + + /// Compiler logging. + public ILogger Logger { get; init; } = NullLogger.Instance; + + /// Logger factory for the NuGet resolver. + public ILoggerFactory? LoggerFactory { get; init; } + } + + /// One NodeType's build inside a package. + public sealed record TypeBuild( + string NodePath, + string Package, + string? CompileError, + TimeSpan CompileTime, + int SourceCount, + string? DllPath, + StaticTestRunner.Run? Tests, + ImmutableArray BindsDependencyAssemblies) + { + /// Compiled and every test that ran passed. + public bool IsGreen => CompileError is null && (Tests is null || Tests.IsGreen); + } + + /// One package's build: its types, what it emitted, and the two time splits. + public sealed record PackageBuild( + string Id, + ImmutableArray Types, + ImmutableArray EmittedAssemblies, + TimeSpan Compile, + TimeSpan Tests, + ImmutableArray ExternalRequirements) + { + /// Every type compiled and tested green. + public bool IsGreen => Types.All(t => t.IsGreen); + + /// Cases that ran and passed, across the package. + public int TestsPassed => Types.Sum(t => t.Tests?.Passed ?? 0); + + /// Cases that ran and failed. + public int TestsFailed => Types.Sum(t => t.Tests?.Failed ?? 0); + + /// Cases left to the mesh lane. + public int TestsNeedMesh => Types.Sum(t => t.Tests?.NeedsMesh ?? 0); + } + + /// The whole build. + public sealed record Report( + string FrameworkIdentity, + ImmutableArray> Packages, + ImmutableArray CriticalPath, + TimeSpan Wall, + ImmutableArray Bundles, + string? FatalError = null) + { + /// 0 green, 1 any red or blocked, 70 fatal. + public int ExitCode => + FatalError is not null ? 70 + : Packages.All(p => p.IsGreen) ? 0 + : 1; + } + + /// + /// Every progress line starts with the UTC time and the managed thread — packages build in + /// parallel, so a reader must be able to tell whose line this is (maintainer, 2026-08-30: + /// "output the time and which package … and for multithreaded also some thread id"). + /// + private static string Stamp() => + $"{DateTime.UtcNow:HH:mm:ss.fff} [T{Environment.CurrentManagedThreadId:D3}]"; + + /// Runs the build and emits the report once; the exit code is . + public static IObservable Run(Options options) + { + ArgumentNullException.ThrowIfNull(options); + return Observable.Defer(() => RunCore(options)) + .Catch((Exception ex) => Observable.Return( + new Report(PrebuiltAssemblySeeder.LiveFrameworkMvid, [], [], TimeSpan.Zero, [], + $"{ex.GetType().Name}: {ex.Message}"))); + } + + private static IObservable RunCore(Options options) + { + var wall = Stopwatch.StartNew(); + var frameworkIdentity = PrebuiltAssemblySeeder.LiveFrameworkMvid; + if (PrebuiltAssemblySeeder.LiveFrameworkIdentityWarning is { } warning) + options.Output.WriteLine($"build: ⚠ framework identity degraded — {warning}"); + + RepoSnapshot snapshot; + try + { + snapshot = LocalNodeRepo.LoadSync(options.RepoRoot); + } + catch (Exception ex) + { + return Observable.Return(Fatal(frameworkIdentity, wall, $"{ex.GetType().Name}: {ex.Message}")); + } + return LocalNodeRepo.DiscoverPackages(snapshot) + .Take(1) + .SelectMany(packages => Build(options, wall, frameworkIdentity, snapshot, packages)); + } + + private static IObservable Build( + Options options, Stopwatch wall, string frameworkIdentity, RepoSnapshot snapshot, + IReadOnlyList packages) + { + if (packages.Count == 0) + return Observable.Return(Fatal(frameworkIdentity, wall, + $"No node-repo packages (top-level folders with an index.json root) found under " + + $"'{Path.GetFullPath(options.RepoRoot)}'.")); + + var byId = packages.ToDictionary(p => p.Id, StringComparer.Ordinal); + var selection = Select(options.Packages, byId, out var unknown); + if (unknown.Length > 0) + return Observable.Return(Fatal(frameworkIdentity, wall, + $"unknown package(s): {string.Join(", ", unknown)} — known: " + + string.Join(", ", packages.Select(p => p.Id)))); + + var skipped = new List(); + var treeNodes = TreeNodeLoader.Load( + snapshot, packages, (path, reason) => skipped.Add($"{path}: {reason}")); + foreach (var skip in skipped) + options.Output.WriteLine($"build: skipped (not a materialisable node) {skip}"); + var nodeSet = NodeSet.Create(treeNodes.Select(t => t.Node)); + var typesByPackage = treeNodes + .Where(t => t.Node.Content is NodeTypeDefinition) + .GroupBy(t => t.Package, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Node.Path, StringComparer.Ordinal).ToArray(), StringComparer.Ordinal); + + IReadOnlyList modules; + try + { + modules = TreeBake.LoadExternalModules(new TreeBake.Options + { + RepoRoot = options.RepoRoot, + OutputDirectory = options.OutputDirectory ?? Path.GetTempPath(), + ModuleAssemblyPaths = options.ModuleAssemblyPaths, + Output = options.Output, + }); + } + catch (InvalidOperationException ex) + { + return Observable.Return(Fatal(frameworkIdentity, wall, ex.Message)); + } + var idOf = CompiledDependencies.CreateIdResolver( + FrameworkBuildIdentity.ProcessSurfacePairs, + TreeBake.ModuleMvidsOf(modules), + FrameworkBuildIdentity.ProcessImplMvidOf); + var toolchainId = CompiledDependencies.ComputeToolchainId(FrameworkBuildIdentity.ProcessImplMvidOf); + var baseReferences = CompileReferences.ComposeWithModules(modules); + var nugetResolver = new NuGetAssemblyResolver( + options.LoggerFactory?.CreateLogger() + ?? NullLogger.Instance); + var workDirectory = Path.Combine( + Path.GetTempPath(), $"mw-cascade-build-{Environment.ProcessId}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDirectory); + + IReadOnlyList DependenciesOf(string id) => + byId.TryGetValue(id, out var m) + ? m.Requires.Select(PackageDependencyGraph.DependencyId).Where(d => d.Length > 0).ToArray() + : []; + + options.Output.WriteLine( + $"{Stamp()} build: {selection.Length} of {packages.Count} package(s) selected " + + $"({(options.Packages.Count == 0 || options.Packages.Contains(AllSelector) ? "all — full rebuild" : string.Join(", ", options.Packages))}), " + + $"{treeNodes.Length} node(s), max-parallel={options.MaxParallel}, tests={(options.RunTests ? "on" : "off")}, " + + $"framework={frameworkIdentity}"); + + var entriesByPackage = new Dictionary>(StringComparer.Ordinal); + var entriesLock = new object(); + + return Cascade.Run( + selection, + DependenciesOf, + (id, deps) => + { + var build = BuildPackage( + options, id, typesByPackage.TryGetValue(id, out var types) ? types : [], + deps, byId, nodeSet, baseReferences, idOf, toolchainId, workDirectory, nugetResolver, + entriesByPackage, entriesLock); + return (build, build.IsGreen); + }, + options.MaxParallel) + .Select(results => Finish(options, wall, frameworkIdentity, snapshot, packages, results, entriesByPackage, workDirectory, DependenciesOf)); + } + + private static Report Finish( + Options options, Stopwatch wall, string frameworkIdentity, RepoSnapshot snapshot, + IReadOnlyList packages, ImmutableArray> results, + Dictionary> entriesByPackage, string workDirectory, + Func> DependenciesOf) + { + var bundles = ImmutableArray.Empty; + if (options.OutputDirectory is { } outDir) + { + bundles = TreeBake.WriteBundles( + new TreeBake.Options + { + RepoRoot = options.RepoRoot, + OutputDirectory = outDir, + SourceSha = options.SourceSha, + Output = options.Output, + }, + packages, snapshot, frameworkIdentity, entriesByPackage); + } + + try + { + if (options.OutputDirectory is null && Directory.Exists(workDirectory)) + Directory.Delete(workDirectory, recursive: true); + } + catch (IOException) + { + } + + var report = new Report( + frameworkIdentity, results, Cascade.CriticalPath(results, DependenciesOf), wall.Elapsed, bundles); + Print(options.Output, report, DependenciesOf); + if (options.ReportPath is { } reportPath) + WriteJson(reportPath, report); + return report; + } + + /// + /// The requested ids plus their transitive requirements inside the repo. External + /// requirements (a package this repo does not hold) are not an error here — they are named on + /// the package that needs them and satisfied by --module or by the image itself. + /// + internal static ImmutableArray Select( + IReadOnlyList requested, IReadOnlyDictionary byId, + out ImmutableArray unknown) + { + if (requested.Count == 0 || requested.Any(r => string.Equals(r, AllSelector, StringComparison.OrdinalIgnoreCase))) + { + unknown = []; + return [.. byId.Keys.OrderBy(k => k, StringComparer.Ordinal)]; + } + unknown = [.. requested.Where(r => !byId.ContainsKey(r)).Distinct(StringComparer.Ordinal)]; + var selected = new HashSet(StringComparer.Ordinal); + var stack = new Stack(requested.Where(byId.ContainsKey)); + while (stack.Count > 0) + { + var id = stack.Pop(); + if (!selected.Add(id)) + continue; + foreach (var dep in byId[id].Requires.Select(PackageDependencyGraph.DependencyId)) + { + if (dep.Length > 0 && byId.ContainsKey(dep)) + stack.Push(dep); + } + } + return [.. selected.OrderBy(k => k, StringComparer.Ordinal)]; + } + + private static PackageBuild BuildPackage( + Options options, + string id, + TreeNodeLoader.TreeNode[] types, + IReadOnlyList> deps, + IReadOnlyDictionary byId, + NodeSet nodeSet, + IReadOnlyList baseReferences, + Func idOf, + string toolchainId, + string workDirectory, + INuGetAssemblyResolver nugetResolver, + Dictionary> entriesByPackage, + object entriesLock) + { + var external = byId[id].Requires + .Select(PackageDependencyGraph.DependencyId) + .Where(d => d.Length > 0 && !byId.ContainsKey(d)) + .ToImmutableArray(); + var dependencyAssemblies = deps + .Where(d => d.Result is not null) + .SelectMany(d => d.Result!.EmittedAssemblies) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var dependencyNames = dependencyAssemblies + .Select(p => Path.GetFileNameWithoutExtension(p)) + .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + var references = dependencyAssemblies.Length == 0 + ? baseReferences + : baseReferences.Concat(dependencyAssemblies.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))).ToArray(); + + options.Output.WriteLine( + $"{Stamp()} [{id}] start — {types.Length} type(s), depends on " + + (deps.Count == 0 ? "nothing" : string.Join(", ", deps.Select(d => d.Id))) + + (external.Length == 0 ? "" : $" (+ external: {string.Join(", ", external)})")); + + var compileClock = Stopwatch.StartNew(); + var testClock = new Stopwatch(); + var built = ImmutableArray.CreateBuilder(); + var emitted = ImmutableArray.CreateBuilder(); + var packageDir = Path.Combine(workDirectory, CodeConventions.SanitizeNodeName(id)); + + foreach (var candidate in types) + { + var definition = (NodeTypeDefinition)candidate.Node.Content!; + var resolution = nodeSet.ResolveSources(definition.Sources, definition.Tests, candidate.Node.Path); + if (resolution.IsEstablished + && resolution.Sources.IsEmpty + && string.IsNullOrWhiteSpace(definition.Configuration)) + continue; + + var typeClock = Stopwatch.StartNew(); + NodeSetCompiler.CompiledNode compiled; + try + { + compiled = NodeSetCompiler.Compile( + nodeSet, candidate.Node, definition.Sources, definition.Tests, + definition.Configuration, definition.ContentCollections, + references, idOf, toolchainId, + Path.Combine(packageDir, CodeConventions.SanitizeNodeName(candidate.Node.Path)), + resolveNuGet: TreeBake.BlockingNuGetResolution(nugetResolver), + logger: options.Logger); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + typeClock.Stop(); + built.Add(new TypeBuild( + candidate.Node.Path, id, $"{ex.GetType().Name}: {ex.Message}", typeClock.Elapsed, + resolution.Sources.Length, null, null, [])); + options.Output.WriteLine($"{Stamp()} [{id}] RED {candidate.Node.Path} ({typeClock.Elapsed.TotalMilliseconds:F0} ms)"); + options.Output.WriteLine(ex.Message); + continue; + } + typeClock.Stop(); + emitted.Add(compiled.DllPath); + + var binds = compiled.Dependencies.Keys + .Where(dependencyNames.Contains) + .OrderBy(k => k, StringComparer.Ordinal) + .ToImmutableArray(); + + var sourceVersions = resolution.Sources + .Select(n => n.Path) + .ToImmutableSortedDictionary(p => p, _ => 0L, StringComparer.Ordinal); + lock (entriesLock) + { + if (!entriesByPackage.TryGetValue(id, out var entries)) + entriesByPackage[id] = entries = []; + entries.Add(new BundleWriter.AssemblyEntry( + compiled.NodePath, + () => File.OpenRead(compiled.DllPath), + compiled.PdbPath is null ? null : () => File.OpenRead(compiled.PdbPath), + sourceVersions, + compiled.Dependencies)); + } + + StaticTestRunner.Run? tests = null; + if (options.RunTests && definition.Tests is { Count: > 0 }) + { + testClock.Start(); + tests = StaticTestRunner.Execute( + compiled.DllPath, [.. dependencyAssemblies, .. emitted], options.CaseTimeout, options.Output); + testClock.Stop(); + } + + built.Add(new TypeBuild( + compiled.NodePath, id, null, typeClock.Elapsed, compiled.Inputs.MatchedSourcePaths.Length, + compiled.DllPath, tests, binds)); + options.Output.WriteLine( + $"{Stamp()} [{id}] ok {compiled.NodePath} ({typeClock.Elapsed.TotalMilliseconds:F0} ms, " + + $"{compiled.Inputs.MatchedSourcePaths.Length} source(s))" + + (tests is null ? "" : $" tests: {tests.Passed} passed, {tests.Failed} failed, {tests.NeedsMesh} needs-mesh") + + (binds.IsEmpty ? "" : $" binds-dependency-assembly: {string.Join(", ", binds)}")); + } + compileClock.Stop(); + + var result = new PackageBuild( + id, built.ToImmutable(), emitted.ToImmutable(), + compileClock.Elapsed - testClock.Elapsed, testClock.Elapsed, external); + options.Output.WriteLine( + $"{Stamp()} [{id}] {(result.IsGreen ? "GREEN" : "RED")} — compile {result.Compile.TotalSeconds:F1}s, " + + $"tests {result.Tests.TotalSeconds:F1}s ({result.TestsPassed} passed, {result.TestsFailed} failed, " + + $"{result.TestsNeedMesh} needs-mesh)"); + return result; + } + + private static Report Fatal(string frameworkIdentity, Stopwatch wall, string error) => + new(frameworkIdentity, [], [], wall.Elapsed, [], error); + + private static void Print( + TextWriter output, Report report, Func> dependenciesOf) + { + output.WriteLine(); + if (report.FatalError is not null) + { + output.WriteLine($"FATAL: {report.FatalError}"); + return; + } + output.WriteLine("package verdict ready queued work compile tests types passed failed mesh note"); + foreach (var p in report.Packages.OrderBy(p => p.Finished)) + { + var b = p.Result; + var note = p.Outcome switch + { + Cascade.NodeOutcome.Blocked => $"blocked by {p.BlockedBy}", + Cascade.NodeOutcome.Faulted => p.Error ?? "faulted", + Cascade.NodeOutcome.Red when b is not null => string.Join("; ", + b.Types.Where(t => !t.IsGreen).Select(t => + t.CompileError is not null ? $"{t.NodePath}: compile" : $"{t.NodePath}: {t.Tests?.Failed} failed")), + _ => b is not null && b.Types.Any(t => !t.BindsDependencyAssemblies.IsEmpty) + ? "binds-dependency-assembly" + : "", + }; + output.WriteLine( + $"{p.Id,-30} {Verdict(p.Outcome),-8} {p.Ready.TotalSeconds,7:F1} {p.Queued.TotalSeconds,8:F1} " + + $"{p.Work.TotalSeconds,7:F1} {(b?.Compile.TotalSeconds ?? 0),8:F1} {(b?.Tests.TotalSeconds ?? 0),7:F1} " + + $"{(b?.Types.Length ?? 0),6} {(b?.TestsPassed ?? 0),6} {(b?.TestsFailed ?? 0),6} {(b?.TestsNeedMesh ?? 0),4} {note}"); + } + output.WriteLine(); + var green = report.Packages.Count(p => p.IsGreen); + var red = report.Packages.Count(p => p.Outcome is Cascade.NodeOutcome.Red or Cascade.NodeOutcome.Faulted); + var blocked = report.Packages.Count(p => p.Outcome == Cascade.NodeOutcome.Blocked); + output.WriteLine( + $"build: {green} green, {red} red, {blocked} blocked of {report.Packages.Length} in {report.Wall.TotalSeconds:F1}s wall; " + + $"critical path ({report.CriticalPath.Length}): {string.Join(" → ", report.CriticalPath)}" + + (report.Bundles.IsEmpty ? "" : $"; {report.Bundles.Length} bundle(s) written")); + var serial = report.Packages.Sum(p => p.Work.TotalSeconds); + if (serial > 0) + output.WriteLine($"build: {serial:F1}s of work in {report.Wall.TotalSeconds:F1}s wall — parallel speed-up {serial / Math.Max(0.001, report.Wall.TotalSeconds):F1}×"); + } + + private static string Verdict(Cascade.NodeOutcome outcome) => outcome switch + { + Cascade.NodeOutcome.Green => "green", + Cascade.NodeOutcome.Red => "RED", + Cascade.NodeOutcome.Blocked => "blocked", + _ => "FAULT", + }; + + private static void WriteJson(string path, Report report) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + var shape = new + { + framework = report.FrameworkIdentity, + wallSeconds = report.Wall.TotalSeconds, + exitCode = report.ExitCode, + criticalPath = report.CriticalPath, + fatal = report.FatalError, + packages = report.Packages.Select(p => new + { + id = p.Id, + outcome = p.Outcome.ToString(), + blockedBy = p.BlockedBy, + error = p.Error, + readySeconds = p.Ready.TotalSeconds, + startedSeconds = p.Started.TotalSeconds, + finishedSeconds = p.Finished.TotalSeconds, + compileSeconds = p.Result?.Compile.TotalSeconds, + testSeconds = p.Result?.Tests.TotalSeconds, + external = p.Result?.ExternalRequirements, + types = p.Result?.Types.Select(t => new + { + path = t.NodePath, + compileMs = t.CompileTime.TotalMilliseconds, + sources = t.SourceCount, + compileError = t.CompileError, + bindsDependencyAssemblies = t.BindsDependencyAssemblies, + tests = t.Tests is null ? null : new + { + loadError = t.Tests.LoadError, + passed = t.Tests.Passed, + failed = t.Tests.Failed, + needsMesh = t.Tests.NeedsMesh, + cases = t.Tests.Cases.Select(c => new + { + name = c.Name, + outcome = c.Outcome.ToString(), + ms = c.Elapsed.TotalMilliseconds, + error = c.Error, + }), + }, + }), + }), + }; + File.WriteAllText(path, JsonSerializer.Serialize(shape, new JsonSerializerOptions { WriteIndented = true })); + } +} diff --git a/tools/MeshWeaver.PluginTester/Program.cs b/tools/MeshWeaver.PluginTester/Program.cs index 657c4261f5..c850765030 100644 --- a/tools/MeshWeaver.PluginTester/Program.cs +++ b/tools/MeshWeaver.PluginTester/Program.cs @@ -63,6 +63,8 @@ { if (args.Length > 0 && args[0] == "compile") return RunCompile(args[1..]); + if (args.Length > 0 && args[0] == CascadeBuild.Verb) + return await RunBuild(args[1..]); if (args.Length > 0 && args[0] == "framework-identity") return RunFrameworkIdentity(args[1..]); return await RunGate(args); @@ -73,6 +75,97 @@ return 70; // EX_SOFTWARE: distinct from 0 (green), 1 (gate red) and 2 (bad usage). } +static async Task RunBuild(string[] args) +{ + // build [… | all] [--module ]… [--out ] [--report ] + // [--max-parallel ] [--case-timeout ] [--no-tests] [--source-sha ] + // The new build process (2026-08-30): compile + run tests per package, as a dependency + // cascade, from the checkout on disk, against this image's /app — no mesh import anywhere. + string? root = null; + var packages = new List(); + var modules = new List(); + string? outDir = null; + string? reportPath = null; + string? sourceSha = null; + var maxParallel = Math.Max(1, Environment.ProcessorCount); + var caseTimeout = TimeSpan.FromSeconds(60); + var runTests = true; + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--module" when i + 1 < args.Length: + modules.Add(args[++i]); + break; + case "--out" when i + 1 < args.Length: + outDir = args[++i]; + break; + case "--report" when i + 1 < args.Length: + reportPath = args[++i]; + break; + case "--source-sha" when i + 1 < args.Length: + sourceSha = args[++i]; + break; + case "--max-parallel" when i + 1 < args.Length: + if (!int.TryParse(args[++i], NumberStyles.Integer, CultureInfo.InvariantCulture, out maxParallel) || maxParallel < 1) + { + Console.Error.WriteLine("mw-plugin-test build: --max-parallel takes a positive integer"); + return 2; + } + break; + case "--case-timeout" when i + 1 < args.Length: + if (!double.TryParse(args[++i], NumberStyles.Float, CultureInfo.InvariantCulture, out var s) || s <= 0) + { + Console.Error.WriteLine("mw-plugin-test build: --case-timeout takes seconds > 0"); + return 2; + } + caseTimeout = TimeSpan.FromSeconds(s); + break; + case "--no-tests": + runTests = false; + break; + case "-h" or "--help": + Console.WriteLine(BuildUsage()); + return 0; + case var flag when flag.StartsWith("--", StringComparison.Ordinal): + Console.Error.WriteLine($"mw-plugin-test build: unknown option {flag}"); + Console.Error.WriteLine(BuildUsage()); + return 2; + default: + if (root is null) + root = args[i]; + else + packages.Add(args[i]); + break; + } + } + if (root is null) + { + Console.Error.WriteLine(BuildUsage()); + return 2; + } + var report = await CascadeBuild.Run(new CascadeBuild.Options + { + RepoRoot = root, + Packages = packages, + ModuleAssemblyPaths = modules, + OutputDirectory = outDir, + ReportPath = reportPath, + MaxParallel = maxParallel, + CaseTimeout = caseTimeout, + RunTests = runTests, + SourceSha = sourceSha, + }).Await(CancellationToken.None); + return report.ExitCode; +} + +static string BuildUsage() => + "usage: mw-plugin-test build [... | all] [--module ]... [--out ] " + + "[--report ] [--max-parallel ] [--case-timeout ] [--no-tests] [--source-sha ]\n" + + " Compiles AND tests each selected package (plus its in-repo requirements) as a dependency " + + "cascade: a package starts when its dependencies are green, is blocked when one is red. " + + "'all' (default) rebuilds everything. Sources are read from disk; nothing is imported into a mesh."; + static int RunCompile(string[] args) { string? compileRoot = null; @@ -447,7 +540,7 @@ static async Task RunGate(string[] args) } case "--help" or "-h": Console.WriteLine( - "usage: mw-plugin-test [--compile-timeout ] [--render-timeout ] " + "usage: mw-plugin-test build [... | all] ... (see build --help)\n mw-plugin-test [--compile-timeout ] [--render-timeout ] " + "[--allow ] [--report ] [--seed ] [--bake-output ] " + "[--source-sha ] [--module ]... [--print-framework-identity]"); return 0; diff --git a/tools/MeshWeaver.PluginTester/README.md b/tools/MeshWeaver.PluginTester/README.md index 24fa9bcab7..472e5caeca 100644 --- a/tools/MeshWeaver.PluginTester/README.md +++ b/tools/MeshWeaver.PluginTester/README.md @@ -20,8 +20,48 @@ mw-compiler compile --output # BAKE: build-step compile, NO mesh mw-compiler # GATE: mesh run — render + Tests areas mw-compiler --bake-output # legacy: the gate's mesh ALSO produces the bundles mw-compiler --print-framework-identity # one-line identity + provenance diagnostic +mw-compiler build [...|all] # BUILD: compile + test per package, dependency cascade ``` +## `build` — compile AND test, per package, as a dependency cascade (2026-08-30) + +`build` is the build process for node repos. **Build always means compile and run tests.** The +input is one or many packages, or `all` (the default) for the full rebuild a platform rebuild +needs; the selection is the named packages plus their transitive `requires` inside the repo. + +``` +mw-compiler build [... | all] [--module ]... [--out ] \ + [--report ] [--max-parallel ] [--case-timeout ] [--no-tests] [--source-sha ] +``` + +- **A cascade, not a schedule.** Every package has a result stream; a package OBSERVES the + streams of the packages it requires and starts itself the moment the last one completes green. + Packages with no edge between them build at the same time (bounded by `--max-parallel`). + **On red we break**: a package whose dependency did not end green never starts and is reported + `blocked by `; **on green we continue**. A failure is reported once, where it + happened. A cycle is refused up front and named. +- **No mesh, no import.** Sources come from the checkout on disk (the same node loader as + `compile`), composed exactly as the portal composes a NodeType compile (`NodeSetCompiler`: the + same skeleton, the same options, this image's `/app` as the reference set) — and each package + compiles against the assemblies its dependency packages just emitted. Grains cannot carry a + Roslyn workload; a build process can. +- **Tests without a mesh.** The `Test/*.cs` convention — static classes whose public static + parameterless methods throw on failure — runs straight from the emitted assembly in a + collectible load context, each case timed and capped by `--case-timeout`. A case that takes a + host (a `Tests` layout-area aggregator, anything needing a hub) is COUNTED and NAMED as + `needs-mesh`, never dropped: the gate (`mw-compiler --seed `) still runs those, + seeded from `--out` so nothing is compiled twice. +- **Timings are the point.** Every package reports ready / queued / work, its compile and test + splits and per-type compile times; the summary prints the critical path (the chain whose serial + length is the wall-clock floor) and the parallel speed-up. `--report` writes all of it as JSON. +- **Parity flag.** The portal reaches other packages' types by `shared=` source inclusion, never + by referencing their emitted assemblies. A type whose emitted assembly turns out to BIND a + dependency package's assembly is therefore green here on grounds the portal does not have; the + report marks it `binds-dependency-assembly` so that difference is visible, not discovered as a + CompileError in production. + +Exit codes: `0` every selected package green · `1` any red or blocked · `2` usage · `70` fatal. + ## `compile` — the bake, as a build step `compile` is the compiler-driven bake (#1763). It reads the node files out of the checkout, diff --git a/tools/MeshWeaver.PluginTester/StaticTestRunner.cs b/tools/MeshWeaver.PluginTester/StaticTestRunner.cs new file mode 100644 index 0000000000..294fb1a214 --- /dev/null +++ b/tools/MeshWeaver.PluginTester/StaticTestRunner.cs @@ -0,0 +1,238 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; + +namespace MeshWeaver.PluginTester; + +/// +/// Executes a compiled NodeType's in-mesh tests WITHOUT a mesh: the Test/*.cs convention is +/// static classes whose public static parameterless methods throw on failure (that is what a +/// Tests layout area's case table wraps), so the assembly the build just emitted can be +/// loaded into a collectible context and those methods invoked directly, each one timed. +/// +/// What this runs and what it reports as not-run. A case that needs a host — a method +/// with parameters (LayoutAreaHost, a hub) — cannot execute here; it is COUNTED as +/// and named, never silently dropped, so the report says exactly +/// how much of a type's suite the mesh lane still owns. That is the honest split the maintainer +/// set on 2026-08-30: compile and pure tests off the mesh ("grains cannot handle compile +/// workload"; "do not try to import mesh nodes"), the hosted cases through the gate that seeds +/// from this build's output. +/// +/// Resolution. The emitted assembly binds the framework (already in this process — +/// the tester's own closure is the image's /app) and the dependency packages' emitted +/// assemblies, which are mapped by simple name into the context. Anything else is a missing +/// dependency and surfaces as the load failure it is. +/// +public static class StaticTestRunner +{ + /// What happened to one case. + public enum Outcome + { + /// Ran and returned. + Passed, + /// Ran and threw. + Failed, + /// Needs a host or hub — left to the mesh lane. + NeedsMesh, + } + + /// One executed (or classified) case. + /// Type.Method. + /// The verdict. + /// Wall time of the invocation (zero when not run). + /// The failure text, innermost exception first. + public sealed record Case(string Name, Outcome Outcome, TimeSpan Elapsed, string? Error); + + /// The run over one assembly. + /// The assembly path. + /// Every case found, in discovery order. + /// Set when the assembly could not be loaded at all — no cases then. + public sealed record Run(string Assembly, ImmutableArray Cases, string? LoadError) + { + /// Cases that ran and passed. + public int Passed => Cases.Count(c => c.Outcome == Outcome.Passed); + + /// Cases that ran and failed. + public int Failed => Cases.Count(c => c.Outcome == Outcome.Failed); + + /// Cases that need the mesh lane. + public int NeedsMesh => Cases.Count(c => c.Outcome == Outcome.NeedsMesh); + + /// Green iff it loaded and nothing that ran failed. No cases is green, not a pass. + public bool IsGreen => LoadError is null && Failed == 0; + } + + /// + /// Loads with + /// resolvable by simple name, discovers the test classes, invokes every runnable case and + /// unloads the context. The is a hard cap: a case that + /// outlives it is reported failed with the timeout named, and the run continues. + /// + public static Run Execute( + string assemblyPath, + IReadOnlyList dependencyAssemblies, + TimeSpan perCaseTimeout, + TextWriter? output = null) + { + ArgumentNullException.ThrowIfNull(assemblyPath); + ArgumentNullException.ThrowIfNull(dependencyAssemblies); + + var byName = dependencyAssemblies + .Where(File.Exists) + .GroupBy(p => Path.GetFileNameWithoutExtension(p), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var context = new TestLoadContext(byName); + try + { + Assembly assembly; + try + { + assembly = context.LoadFromAssemblyPath(Path.GetFullPath(assemblyPath)); + } + catch (Exception ex) when (ex is IOException or BadImageFormatException or FileLoadException) + { + return new Run(assemblyPath, [], $"{ex.GetType().Name}: {ex.Message}"); + } + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + var missing = ex.LoaderExceptions + .Where(e => e is not null) + .Select(e => e!.Message) + .Distinct(StringComparer.Ordinal) + .Take(3); + return new Run(assemblyPath, [], + "the assembly loaded but its types did not: " + string.Join(" | ", missing) + + " — a dependency package's assembly is not in the reference set handed to " + + "this run, or a module named by the source is not composed into the image"); + } + + var cases = ImmutableArray.CreateBuilder(); + foreach (var type in types.Where(IsTestClass).OrderBy(t => t.FullName, StringComparer.Ordinal)) + { + foreach (var method in TestMethods(type)) + { + var name = $"{type.Name}.{method.Name}"; + if (method.GetParameters().Length > 0) + { + cases.Add(new Case(name, Outcome.NeedsMesh, TimeSpan.Zero, + "takes " + string.Join(", ", method.GetParameters().Select(p => p.ParameterType.Name)) + + " — a hosted case; the mesh lane runs it")); + continue; + } + cases.Add(Invoke(name, method, perCaseTimeout)); + output?.WriteLine(Describe(cases[^1])); + } + } + return new Run(assemblyPath, cases.ToImmutable(), null); + } + finally + { + context.Unload(); + } + } + + private static string Describe(Case c) => c.Outcome switch + { + Outcome.Passed => $" ok {c.Name} ({c.Elapsed.TotalMilliseconds:F0} ms)", + Outcome.Failed => $" FAIL {c.Name} ({c.Elapsed.TotalMilliseconds:F0} ms): {c.Error}", + _ => $" mesh {c.Name}: {c.Error}", + }; + + private static Case Invoke(string name, MethodInfo method, TimeSpan timeout) + { + var clock = Stopwatch.StartNew(); + // The case runs on its own thread so a hang can be reported by name rather than hanging + // the whole build; the thread is background so it cannot keep the process alive. + Exception? failure = null; + var done = new ManualResetEventSlim(false); + var thread = new Thread(() => + { + try + { + method.Invoke(null, null); + } + catch (TargetInvocationException ex) + { + failure = ex.InnerException ?? ex; + } + catch (Exception ex) + { + failure = ex; + } + finally + { + done.Set(); + } + }) + { + IsBackground = true, + Name = $"test:{name}", + }; + thread.Start(); + if (!done.Wait(timeout)) + { + return new Case(name, Outcome.Failed, clock.Elapsed, + $"did not return within {timeout.TotalSeconds:F0}s — a hung case; the thread is " + + "abandoned and the build continues"); + } + clock.Stop(); + return failure is null + ? new Case(name, Outcome.Passed, clock.Elapsed, null) + : new Case(name, Outcome.Failed, clock.Elapsed, Innermost(failure)); + } + + private static string Innermost(Exception ex) + { + var e = ex; + while (e.InnerException is not null) + e = e.InnerException; + var first = e.Message.Split('\n', 2)[0].Trim(); + return $"{e.GetType().Name}: {first}"; + } + + /// + /// A test class: public, static (abstract + sealed), not compiler-generated, whose name ends + /// in Test or Tests — the convention every Test/*.cs in the node repos + /// follows (ModuleTests, CourseIndexTests, …). The *TestsArea aggregator + /// classes match too, and their only method takes a host, so they classify as needs-mesh. + /// + private static bool IsTestClass(Type t) => + t.IsClass && t.IsPublic && t.IsAbstract && t.IsSealed + && !t.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) + && (t.Name.EndsWith("Tests", StringComparison.Ordinal) + || t.Name.EndsWith("Test", StringComparison.Ordinal) + || t.Name.EndsWith("TestsArea", StringComparison.Ordinal)); + + private static IEnumerable TestMethods(Type t) => + t.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName + && !m.IsGenericMethodDefinition + && !m.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) + && (m.ReturnType == typeof(void) || m.GetParameters().Length > 0)) + .OrderBy(m => m.Name, StringComparer.Ordinal); + + private sealed class TestLoadContext(IReadOnlyDictionary dependencyPaths) + : AssemblyLoadContext(isCollectible: true) + { + protected override Assembly? Load(AssemblyName assemblyName) + { + // Framework and image assemblies: whatever this process already runs — the tester's + // closure IS the image's /app, and binding a second copy would split every type. + if (Default.Assemblies.Any(a => string.Equals( + a.GetName().Name, assemblyName.Name, StringComparison.OrdinalIgnoreCase))) + return null; + return assemblyName.Name is { } name && dependencyPaths.TryGetValue(name, out var path) + ? LoadFromAssemblyPath(path) + : null; + } + } +} diff --git a/tools/MeshWeaver.PluginTester/Testing/TestContext.cs b/tools/MeshWeaver.PluginTester/Testing/TestContext.cs new file mode 100644 index 0000000000..eb7c6791bb --- /dev/null +++ b/tools/MeshWeaver.PluginTester/Testing/TestContext.cs @@ -0,0 +1,66 @@ +namespace MeshWeaver.Testing; + +/// +/// The per-case context a build-run test sees — the shape xUnit's TestContext.Current +/// gave the suites this replaces, so a migrated body keeps reading +/// TestContext.Current.CancellationToken. The runner sets it on the case's own thread +/// before invoking the method; the token trips at the case budget, so a case that threads it +/// through its waits ends with a named timeout instead of a hung build. +/// +public static class TestContext +{ + [ThreadStatic] private static TestContextData? current; + + /// The running case's context; a neutral one when no case is running. + public static TestContextData Current => current ??= new TestContextData("(no case)", CancellationToken.None); + + /// Installs the context for the case about to run on this thread. + public static IDisposable Enter(string caseName, CancellationToken token) + { + var previous = current; + current = new TestContextData(caseName, token); + return new Restore(() => current = previous); + } + + private sealed class Restore(Action undo) : IDisposable + { + public void Dispose() => undo(); + } +} + +/// What a case may ask about itself. +/// Type.Method of the running case. +/// Trips at the case budget. +public sealed record TestContextData(string CaseName, CancellationToken CancellationToken); + +/// +/// Thrown by a case that decides it cannot run here (a platform it is not on, a credential it +/// does not have). The runner reports it as skipped — never as passed, never as failed. +/// +public sealed class SkipException(string reason) : Exception(reason) +{ + /// The reason the case gave. + public string Reason { get; } = reason; +} + +/// +/// Where a case writes what it wants a reader to see. Lines go to the runner's output (the build +/// log) prefixed with the case name, so a failure's neighbourhood is readable without a debugger — +/// the role ITestOutputHelper played in the suites this replaces. +/// +public static class TestLog +{ + /// Optional sink the runner installs; the console otherwise. + public static Action? Sink { get; set; } + + /// Writes one line, prefixed with the running case. + public static void WriteLine(string message) + { + var line = $" [{TestContext.Current.CaseName}] {message}"; + (Sink ?? Console.WriteLine)(line); + } + + /// Writes one formatted line. + public static void WriteLine(string format, params object?[] args) => + WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture, format, args)); +} diff --git a/tools/MeshWeaver.PluginTester/TreeBake.cs b/tools/MeshWeaver.PluginTester/TreeBake.cs index a54393356f..e99d613baf 100644 --- a/tools/MeshWeaver.PluginTester/TreeBake.cs +++ b/tools/MeshWeaver.PluginTester/TreeBake.cs @@ -235,7 +235,20 @@ public static Report Run(Options options) /// module's types, reported the resulting misses as CONTENT errors, and named nothing about a /// reference. Loud here, once, beats five red NodeTypes and a fleet that will not roll. /// - private static IReadOnlyList LoadExternalModules(Options options) + /// + /// The compiler's NuGet hook is synchronous by contract ( + /// takes a plain delegate), and the resolver is genuinely async, so this is the ONE place the + /// bake lane blocks on it. Shared with the build verb so the production blocking-bridge + /// inventory keeps counting exactly one site, here. + /// + internal static Func, CancellationToken, IReadOnlyList> + BlockingNuGetResolution(INuGetAssemblyResolver resolver) => + (refs, ct) => resolver + .ResolveAsync(refs, targetFramework: null, ct) + .GetAwaiter().GetResult() + .AssemblyPaths; + + internal static IReadOnlyList LoadExternalModules(Options options) { var paths = options.ModuleAssemblyPaths; if (paths.Count == 0) @@ -274,7 +287,7 @@ private static IReadOnlyList LoadExternalModules(Option /// Installed module simple name → implementation MVID ("N"). Deliberately the same /// projection the mesh makes (NodeTypeCompilationHelpers.ModuleMvidsOf): producer and /// consumer have to compute one id for one build, or every bundle is declined. - private static IReadOnlyDictionary ModuleMvidsOf( + internal static IReadOnlyDictionary ModuleMvidsOf( IReadOnlyList modules) { var map = new Dictionary(StringComparer.Ordinal); @@ -340,10 +353,7 @@ private static Report BakeAll( // The ONE Task bridge in the bake, at a console build step — never on a hub // scheduler. NuGet restore is genuine network IO with no reactive surface; // the runtime bridges it through the IoPool for the same reason. - resolveNuGet: (refs, ct) => nugetResolver - .ResolveAsync(refs, targetFramework: null, ct) - .GetAwaiter().GetResult() - .AssemblyPaths, + resolveNuGet: BlockingNuGetResolution(nugetResolver), logger: options.Logger); // 🚨 The RAW resolved set, not the post-filter compile set — the mesh-driven bake @@ -408,7 +418,7 @@ private static Report BakeAll( return new Report(frameworkIdentity, results.ToImmutable(), bundles); } - private static ImmutableArray WriteBundles( + internal static ImmutableArray WriteBundles( Options options, IReadOnlyList packages, RepoSnapshot snapshot,