Skip to content
9 changes: 0 additions & 9 deletions scripts/type-forwards.allow
Original file line number Diff line number Diff line change
Expand Up @@ -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.
94 changes: 94 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/BuildProcess.md
Original file line number Diff line number Diff line change
@@ -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 <repo-root> [<package>... | all] [--module <dll>]... [--out <dir>] \
[--report <file>] [--max-parallel <n>] [--case-timeout <s>] [--no-tests] [--source-sha <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 \<dependency\>*, 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 `<Type>/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 <root> --seed <out>`) 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`) |
22 changes: 22 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/WritingTests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Type>/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 <repo-root> [<package>... | 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:
Expand Down
190 changes: 190 additions & 0 deletions test/MeshWeaver.PluginTester.Test/CascadeTest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class CascadeTest
{
private static readonly IReadOnlyDictionary<string, string[]> Diamond =
new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["Store"] = [],
["AI"] = ["Store"],
["Observability"] = ["Store"],
["Edu"] = ["Store", "AI"],
};
Comment on lines +18 to +25

private static IReadOnlyList<string> DepsOf(string id) =>
Diamond.TryGetValue(id, out var d) ? d : [];

[Fact]
public async Task ANodeStartsOnlyAfterEveryDependencyIsGreen_AndDependentsSeeTheirResults()
{
var seen = new ConcurrentDictionary<string, string[]>(StringComparer.Ordinal);
var results = await Cascade.Run<string>(
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<string>();
var results = await Cascade.Run<string>(
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<string>(
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<string, int>(StringComparer.Ordinal);
var wide = new Dictionary<string, string[]>(StringComparer.Ordinal) { ["Base"] = [] };
for (var i = 0; i < 20; i++)
wide[$"Leaf{i:D2}"] = ["Base"];

await Cascade.Run<int>(
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<int>(
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<string, string[]>(StringComparer.Ordinal)
{
["A"] = ["B"],
["B"] = ["C"],
["C"] = ["A"],
};
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Cascade.Run<int>(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<int>(
["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<int>(
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<int>([], _ => [], (_, _) => (0, true), 1).Await(TestContext.Current.CancellationToken));
}
}
Loading
Loading