Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@
<ProjectReference Include="..\..\src\MeshWeaver.Kernel.Hub\MeshWeaver.Kernel.Hub.csproj" />
<!-- AI Services -->
<ProjectReference Include="..\..\src\MeshWeaver.Documentation\MeshWeaver.Documentation.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Markdown.Export\MeshWeaver.Markdown.Export.csproj" />
<!-- MeshWeaver.Markdown.Export is deliberately NOT referenced (#1644 step 2): it ships via
the modules/<Name>/ closure layout in MeshModulesPublish.targets and activates via
Modules:Assemblies -> MarkdownExportProviderAttribute. Only the wire contract stays in
the app closure (via MeshWeaver.Blazor -> MeshWeaver.Markdown.Export.Contract). -->
<ProjectReference Include="..\..\src\MeshWeaver.AI\MeshWeaver.AI.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.AI.AzureFoundry\MeshWeaver.AI.AzureFoundry.csproj" />
<!-- SHIPS-THE-BITS references: these four flow the provider-pack DLLs + their SDK closures
Expand Down
177 changes: 161 additions & 16 deletions memex/MeshModulesPublish.targets
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
<Project>
<!--
The modules/ publish layout — design #1644, step 1 (ADDITIVE).
The modules/ publish layout — design #1644.

Publishing a host lays every mesh MODULE out under modules/<Name>/ beside the app. Which
modules EXIST is this list (a publish/composition decision); which ACTIVATE stays the
deployment's Modules:Assemblies configuration, whose loader probes modules/<name>/<name>.dll
first and falls back to the app folder (MeshBuilder.ResolveModulePath).

Step-1 scope, deliberately THIN: each module contributes its OWN build outputs (dll/pdb/xml),
not its transitive closure. A full `Publish` per module was measured to drag ~1400
framework-package assemblies the app resolves from the SHARED FRAMEWORK (a class library's
standalone publish rolls Microsoft.Extensions.* in; the app does not carry them), and a
filename prune against the app folder cannot see shared-framework provision — the same-identity
trap-door class in its publish-time form. Closure layout is therefore per-module STEP-2 work:
when a module's ProjectReference flips off, its entry here upgrades to a closure publish with a
prune that is correct FOR THAT MODULE (most have no private deps beyond the app's closure;
Speech's native runtime is the known exception).

While a module ALSO rides a ProjectReference from Memex.Portal.Shared (the step-1 double-ship),
even its own DLL is pruned as a same-identity duplicate of the app copy — the folder stays
empty for it and the loader falls back to the app folder: byte-for-byte today's behaviour.
TWO lanes, one per migration state (#1644 step 2 flips modules from the first to the second,
one at a time):

1. THIN lane (@(MeshModule)) — modules still double-shipped via a ships-the-bits
ProjectReference from Memex.Portal.Shared. Each contributes its OWN build outputs
(dll/pdb/xml), all pruned as same-identity duplicates of the app copies, so the folder
stays empty for it and the loader falls back to the app folder: byte-for-byte today's
behaviour. A full `Publish` per module was measured to drag ~1400 framework-package
assemblies here (a class library's standalone publish rolls Microsoft.Extensions.* in),
which is why the closure lane exists only for modules whose reference has flipped OFF.

2. CLOSURE lane (@(MeshModuleClosure)) — modules whose ProjectReference is GONE: the folder
is the ONLY way their bits ship. Each gets a full `Publish` into modules/<Name>/ (culture
satellites suppressed at the source via SatelliteResourceLanguages), then a prune that is
the inverse of the thin lane's problem:
0. drop runtimes/<rid>/ trees (Assembly.LoadFrom never consults the module's deps.json,
so a RID tree under a module folder is unreachable by construction);
a. drop files the app publish already carries at the same RELATIVE path (same-identity
duplicates — the documented trap-door class);
b. drop assemblies the SHARED FRAMEWORK provides (globbed from the machine's
Microsoft.AspNetCore.App.Ref / Microsoft.NETCore.App.Ref targeting packs — the
app resolves these from the shared framework, so a prune against the app output
alone cannot see them; measured at 18 leftovers for Markdown.Export).
What survives is the module's own assembly + its TRUE private deps. First flipped module:
MeshWeaver.Markdown.Export (measured private deps: none — its whole package closure,
Markdig/OpenXml/HtmlAgilityPack/PdfPig, still rides the app via other references; if one
of those references ever leaves, the closure publish+prune keeps the dep automatically).
The closure lane ALSO runs after plain Build of a host (into $(OutDir)modules/), because a
dev `dotnet run` has no publish step and the flipped DLL exists nowhere else.
-->
<ItemGroup>
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.AI.OpenAI/MeshWeaver.AI.OpenAI.csproj" />
Expand All @@ -31,9 +47,11 @@
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Blazor.GoogleMaps/MeshWeaver.Blazor.GoogleMaps.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.ContentCollections.Indexing.PostgreSql/MeshWeaver.ContentCollections.Indexing.PostgreSql.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Speech/MeshWeaver.Speech.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Markdown.Export/MeshWeaver.Markdown.Export.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Observability/MeshWeaver.Observability.csproj" />
<MeshModule Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.OgCard/MeshWeaver.OgCard.csproj" />

<!-- Flipped off the ships-the-bits ProjectReference (#1644 step 2, flip #1). -->
<MeshModuleClosure Include="$(MSBuildThisFileDirectory)../src/MeshWeaver.Markdown.Export/MeshWeaver.Markdown.Export.csproj" />
</ItemGroup>

<Target Name="PublishMeshModules" AfterTargets="Publish" Condition="'$(PublishMeshModules)' != 'false'">
Expand Down Expand Up @@ -73,10 +91,137 @@
</ItemGroup>
<Delete Files="@(_ModuleDuplicate)" />

<!-- Closure lane. Self-invoked on THIS file so the identical worker serves the Build hook
below — a target runs at most once per project instance, and `dotnet publish` runs Build
AND Publish in one instance, so a shared in-instance worker would fire for only one of
the two directories. MeshModulesGuardAppRoot: only the publish lane hard-fails on the
module's own dll in the app root (in a dev bin/ that is routinely a STALE file from the
pre-flip era, not a re-added reference). -->
<MSBuild Projects="$(MSBuildThisFileFullPath)"
Targets="LayoutMeshModuleClosures"
BuildInParallel="false"
Properties="MeshModulesAppDir=$(_AppPublishDir);Configuration=$(Configuration);MeshModulesTargetFramework=$(TargetFramework);NetCoreTargetingPackRoot=$(NetCoreTargetingPackRoot);MeshModulesGuardAppRoot=true" />

<ItemGroup>
<_ModuleFileKept Include="$(_AppPublishDir)modules/**/*.*" />
</ItemGroup>
<Message Importance="high"
Text="PublishMeshModules: @(MeshModule->Count()) module(s) laid out; pruned @(_ModuleDuplicate->Count()) app-closure duplicate(s); modules/ keeps @(_ModuleFileKept->Count()) file(s)." />
Text="PublishMeshModules: @(MeshModule->Count()) thin + @(MeshModuleClosure->Count()) closure module(s) laid out; pruned @(_ModuleDuplicate->Count()) app-closure duplicate(s) from the thin lane; modules/ keeps @(_ModuleFileKept->Count()) file(s)." />
</Target>

<!-- Dev-run lane: a flipped module's DLL exists NOWHERE in a plain build's output (no
ProjectReference ships it, and Build has no publish step), so `dotnet run` on a host would
fail Assembly.LoadFrom at startup. Lay the closure modules into $(OutDir)modules/ — the
same folder ResolveModulePath probes first. Thin-lane modules deliberately stay out: their
DLLs already ride the app output via the ProjectReference. -->
<Target Name="LayoutMeshModuleClosuresAfterBuild" AfterTargets="Build" Condition="'$(PublishMeshModules)' != 'false'">
<MSBuild Projects="$(MSBuildThisFileFullPath)"
Targets="LayoutMeshModuleClosures"
BuildInParallel="false"
Properties="MeshModulesAppDir=$([System.IO.Path]::GetFullPath('$(OutDir)', '$(MSBuildProjectDirectory)'));Configuration=$(Configuration);MeshModulesTargetFramework=$(TargetFramework);NetCoreTargetingPackRoot=$(NetCoreTargetingPackRoot);MeshModulesGuardAppRoot=false" />
</Target>

<!-- The closure-lane worker. Runs against THIS file as its own project (see the callers for
why), parameterized by MeshModulesAppDir — so it must receive Configuration,
MeshModulesTargetFramework and NetCoreTargetingPackRoot from the caller: evaluated
standalone there is no SDK import to define them. -->
<Target Name="LayoutMeshModuleClosures">
<PropertyGroup>
<_AppDir>$([MSBuild]::EnsureTrailingSlash('$(MeshModulesAppDir)'))</_AppDir>
</PropertyGroup>

<!-- The module's own dll in the APP root means a ships-the-bits ProjectReference came back
(the same-identity double-load trap — #1644's fail-if-referenced guard) or the publish
dir is stale from before the flip. Either way the layout would be a lie: fail. -->
<Error Condition="'$(MeshModulesGuardAppRoot)' == 'true' And Exists('$(_AppDir)%(MeshModuleClosure.Filename).dll')"
Text="LayoutMeshModuleClosures: %(MeshModuleClosure.Filename).dll is in the app publish root, but that module ships via modules/ only (#1644 step 2). Remove the re-added ProjectReference, or clean a stale publish directory." />

<!-- Restore explicitly: post-flip the module is OUTSIDE the host's project graph, so the
host's implicit restore never touches it and a clean machine has no assets file. A
separate invocation (different global properties than the build below) also sidesteps
MSBuild's evaluation reuse between a Restore and the build that needs its outputs. -->
<MSBuild Projects="@(MeshModuleClosure)"
Targets="Restore"
BuildInParallel="false"
Properties="Configuration=$(Configuration)"
RemoveProperties="MeshModulesAppDir;MeshModulesTargetFramework;MeshModulesHostName;MeshModulesGuardAppRoot;NetCoreTargetingPackRoot;PublishDir;PublishMeshModules" />

<!-- Build, then Publish with NoBuild — deliberately TWO invocations, and the Build one runs
with the module's ordinary global properties (RemoveProperties strips everything this
lane added, including an outer `-o`'s PublishDir). That makes it the SAME scheduled
instance as a solution-level build of the module (which is no longer ordered against the
host — nothing else serialises the two), so MSBuild merges the requests instead of
racing two builds over one obj/ — the documented CopyRefAssembly corruption class.
Folding Build into the Publish call would run it inside the PublishDir-divergent
instance and reopen exactly that race. -->
<MSBuild Projects="@(MeshModuleClosure)"
Targets="Build"
BuildInParallel="false"
Properties="Configuration=$(Configuration)"
RemoveProperties="MeshModulesAppDir;MeshModulesTargetFramework;MeshModulesHostName;MeshModulesGuardAppRoot;NetCoreTargetingPackRoot;PublishDir;PublishMeshModules" />
<!-- SatelliteResourceLanguages=en: without it the closure publish lays out a culture folder
per Roslyn localization (cs/de/es/… via Kernel.Hub), every one a same-identity duplicate
of the app's own satellite folders. Suppressing them at the SOURCE beats deleting them
afterwards (no empty-directory litter). Caveat, documented for the wave that flips a
module with genuinely-private localized deps: such a module would need its cultures
listed here. -->
<MSBuild Projects="@(MeshModuleClosure)"
Targets="Publish"
BuildInParallel="false"
Properties="Configuration=$(Configuration);NoBuild=true;SatelliteResourceLanguages=en;PublishDir=$(_AppDir)modules/%(Filename)/"
RemoveProperties="MeshModulesAppDir;MeshModulesTargetFramework;MeshModulesHostName;MeshModulesGuardAppRoot;NetCoreTargetingPackRoot;PublishMeshModules" />

<!-- Prune (0): RID trees. A module loads via Assembly.LoadFrom, which never consults the
module's deps.json — the fallback probe is the module's FLAT folder only — so a
runtimes/<rid>/ tree under a module folder is unreachable dead weight by construction
(today: a net8.0-flavoured Pkcs copy and the Windows EventLog message table). A module
that truly needs native/RID assets (Speech is the known exception, per the #1644 design)
needs explicit flat placement or NativeLibrary resolution in ITS flip, not a RID tree. -->
<RemoveDir Directories="$(_AppDir)modules/%(MeshModuleClosure.Filename)/runtimes"
Condition="Exists('$(_AppDir)modules/%(MeshModuleClosure.Filename)/runtimes')" />

<!-- Prune (a): same-identity files the app publish already carries, compared by RELATIVE
path (%(RecursiveDir)) so a nested duplicate matches the app's nested copy, not just
root-level names. The batched glob and the duplicate selection live in SEPARATE
ItemGroups: mixing a %(MeshModuleClosure.Filename) batch with a plain @(_ClosureFile)
transform in one element would re-consume the accumulated files once per batch
iteration when a second module joins this lane. -->
<ItemGroup>
<_ClosureFile Include="$(_AppDir)modules/%(MeshModuleClosure.Filename)/**/*.*" />
</ItemGroup>
<ItemGroup>
<_ClosureAppDuplicate Include="@(_ClosureFile)" Condition="Exists('$(_AppDir)%(RecursiveDir)%(Filename)%(Extension)')" />
</ItemGroup>
<Delete Files="@(_ClosureAppDuplicate)" />

<!-- Prune (b): shared-framework-provided assemblies. A standalone class-library publish
rolls Microsoft.Extensions.* in as ordinary package deps, but the APP resolves those
from the Microsoft.AspNetCore.App shared framework — so they are absent from the app
root and prune (a) cannot see them (the same-identity trap-door in publish-time form).
The targeting packs ARE the definition of what the shared framework provides; glob them
rather than hand-maintaining a name list that drifts with every framework bump. -->
<ItemGroup>
<_FrameworkRefAssembly Include="$(NetCoreTargetingPackRoot)/Microsoft.AspNetCore.App.Ref/*/ref/$(MeshModulesTargetFramework)/*.dll" />
<_FrameworkRefAssembly Include="$(NetCoreTargetingPackRoot)/Microsoft.NETCore.App.Ref/*/ref/$(MeshModulesTargetFramework)/*.dll" />
</ItemGroup>
<Error Condition="'@(_FrameworkRefAssembly->Count())' == '0'"
Text="LayoutMeshModuleClosures: no targeting packs found under '$(NetCoreTargetingPackRoot)' for $(MeshModulesTargetFramework) — cannot prune shared-framework assemblies from the module folders. (Any machine that builds the host has these packs; an empty glob means the path plumbing broke, and skipping the prune would silently ship framework duplicates.)" />
<PropertyGroup>
<_FrameworkNames>;@(_FrameworkRefAssembly->'%(Filename)%(Extension)');</_FrameworkNames>
</PropertyGroup>
<ItemGroup>
<_ClosureFileLeft Include="$(_AppDir)modules/%(MeshModuleClosure.Filename)/**/*.dll" />
</ItemGroup>
<ItemGroup>
<_ClosureFrameworkDuplicate Include="@(_ClosureFileLeft)"
Condition="$(_FrameworkNames.Contains(';%(Filename)%(Extension);'))" />
</ItemGroup>
<Delete Files="@(_ClosureFrameworkDuplicate)" />

<ItemGroup>
<_ClosureKept Include="$(_AppDir)modules/%(MeshModuleClosure.Filename)/**/*.*" />
</ItemGroup>
<Message Importance="high"
Text="LayoutMeshModuleClosures: @(MeshModuleClosure->Count()) closure module(s) → $(_AppDir)modules/; pruned @(_ClosureAppDuplicate->Count()) app-closure + @(_ClosureFrameworkDuplicate->Count()) shared-framework duplicate(s); kept: @(_ClosureKept->'%(Filename)%(Extension)')" />
</Target>
</Project>
7 changes: 7 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ correct for that module) is what makes the folder carry real content; which modu
becomes a publish decision while which ACTIVATE stays `Modules:Assemblies`. Skip the whole
target with `-p:PublishMeshModules=false`.

The first flipped module is `MeshWeaver.Markdown.Export`: no host references it any more — its
targets entry runs a full closure publish pruned against the app root AND the shared-framework
targeting packs, so its folder carries the engine assembly (measured private deps beyond it:
none; the engine's package closure still rides the app via other references). Because a flipped
DLL exists nowhere else, the closure lane also lays it into a plain build's output
(`bin/…/modules/`), keeping `dotnet run` on a host working without a publish step.

## Modules and the in-mesh compiler

In-mesh source compiles against the platform's `TRUSTED_PLATFORM_ASSEMBLIES` **plus this mesh's
Expand Down
3 changes: 3 additions & 0 deletions test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
shells call — an unmapped one there answers the SPA fallback with a 200, not a 404 (#1474). -->
<ProjectReference Include="..\..\memex\Memex.LocalMesh\Memex.LocalMesh.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Fixture\MeshWeaver.Fixture.csproj" />
<!-- Direct on purpose (#1644 step 2): the export engine left Memex.Portal.Shared's closure
(it ships via modules/), so ModuleLaneSwitchTest anchors the real DLL itself. -->
<ProjectReference Include="..\..\src\MeshWeaver.Markdown.Export\MeshWeaver.Markdown.Export.csproj" />
<!-- MigrationRegistryTest runs the migration runner's own start-up guard (VerifyComplete) in
`dotnet test`. It only ever ran INSIDE the migration container before, so a registry/
DbVersion.Latest drift shipped a dead image while main stayed green (V53, 2026-08-14). -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
<ProjectReference Include="..\..\src\MeshWeaver.Blazor\MeshWeaver.Blazor.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Mcp\MeshWeaver.Mcp.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Markdown.Collaboration\MeshWeaver.Markdown.Collaboration.csproj" />
<!-- Direct on purpose (#1644 step 2): the export engine left Memex.Portal.Shared's
closure (it ships via modules/), so the export tests reference it themselves. -->
<ProjectReference Include="..\..\src\MeshWeaver.Markdown.Export\MeshWeaver.Markdown.Export.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Messaging.Hub\MeshWeaver.Messaging.Hub.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Import\MeshWeaver.Import.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Maps\MeshWeaver.Maps.csproj" />
Expand Down
3 changes: 3 additions & 0 deletions test/MeshWeaver.Security.Test/MeshWeaver.Security.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
<ProjectReference Include="..\..\src\MeshWeaver.Blazor\MeshWeaver.Blazor.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Mcp\MeshWeaver.Mcp.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Markdown.Collaboration\MeshWeaver.Markdown.Collaboration.csproj" />
<!-- Direct on purpose (#1644 step 2): the export engine left Memex.Portal.Shared's
closure (it ships via modules/), so the export-menu/access tests reference it. -->
<ProjectReference Include="..\..\src\MeshWeaver.Markdown.Export\MeshWeaver.Markdown.Export.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Messaging.Hub\MeshWeaver.Messaging.Hub.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Import\MeshWeaver.Import.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Maps\MeshWeaver.Maps.csproj" />
Expand Down
Loading