From c681f617018d3e891593a2e7ad01c510a63accbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Mon, 17 Aug 2026 09:54:10 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(#1707):=20extract=20the=20NodeType=20c?= =?UTF-8?q?ompile=20toolchain=20into=20MeshWeaver.Compiler=20=E2=80=94=20t?= =?UTF-8?q?he=20framework=20identity=20pins=20the=20toolchain,=20not=20the?= =?UTF-8?q?=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything that shapes a dynamic NodeType compile's GENERATED INPUT moves below MeshWeaver.Graph into a dedicated MeshWeaver.Compiler assembly: the skeleton generator, CodeQueryResolver (+ CodeConventions), @@-include shaping and source aggregation/filter/join order (NodeCompileShaping), the reference set (CompileReferences), #r/generator shaping (GeneratorPipeline), parse/compile options + the staged, verified emit + the emit canary (EmitPipeline), failure diagnostics (CompileDiagnostics), the framework identity (FrameworkBuildIdentity, now also the process-lifetime FrameworkVersion accessor), the store naming (FileSystemAssemblyStore), and the pure adoption gate. FullMvidAssemblies flips to [MeshWeaver.Compiler, MeshWeaver.NuGet] — the NuGet entry closes a pre-existing hole (NuGetDirectiveParser/resolver shape compile input but were surface-hashed only). Graph body edits stop rebaking the world; 'rebuild only when we need to' (#1696) now holds in practice. The store's filename hash is a digest of the EMITTED bytes, so the extraction is complete by construction: any input-shaping left behind would serve stale DLLs. The mesh-actor half (discovery, impersonation, IoPool scheduling, status write-backs) stays in Graph and orchestrates the toolchain — one pipeline for the portal's on-demand compile, the batch bake, and the CI bake host. Moved public types keep their namespace and are TypeForwardedTo from Graph so modules compiled against earlier releases keep binding. Plugin.Build's IdentityAssembly anchor follows the toolchain (MeshWeaver.Compiler.dll); the manifest-less local fallback is the anchor's MVID (every persistent-store host ships a surface manifest and resolves the s-hash even locally). First roll after this merges mints a new identity (one-time full rebake — the key scheme changed), after which internal-only and Graph-body merges invalidate nothing. Docs (CiContentBake, NodeTypeCompilation incl. the corrected HasUsableBuild rules, ReleaseProcess, Modules, BuildCoordination) and all tests adapted. Part of #1707 (slice 1); under #1660. Co-Authored-By: Claude Fable 5 --- MeshWeaver.slnx | 1 + .../AssemblyStoreExtensions.cs | 0 src/MeshWeaver.Compiler/CodeConventions.cs | 26 + .../CodeQueryResolver.cs | 17 +- .../CompilationException.cs | 37 + .../CompilationInputs.cs | 0 src/MeshWeaver.Compiler/CompileDiagnostics.cs | 138 +++ src/MeshWeaver.Compiler/CompileReferences.cs | 111 ++ .../DynamicMeshNodeAttributeGenerator.cs | 0 src/MeshWeaver.Compiler/EmitPipeline.cs | 439 +++++++ .../EmittedArtifact.cs | 0 .../FileSystemAssemblyStore.cs | 16 +- .../FrameworkBuildIdentity.cs | 107 +- src/MeshWeaver.Compiler/GeneratorPipeline.cs | 91 ++ .../MeshWeaver.Compiler.csproj | 36 + src/MeshWeaver.Compiler/NodeCompileShaping.cs | 284 +++++ src/MeshWeaver.Compiler/README.md | 28 + .../SourceGeneratorLoader.cs | 0 .../SourceSnapshot.cs | 2 +- .../SpeculativeCompilation.cs | 2 +- .../Data/Architecture/BuildCoordination.md | 7 +- .../Data/Architecture/CiContentBake.md | 17 +- .../Data/Architecture/Modules.md | 3 +- .../Data/Architecture/NodeTypeCompilation.md | 74 +- .../Data/Architecture/ReleaseProcess.md | 6 +- ...-08-17-the-compiler-is-its-own-assembly.md | 25 + .../Configuration/CodeNodeType.cs | 10 +- .../MeshNodeCompilationService.cs | 1033 +---------------- .../Configuration/MeshNodeLanguageService.cs | 3 +- .../NodeTypeCompilationHelpers.cs | 43 +- .../Configuration/PrebuiltAssemblySeeder.cs | 36 +- src/MeshWeaver.Graph/MeshWeaver.Graph.csproj | 5 + .../Properties/CompilerTypeForwards.cs | 13 + src/MeshWeaver.Hosting/NodeTypeBatchBake.cs | 5 +- .../FrameworkIdentity.cs | 17 +- .../ModulePackCommand.cs | 30 +- .../CodeIncludePatternTest.cs | 15 +- .../CompileFailureReportedOnceTest.cs | 33 +- .../EmitToDiskWithRetryTest.cs | 23 +- .../FrameworkBuildIdentityTest.cs | 110 +- .../RecursiveCompilationTest.cs | 7 +- .../CodeEditRecompileTest.cs | 7 +- .../SourceSnapshotEstablishmentTest.cs | 5 +- .../BatchBakeSourceDiscoveryScaleTests.cs | 3 +- .../TestTraceLogFaultTest.cs | 2 +- 45 files changed, 1638 insertions(+), 1229 deletions(-) rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/AssemblyStoreExtensions.cs (100%) create mode 100644 src/MeshWeaver.Compiler/CodeConventions.cs rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/CodeQueryResolver.cs (94%) create mode 100644 src/MeshWeaver.Compiler/CompilationException.cs rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/CompilationInputs.cs (100%) create mode 100644 src/MeshWeaver.Compiler/CompileDiagnostics.cs create mode 100644 src/MeshWeaver.Compiler/CompileReferences.cs rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/DynamicMeshNodeAttributeGenerator.cs (100%) create mode 100644 src/MeshWeaver.Compiler/EmitPipeline.cs rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/EmittedArtifact.cs (100%) rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/FileSystemAssemblyStore.cs (94%) rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/FrameworkBuildIdentity.cs (72%) create mode 100644 src/MeshWeaver.Compiler/GeneratorPipeline.cs create mode 100644 src/MeshWeaver.Compiler/MeshWeaver.Compiler.csproj create mode 100644 src/MeshWeaver.Compiler/NodeCompileShaping.cs create mode 100644 src/MeshWeaver.Compiler/README.md rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/SourceGeneratorLoader.cs (100%) rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/SourceSnapshot.cs (98%) rename src/{MeshWeaver.Graph/Configuration => MeshWeaver.Compiler}/SpeculativeCompilation.cs (98%) create mode 100644 src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md create mode 100644 src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs diff --git a/MeshWeaver.slnx b/MeshWeaver.slnx index 1ecec19f39..77cf6a9fb1 100644 --- a/MeshWeaver.slnx +++ b/MeshWeaver.slnx @@ -61,6 +61,7 @@ + diff --git a/src/MeshWeaver.Graph/Configuration/AssemblyStoreExtensions.cs b/src/MeshWeaver.Compiler/AssemblyStoreExtensions.cs similarity index 100% rename from src/MeshWeaver.Graph/Configuration/AssemblyStoreExtensions.cs rename to src/MeshWeaver.Compiler/AssemblyStoreExtensions.cs diff --git a/src/MeshWeaver.Compiler/CodeConventions.cs b/src/MeshWeaver.Compiler/CodeConventions.cs new file mode 100644 index 0000000000..ab94bac010 --- /dev/null +++ b/src/MeshWeaver.Compiler/CodeConventions.cs @@ -0,0 +1,26 @@ +namespace MeshWeaver.Compiler; + +/// +/// The Code-node naming conventions the compile toolchain keys on. Defined here — below the +/// graph — because 's default +/// queries and path matching are part of the compile toolchain's identity boundary; +/// MeshWeaver.Graph's CodeNodeType aliases these same constants for the mesh-side +/// registration, so the two can never drift. +/// +public static class CodeConventions +{ + /// The NodeType value used to identify code nodes. + public const string CodeNodeType = "Code"; + + /// + /// The sub-namespace for source code files. Code nodes live under + /// {NodeTypePath}/Source/ alongside (not inside) their parent NodeType. + /// + public const string SourceSubNamespace = "Source"; + + /// + /// The sub-namespace for test code files. Tests live under {NodeTypePath}/Test/ + /// alongside (not inside) their parent NodeType. + /// + public const string TestSubNamespace = "Test"; +} diff --git a/src/MeshWeaver.Graph/Configuration/CodeQueryResolver.cs b/src/MeshWeaver.Compiler/CodeQueryResolver.cs similarity index 94% rename from src/MeshWeaver.Graph/Configuration/CodeQueryResolver.cs rename to src/MeshWeaver.Compiler/CodeQueryResolver.cs index bc543af2a3..c4e2de9bc6 100644 --- a/src/MeshWeaver.Graph/Configuration/CodeQueryResolver.cs +++ b/src/MeshWeaver.Compiler/CodeQueryResolver.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Text.RegularExpressions; +using MeshWeaver.Compiler; using MeshWeaver.Mesh; namespace MeshWeaver.Graph.Configuration; @@ -11,7 +12,7 @@ namespace MeshWeaver.Graph.Configuration; /// user sees in the UI is guaranteed to match the files the runtime actually /// pulls into the NodeType's assembly. /// -/// Rules (mirrored from ): +/// Rules (mirrored from NodeTypeDefinition.Sources): /// /// /// An optional name= prefix (e.g. "shared=@Lib/Common") @@ -34,19 +35,19 @@ namespace MeshWeaver.Graph.Configuration; public static class CodeQueryResolver { /// - /// Default Source query when a NodeType doesn't declare . + /// Default Source query when a NodeType doesn't declare NodeTypeDefinition.Sources. /// Resolves to {NodeTypePath}/Source subtree. /// public static readonly IReadOnlyList DefaultSources = - [$"namespace:{CodeNodeType.SourceSubNamespace} scope:subtree"]; + [$"namespace:{CodeConventions.SourceSubNamespace} scope:subtree"]; /// - /// Default Test query when a NodeType doesn't declare . + /// Default Test query when a NodeType doesn't declare NodeTypeDefinition.Tests. /// Resolves to {NodeTypePath}/Test subtree. Mirrors /// so the side-menu Sources/Tests split matches the convention. /// public static readonly IReadOnlyList DefaultTests = - [$"namespace:{CodeNodeType.TestSubNamespace} scope:subtree"]; + [$"namespace:{CodeConventions.TestSubNamespace} scope:subtree"]; /// Group name for unnamed Source query entries. public const string DefaultSourceGroupName = "src"; @@ -81,8 +82,8 @@ public static (string? Name, string Query) ParseName(string rawEntry) } /// - /// Expands one raw query entry from or - /// into one-or-more concrete mesh query + /// Expands one raw query entry from NodeTypeDefinition.Sources or + /// NodeTypeDefinition.Tests into one-or-more concrete mesh query /// strings ready for IMeshService.Query<MeshNode>. An optional /// name= prefix is stripped first — the compiler ignores grouping. /// @@ -146,7 +147,7 @@ private static string RebaseRelativeNamespace(string query, string selfPath) private static string WithCodeTypeFilter(string query) => query.Contains("nodeType:", System.StringComparison.OrdinalIgnoreCase) ? query - : $"{query} nodeType:{CodeNodeType.NodeType}"; + : $"{query} nodeType:{CodeConventions.CodeNodeType}"; /// /// Groups the raw Sources/Tests entries by their name= prefix (falling back diff --git a/src/MeshWeaver.Compiler/CompilationException.cs b/src/MeshWeaver.Compiler/CompilationException.cs new file mode 100644 index 0000000000..b1f19d84a5 --- /dev/null +++ b/src/MeshWeaver.Compiler/CompilationException.cs @@ -0,0 +1,37 @@ +// Moved verbatim from MeshNodeCompilationService.cs in MeshWeaver.Graph (#1707). The namespace +// stays MeshWeaver.Graph.Configuration and MeshWeaver.Graph type-forwards it, so modules compiled +// against earlier releases keep binding. +namespace MeshWeaver.Graph.Configuration; + +/// +/// Exception thrown when compilation fails. +/// +public class CompilationException : Exception +{ + /// The mesh path of the node whose compilation failed. + public string NodePath { get; } + + /// + /// Initializes a new instance of the exception for a failed compilation. + /// + /// The mesh path of the node whose compilation failed. + /// The error message describing the failure. + public CompilationException(string nodePath, string message) + : base(message) + { + NodePath = nodePath; + } + + /// + /// Initializes a new instance of the exception for a failed compilation, wrapping an + /// underlying cause. + /// + /// The mesh path of the node whose compilation failed. + /// The error message describing the failure. + /// The underlying exception that caused the failure. + public CompilationException(string nodePath, string message, Exception innerException) + : base(message, innerException) + { + NodePath = nodePath; + } +} diff --git a/src/MeshWeaver.Graph/Configuration/CompilationInputs.cs b/src/MeshWeaver.Compiler/CompilationInputs.cs similarity index 100% rename from src/MeshWeaver.Graph/Configuration/CompilationInputs.cs rename to src/MeshWeaver.Compiler/CompilationInputs.cs diff --git a/src/MeshWeaver.Compiler/CompileDiagnostics.cs b/src/MeshWeaver.Compiler/CompileDiagnostics.cs new file mode 100644 index 0000000000..a797e49ad6 --- /dev/null +++ b/src/MeshWeaver.Compiler/CompileDiagnostics.cs @@ -0,0 +1,138 @@ +using MeshWeaver.Graph.Configuration; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Lsp = MeshWeaver.Mesh.Services.LanguageServer; + +namespace MeshWeaver.Compiler; + +/// +/// Diagnostics shaping for dynamic NodeType compiles: the flattened failure summary, the +/// structured per-source-file re-derivation, and the source-discovery report. Pure over +/// / Roslyn diagnostics; the scheduling and the write-backs stay +/// with the caller in MeshWeaver.Graph. +/// +public static class CompileDiagnostics +{ + /// + /// Formats a failed Roslyn Emit's diagnostics into a complete, never-empty error + /// message — each line carries the diagnostic CS#### id, severity, source line and + /// message. Falls back to Warning-severity diagnostics when there are no Errors, and to an + /// explanatory sentence when Emit failed with NO diagnostics at all (typically a missing + /// source file or a configuration lambda referencing a type that was never compiled). The + /// previous Where(Severity == Error).Select(GetMessage) produced a bare + /// "Compilation failed for 'X':" whenever the failure carried no Error-severity diagnostic. + /// + internal static string FormatCompileFailure(string nodePath, IEnumerable diagnostics) + { + var joined = string.Join('\n', diagnostics + .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning) + .OrderByDescending(d => d.Severity) + .Select(d => + { + var loc = d.Location.IsInSource + ? $" (line {d.Location.GetLineSpan().StartLinePosition.Line + 1})" + : ""; + return $"{d.Id} {d.Severity}{loc}: {d.GetMessage()}"; + })); + return !string.IsNullOrEmpty(joined) + ? $"Compilation failed for '{nodePath}':\n{joined}" + : $"Compilation failed for '{nodePath}': Roslyn emit failed but produced no error/warning " + + "diagnostics — this usually means a source file was not found, or the configuration " + + "lambda references a type that was never compiled (see the source-discovery report below)."; + } + + /// + /// Sentinel FilePath for the generated skeleton tree — must match the one the LSP uses + /// so skeleton-internal diagnostics (framework noise the user can't act on) are filtered out. + /// + internal const string SkeletonDiagnosticsPath = "__skeleton__.cs"; + + /// + /// Re-derives a failed compile's diagnostics in their structured, per-source-file form by + /// assembling ONE LSP-style compilation (skeleton tree + one tree per src/test Code node, + /// each carrying the MeshNode path as its FilePath) — exactly the model + /// / the language service use, so a diagnostic's + /// is the Code node path. This is what lets the + /// GUI mark each error at its exact line/column in a Monaco editor and link to the source. + /// Synchronous, CPU-bound — the caller schedules it off the hub. + /// + internal static IReadOnlyList DiagnoseInputs(CompilationInputs inputs) + { + var trees = new List(inputs.Sources.Length + 1) + { + CSharpSyntaxTree.ParseText( + Microsoft.CodeAnalysis.Text.SourceText.From(inputs.SkeletonSource), + inputs.ParseOptions, path: SkeletonDiagnosticsPath), + }; + foreach (var (path, code) in inputs.Sources) + trees.Add(CSharpSyntaxTree.ParseText( + Microsoft.CodeAnalysis.Text.SourceText.From(code), inputs.ParseOptions, path: path)); + + // Structured failure diagnostics are best-effort: pass no generator candidates so + // RunSourceGenerators is a no-op here (the authoritative flat summary from the production + // compile already reflects generation). Avoids loading any generator on every failed compile. + var compilation = GeneratorPipeline.RunSourceGenerators( + CSharpCompilation.Create(inputs.AssemblyName, trees, inputs.References, inputs.CompilationOptions), + Array.Empty(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, CancellationToken.None); + + var diags = compilation.GetDiagnostics(); + if (diags.IsDefaultOrEmpty) return Array.Empty(); + + var result = new List(diags.Length); + foreach (var d in diags) + { + if (d.Severity is not (DiagnosticSeverity.Error or DiagnosticSeverity.Warning)) continue; + // Skeleton-internal diagnostics are framework noise the user can't act on. + if (d.Location.SourceTree?.FilePath == SkeletonDiagnosticsPath) continue; + result.Add(ToDiagnosticInfo(d)); + } + // Errors first, then by file then position — stable order for the GUI. + return result + .OrderByDescending(d => d.Severity) + .ThenBy(d => d.Location?.SourcePath, StringComparer.Ordinal) + .ThenBy(d => d.Location?.Range.Start.Line ?? 0) + .ToList(); + } + + internal static Lsp.DiagnosticInfo ToDiagnosticInfo(Diagnostic d) + { + Lsp.SourceLocation? location = null; + if (d.Location.IsInSource && d.Location.SourceTree?.FilePath is { Length: > 0 } path) + { + var span = d.Location.GetLineSpan(); + location = new Lsp.SourceLocation( + path, + new Lsp.SourceRange( + new Lsp.SourcePosition(span.StartLinePosition.Line, span.StartLinePosition.Character), + new Lsp.SourcePosition(span.EndLinePosition.Line, span.EndLinePosition.Character))); + } + return new Lsp.DiagnosticInfo(d.Id, MapDiagnosticSeverity(d.Severity), d.GetMessage(), location); + } + + internal static Lsp.DiagnosticSeverity MapDiagnosticSeverity(DiagnosticSeverity s) => s switch + { + DiagnosticSeverity.Hidden => Lsp.DiagnosticSeverity.Hidden, + DiagnosticSeverity.Info => Lsp.DiagnosticSeverity.Info, + DiagnosticSeverity.Warning => Lsp.DiagnosticSeverity.Warning, + DiagnosticSeverity.Error => Lsp.DiagnosticSeverity.Error, + _ => Lsp.DiagnosticSeverity.Info, + }; + + /// Formats the source-discovery report appended to every compile failure: every + /// executed query and every matched Code path. + internal static string BuildSourceDiscoveryReport( + IReadOnlyList executedQueries, IReadOnlyList matchedCodePaths) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"Executed source queries ({executedQueries.Count}):"); + foreach (var q in executedQueries) + sb.AppendLine($" - {q}"); + sb.AppendLine($"Matched Code nodes ({matchedCodePaths.Count}):"); + if (matchedCodePaths.Count == 0) + sb.AppendLine(" (none) — the configuration lambda cannot reference types because no source files were included. Check that your Source Code nodes exist and that the NodeType's `sources` list points at them."); + else + foreach (var p in matchedCodePaths) + sb.AppendLine($" - {p}"); + return sb.ToString(); + } +} diff --git a/src/MeshWeaver.Compiler/CompileReferences.cs b/src/MeshWeaver.Compiler/CompileReferences.cs new file mode 100644 index 0000000000..055ca9a4ea --- /dev/null +++ b/src/MeshWeaver.Compiler/CompileReferences.cs @@ -0,0 +1,111 @@ +using MeshWeaver.Mesh; +using Microsoft.CodeAnalysis; + +namespace MeshWeaver.Compiler; + +/// +/// The metadata-reference set a dynamic NodeType compile is built against — the process-wide TPA +/// baseline plus this mesh's installed-module composition. Which assemblies Roslyn can see is +/// part of the compile's input, so the construction lives inside the toolchain identity boundary +/// (#1707); MeshWeaver.Graph resolves the module list from the mesh's service tree and threads it +/// in. +/// +public static class CompileReferences +{ + /// + /// The process-wide default reference list — TPA assemblies plus a few well-known additions, + /// built once at type load (eager static init: a plain field read on every compile, no Lazy + /// dispatch, no synchronization; an immutable constant lookup in the NoStaticState.md sense). + /// + public static IReadOnlyList Default { get; } = GetDefaultReferences(); + + /// + /// Builds the process-wide MetadataReference list — TPA assemblies plus a few + /// well-known additions. Uses + /// (mmap, lazy read) — Roslyn typically reads only a small fraction of each + /// assembly's metadata, so the upfront cost is tiny. An earlier attempt at + /// + /// to avoid finalizer pressure ended up reading the whole DLL into managed + /// memory eagerly — net 10%+ slower in the autocomplete-test CPU profile, + /// since most of those bytes were never touched. The file-handle finalizer + /// pressure those references add is also tiny in practice (the static + /// property holds them for the process lifetime; finalizers only run at shutdown). + /// + private static List GetDefaultReferences() + { + var references = new List(); + + var trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (trustedAssemblies != null) + { + foreach (var path in trustedAssemblies.Split(Path.PathSeparator)) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + continue; + try + { + references.Add(MetadataReference.CreateFromFile(path)); + } + catch + { + // Skip assemblies that can't be loaded + } + } + } + + // Three well-known additions in case TPA didn't include them. Dedup + // against TPA-derived set by Display path so we don't double-load. + var seen = new HashSet( + references.Select(r => r.Display ?? string.Empty), + StringComparer.OrdinalIgnoreCase); + foreach (var assembly in new[] + { + typeof(object).Assembly, // System.Runtime + typeof(System.ComponentModel.DataAnnotations.KeyAttribute).Assembly, // DataAnnotations + typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute).Assembly, // System.Text.Json + }) + { + if (!string.IsNullOrEmpty(assembly.Location) + && File.Exists(assembly.Location) + && seen.Add(assembly.Location)) + { + try + { + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + catch + { + // Skip if it can't be loaded + } + } + } + + return references; + } + + /// + /// Composes with THIS mesh's installed module assemblies + /// (Modules:Assemblies → , design #1644). A module + /// published into modules/<name>/ is not in TRUSTED_PLATFORM_ASSEMBLIES, + /// so in-mesh consumers (e.g. a scope class using a map control) would silently lose it + /// without this. Deduped by path — a module still riding the app closure appears in both + /// sets. Returns itself for a zero-module mesh. + /// + public static IReadOnlyList ComposeWithModules( + IReadOnlyList modules) + { + if (modules.Count == 0) + return Default; + var seen = new HashSet( + Default.Select(r => r.Display ?? string.Empty), + StringComparer.OrdinalIgnoreCase); + var composed = new List(Default); + foreach (var module in modules) + { + var location = module.Assembly.Location; + if (!string.IsNullOrEmpty(location) && File.Exists(location) && seen.Add(location)) + composed.Add(MetadataReference.CreateFromFile(location)); + } + return composed; + } +} diff --git a/src/MeshWeaver.Graph/Configuration/DynamicMeshNodeAttributeGenerator.cs b/src/MeshWeaver.Compiler/DynamicMeshNodeAttributeGenerator.cs similarity index 100% rename from src/MeshWeaver.Graph/Configuration/DynamicMeshNodeAttributeGenerator.cs rename to src/MeshWeaver.Compiler/DynamicMeshNodeAttributeGenerator.cs diff --git a/src/MeshWeaver.Compiler/EmitPipeline.cs b/src/MeshWeaver.Compiler/EmitPipeline.cs new file mode 100644 index 0000000000..facb545ee6 --- /dev/null +++ b/src/MeshWeaver.Compiler/EmitPipeline.cs @@ -0,0 +1,439 @@ +using MeshWeaver.Graph.Configuration; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.Compiler; + +/// +/// The Roslyn parse/compile/emit leg of a dynamic NodeType compile — canonical options, the +/// staged + verified disk emit, the in-memory emit, and the emit canary. Everything here +/// determines the emitted bytes given the shaped input, so it lives inside the toolchain +/// identity boundary (#1707). The scheduling (which thread, which timeout, which status +/// write-back) stays with the caller in MeshWeaver.Graph. +/// +public static class EmitPipeline +{ + /// The canonical parse options every dynamic NodeType compile uses — shared by the + /// emit path, the LSP model, and the failure-diagnostics re-derivation so they can never + /// diverge. + internal static CSharpParseOptions CreateParseOptions() + => new(documentationMode: DocumentationMode.Diagnose); + + /// The canonical compilation options every dynamic NodeType compile uses. + internal static CSharpCompilationOptions CreateCompilationOptions() + => new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithOptimizationLevel(OptimizationLevel.Debug) + .WithPlatform(Platform.AnyCpu); + + /// + /// Builds the single-tree emit compilation for the generated source: parse with the source + /// path and UTF-8 encoding embedded (critical for PDB source linking) + the canonical + /// options. + /// + internal static CSharpCompilation CreateEmitCompilation( + string source, + string assemblyName, + IEnumerable references, + string parsePath, + CancellationToken ct) + { + var sourceText = Microsoft.CodeAnalysis.Text.SourceText.From(source, System.Text.Encoding.UTF8); + var syntaxTree = CSharpSyntaxTree.ParseText( + sourceText, CreateParseOptions(), path: parsePath, cancellationToken: ct); + return CSharpCompilation.Create( + assemblyName, + syntaxTrees: [syntaxTree], + references: references, + options: CreateCompilationOptions()); + } + + /// + /// Runs the real Roslyn emit for into + /// (dll + pdb + XML doc) and returns the DLL path it wrote TOGETHER WITH the digest of the image + /// it produced. A failed emit throws a carrying the formatted + /// diagnostics. + /// + /// 🚨 It emits into memory and writes the bytes out, rather than streaming Roslyn straight + /// at the file, for one reason: the publisher has to be able to prove the file on disk IS the + /// image that was emitted. Streaming leaves nothing to compare against, which is how the old + /// Length > 0 gate came to publish an artifact whose metadata had an unwritten region + /// in it and PARK the NodeType for good (#1412). Peak memory is unchanged in practice — Roslyn + /// already serialises the whole PE into an in-memory BlobBuilder before it writes a single + /// byte. See . + /// + /// 🚨 It does NOT log. A compile failure is reported EXACTLY ONCE, by the compile + /// pipeline's single .Catch<…, CompilationException> funnel in + /// MeshNodeCompilationService — the only place that also has the exception, its stack + /// and the source-discovery report (which queries ran, which Code nodes matched). Logging the + /// same diagnostics here as well double-counted EVERY compile failure in production: the ~150 + /// ERROR lines/24h across the memex/memex-cloud/atioz portals were ~72 real failures logged + /// twice, and the duplicate came FIRST — context-free and exception-free, so red-log + /// fingerprinting (which keys on category+eventId+exception+frame) filed it as a second, + /// distinct fault whose only visible frame was the emit path. That is what made a plain + /// "your C# does not compile" read like an emit/IO defect. internal so + /// the log-once contract is unit-testable against a real broken compilation. + /// + internal static EmittedArtifact EmitCompilationToDirectory( + CSharpCompilation compilation, string nodeName, string nodePath, string releaseDir, CancellationToken ct) + { + var dllPath = Path.Combine(releaseDir, $"{nodeName}.dll"); + var pdbPath = Path.Combine(releaseDir, $"{nodeName}.pdb"); + var xmlDocPath = Path.Combine(releaseDir, $"DynamicNode_{nodeName}.xml"); + + using var dllImage = new MemoryStream(); + using var pdbImage = new MemoryStream(); + using var xmlDoc = new MemoryStream(); + + var emitOptions = new EmitOptions( + debugInformationFormat: DebugInformationFormat.PortablePdb, + pdbFilePath: pdbPath); + + EmitResult emitResult; + try + { + emitResult = compilation.Emit( + dllImage, pdbImage, xmlDocumentationStream: xmlDoc, + options: emitOptions, cancellationToken: ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Roslyn THREW instead of returning diagnostics — an emit-phase fault, not a + // compile error. Stamp the canary verdict on the exception and rethrow the + // ORIGINAL untouched: the type name is what CI triage keys on, so this must + // never become a wrapper. The verdict travels to the pipeline's single + // reporting funnel via SummarizeCompileError — no second log from here, the + // log-once contract above still holds. + ex.Data[EmitCanaryDataKey] = ProbeSharedEmitState(compilation); + throw; + } + + if (!emitResult.Success) + // Deterministic compile error — propagates straight out of the retry loop, + // unlogged, to the pipeline's single reporting funnel. + throw new CompilationException(nodePath, + CompileDiagnostics.FormatCompileFailure(nodePath, emitResult.Diagnostics)); + + // The DLL last: it is the discovery key of the release directory, so a reader that ever sees + // the staging dir mid-write still finds the symbols and docs already beside it. (Publication + // itself is the atomic Directory.Move in EmitToDiskWithRetry; this is belt and braces.) + File.WriteAllBytes(pdbPath, Bytes(pdbImage)); + File.WriteAllBytes(xmlDocPath, Bytes(xmlDoc)); + var image = Bytes(dllImage); + File.WriteAllBytes(dllPath, image); + + return EmittedArtifact.For(dllPath, image); + + // Expandable MemoryStreams created with the parameterless ctor expose their buffer, so the + // common path hands out a span over it instead of copying a multi-megabyte image. + static ReadOnlySpan Bytes(MemoryStream s) + => s.TryGetBuffer(out var seg) ? seg.AsSpan() : s.ToArray(); + } + + /// + /// Key under which stamps the canary verdict on a + /// thrown-from-Emit exception, and under which + /// NodeTypeCompilationHelpers.SummarizeCompileError reads it back. + /// + internal const string EmitCanaryDataKey = "MeshWeaver.EmitCanary"; + + /// + /// A minimal, self-contained compilation used ONLY by . + /// Three levels of nested generics on purpose: that is what makes Roslyn's metadata writer + /// walk a type's containing chain (GetConsolidatedTypeParameters recursing through + /// ContainingTypeDefinition) — the exact path issue #890's NRE dies on. A flat class + /// would emit fine even on a poisoned writer and the canary would answer "healthy" wrongly. + /// + private const string EmitCanarySource = + "public class MwEmitCanary { public class Inner { public class Leaf " + + "{ public T A; public U B; public V C; } } }"; + + /// + /// Answers, at the moment a Roslyn Emit throws, which state is actually broken — in + /// TWO legs, because the first leg alone cannot tell "the shared reference set is poisoned" + /// from "the process is broken below Roslyn". + /// + /// Leg 1 — same references. Re-emit a trivial nested-generic compilation built + /// against the same instances as the compilation that + /// just failed. Succeeds ⇒ shared state is healthy and the fault is a property of this node's + /// own compilation inputs (dump its generated source). This is the leg #1378 shipped, and on + /// 2026-08-13 it returned THREW — closing the "generated source" half of the search + /// space. + /// + /// Leg 2 — pristine references. Run only when leg 1 fails: the SAME source + /// against a freshly created, minimal reference set that has never been handed to Roslyn + /// before and is shared with nothing. This is the discriminator, and it exists because + /// Roslyn's own source settles who can supply the null. The NRE's guard + /// (NamedTypeSymbolAdapter.AsNestedTypeDefinitionImpl) admits a type only when + /// ContainingModule == moduleBeingBuilt.SourceModule — so the symbol whose + /// ContainingType reads null is a source symbol of the compilation being + /// emitted, never a PE symbol arriving from a reference. Leg 1 therefore proves the fault is + /// process-wide without proving the reference set carries it. + /// + /// canary=REFERENCES — pristine emits, shared does not: the poison travels + /// with the reference instances (or the symbols Roslyn caches on their + /// AssemblyMetadata), and scoping the set per-mesh is on the right axis. + /// canary=BELOW-ROSLYN — neither emits: brand-new references and freshly + /// parsed source still cannot emit, so nothing about the reference set explains it. The + /// broken state is under Roslyn (CLR heap / JIT / GC) and no reference-set change can fix + /// it. Roslyn keeps no cross-emit state — the metadata writer's indices are per-emit, its + /// object pools hold only scratch buffers, and AssemblyMetadata.CachedSymbols is a + /// weak list of assembly symbols — so there is no Roslyn cache left to blame. + /// canary=INCONCLUSIVE — the pristine control could not be BUILT (no on-disk + /// CoreLib to reference), so leg 2 never ran. Reported as its own verdict rather than + /// folded into BELOW-ROSLYN: a probe that answers its scariest branch on its own + /// inability would send triage after a CLR heap bug nothing observed. + /// + /// + /// 🚨 It cannot fail into the fault path it is diagnosing: every outcome — including + /// the canary throwing — returns a STRING. A diagnostic that throws while diagnosing would + /// replace the original exception and destroy the evidence it exists to preserve. It is also + /// bounded: one tiny in-memory emit, only ever on an already-failing path, never on the + /// success path or on a normal compile error. + /// + /// The compilation whose Emit threw; only its references are used. + /// A one-line verdict, safe to append to a log message. + internal static string ProbeSharedEmitState(CSharpCompilation faulted) + { + var shared = EmitCanary(() => faulted.References); + if (shared.StartsWith("OK", StringComparison.Ordinal)) + return "canary=OK (a trivial nested-generic emit against the SAME reference set " + + "still succeeds ⇒ shared Roslyn/reference state is healthy; the fault is " + + "specific to THIS compilation's inputs — dump its generated source)"; + + // The shared-reference canary failed. Re-run the SAME source against a reference set that + // shares NOTHING with this process's other compilations — freshly created, minimal + // (System.Private.CoreLib is all the canary source needs), and never handed to Roslyn + // before. + // + // 🚨 BUILDING the pristine reference is a SEPARATE step from emitting against it, and its + // failure is a SEPARATE verdict. If CreateFromFile cannot produce it (an empty + // Assembly.Location on a single-file host, a missing file), the discriminator was never + // run — and folding that into BELOW-ROSLYN would make the probe answer its scariest + // branch on its own inability, sending triage after a CLR heap bug that nothing observed. + // A diagnostic that cannot report "I could not run" is the same defect as a gate that + // passes when its input is missing. + string? pristineUnavailable = null; + IReadOnlyList pristineRefs = []; + try + { + var coreLib = typeof(object).Assembly.Location; + if (string.IsNullOrEmpty(coreLib) || !File.Exists(coreLib)) + pristineUnavailable = "no on-disk System.Private.CoreLib to reference"; + else + pristineRefs = [MetadataReference.CreateFromFile(coreLib)]; + } + catch (Exception buildError) + { + pristineUnavailable = $"{buildError.GetType().Name}: {buildError.Message}"; + } + + if (pristineUnavailable is not null) + return $"canary=INCONCLUSIVE shared:{shared} pristine:UNAVAILABLE({pristineUnavailable}) " + + "— the shared reference set cannot emit, but the pristine control could not be " + + "BUILT, so this says nothing about whether the reference set is the cause"; + + var pristine = EmitCanary(() => pristineRefs); + + return pristine.StartsWith("OK", StringComparison.Ordinal) + ? $"canary=REFERENCES shared:{shared} pristine:{pristine} — the same source emits fine " + + "against BRAND-NEW references but fails against the shared set ⇒ the poison " + + "travels with the MetadataReference instances / the Roslyn symbols cached on " + + "them (reference-set scoping is the right axis)" + : $"canary=BELOW-ROSLYN shared:{shared} pristine:{pristine} — a trivial compilation " + + "with BRAND-NEW references and freshly parsed source cannot emit either ⇒ " + + "nothing about the reference set explains this; the broken state is below " + + "Roslyn (CLR heap / JIT / GC), so no reference-set change can fix it — " + + "capture a core dump and re-run with tiering disabled"; + } + + /// + /// One canary leg: emit against the references + /// produces, into memory, and reduce the outcome to a short + /// token. Never throws — see the "cannot fail into the fault path it is diagnosing" note on + /// . The references arrive as a FACTORY so that building + /// them is inside this method's try as well: on a poisoned process even + /// MetadataReference.CreateFromFile is a candidate to fail. + /// + private static string EmitCanary(Func> references) + { + try + { + var canary = CSharpCompilation.Create( + "MeshWeaverEmitCanary", + syntaxTrees: [CSharpSyntaxTree.ParseText(EmitCanarySource)], + references: references(), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using var canaryStream = new MemoryStream(); + var result = canary.Emit(canaryStream); + if (result.Success) + return "OK"; + + var ids = result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.Id).Distinct().Take(5); + return $"DIAGNOSTICS({string.Join(",", ids)})"; + } + catch (Exception probeError) + { + return $"THREW {probeError.GetType().Name}: {probeError.Message}"; + } + } + + /// + /// Number of times re-emits when a "successful" Roslyn + /// emit leaves no assembly on disk (ephemeral-cache eviction). Three attempts recover a + /// transient lost write while still failing fast on a genuinely unwritable cache directory. + /// + /// Why a retry is legitimate here (and is NOT covering for a defect of ours): the + /// condition is genuinely EXTERNAL and transient — a container runtime reclaiming an + /// ephemeral /tmp under memory pressure between our write and our read. Nothing in + /// this process can prevent it, there is no lock/slot/budget being leaked, and the retry is + /// bounded, stateless and timer-free: three synchronous attempts, then a loud terminal + /// failure. A deterministic compile error is explicitly NOT retried. Its counterfactual is a + /// permanently poisoned NodeType (prod AgenticPension/Datenpunkt, 2026-06-22), not a slower + /// recovery. Evidence that it is not masking anything: across memex + memex-cloud + atioz it + /// has fired ZERO times in 7 days (31M log lines) — every ERROR the compile service emits in + /// production is a genuine Roslyn diagnostic, never a lost write. + /// + internal const int DiskEmitAttempts = 3; + + /// + /// Emits to a fresh per-attempt subdirectory under and + /// confirms the assembly on disk IS the image that was emitted, re-emitting up to + /// times when it is not. runs + /// the real Roslyn emit into the supplied directory and returns the DLL path together with the + /// digest of the image it produced (); it may throw + /// for a genuine compile error, which propagates immediately + /// (NEVER retried — only a lost or mismatched artifact triggers a re-emit). internal so + /// the publication contract is unit-testable without a real flaky filesystem. + /// + internal static string EmitToDiskWithRetry( + string cacheDirectory, + string nodeName, + int maxAttempts, + ILogger logger, + Func emitToReleaseDir) + { + string? lastDllPath = null; + // Why the LAST attempt failed to publish, carried into the terminal exception: "could not + // be persisted" alone sent operators looking for a read-only cache directory when the real + // answer was an artifact that is present and unreadable. + var lastReason = "the artifact never appeared"; + + static void TryDeleteDir(string dir) + { + try { Directory.Delete(dir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + var timestamp = DateTimeOffset.UtcNow.Ticks.ToString("x"); + // Unique published name. Discovery orders by the dir's LastWriteTime, NOT by parsing the + // ticks out of the name (see TryGetLatestCachedDllPath), so a GUID suffix guarantees the + // atomic Directory.Move below never collides — on a coarse clock or two rapid compiles — + // while still matching the `{nodeName}_*` glob. + var releaseDir = Path.Combine(cacheDirectory, $"{nodeName}_{timestamp}_{Guid.NewGuid():N}"); + lastDllPath = Path.Combine(releaseDir, $"{nodeName}.dll"); + + // 🚨 Emit into a STAGING dir whose name does NOT match the `{nodeName}_*` discovery glob + // (TryGetLatestCachedDllPath), then atomically publish it by renaming to the discoverable + // name only AFTER the DLL is fully written + verified. The DLL file exists at 0 bytes and + // grows during compilation.Emit (File.Create + Emit is NOT atomic); without staging, a + // concurrent reader can discover the half-written DLL and LoadFromAssemblyPath a truncated + // image → a native crash (SIGSEGV) or a BadImageFormat that deletes the artifact and churns + // the compile. A directory rename on the same filesystem is atomic, so a reader sees either + // nothing or the COMPLETE artifact. + var stagingDir = Path.Combine(cacheDirectory, $".staging-{nodeName}-{timestamp}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(stagingDir); + + // The real emit (a genuine compile error throws straight through — never retried). Discard + // the half-written staging dir first so a failed emit leaves no partial artifact behind (the + // old code leaked a glob-discoverable `{nodeName}_{ticks}` dir here — the same hazard). + EmittedArtifact staged; + try + { + staged = emitToReleaseDir(stagingDir); + } + catch + { + TryDeleteDir(stagingDir); + throw; + } + + // Confirm the staged file IS the image the emit produced, then atomically publish. EVERY + // fault here is a RETRYABLE publish failure — an ephemeral-cache eviction racing the + // read, a lost or partial write, or a transient rename IO error — so discard staging and + // re-emit rather than aborting the compile. + // + // 🚨 The predicate is "these are the bytes we emitted", NOT "this file is non-empty". + // `Length > 0` accepts a 1-byte file, a truncated PE, and — the case that survived + // #1387 — a full-length image with an unwritten region inside its metadata. None of + // those is rejected by the loader in any way the pipeline survives: the first two make + // LoadNodeAssembly return null, the third loads fine and throws + // ReflectionTypeLoadException "…because the format is invalid" on the first GetTypes(). + // CompileResultFromAssembly records either as CompilationStatus.Error, and the + // first-build kickoff is gated on Status == null, so it NEVER retries — the bytes may + // heal, the verdict does not, and the NodeType is parked for good (#1412). Proving the + // artifact before it enters the discovery namespace is the publication contract, the + // same one AtomicFileWrite gives the assembly store; it is emphatically NOT a retry + // around a load. See EmittedArtifact for why a digest and not a metadata walk. + try + { + if (staged.MatchesFileOnDisk(out lastReason)) + { + Directory.Move(stagingDir, releaseDir); + return lastDllPath; + } + + logger.LogWarning( + "Emit for {NodeName} reported success but the staged assembly at {DllPath} is " + + "not the image that was emitted — {Reason} (attempt {Attempt}/{Max}); re-emitting.", + nodeName, staged.DllPath, lastReason, attempt, maxAttempts); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + lastReason = $"publishing failed — {ex.GetType().Name}: {ex.Message}"; + logger.LogWarning(ex, + "Publishing the emitted assembly for {NodeName} failed (attempt {Attempt}/{Max}); re-emitting.", + nodeName, attempt, maxAttempts); + } + + // Drop the staging directory so the retry starts clean. + TryDeleteDir(stagingDir); + } + + throw new CompilationException(nodeName, + $"Compilation succeeded but the emitted assembly for '{nodeName}' could not be published to " + + $"'{cacheDirectory}' after {maxAttempts} attempts (last target '{lastDllPath}'; last failure: " + + $"{lastReason}). The compilation host's cache directory may be read-only, evicting files, or " + + "losing writes."); + } + + /// + /// Compiles and emits the assembly to memory (no disk I/O), returning the DLL + PDB bytes for + /// the caller to load. Like , a failed emit throws + /// UNLOGGED — the pipeline's single .Catch<…, CompilationException> funnel is the + /// one reporter of a compile failure. + /// + internal static (byte[] AssemblyBytes, byte[] PdbBytes) EmitToMemory( + CSharpCompilation compilation, string nodePath, CancellationToken ct) + { + using var dllStream = new MemoryStream(); + using var pdbStream = new MemoryStream(); + + var emitOptions = new EmitOptions( + debugInformationFormat: DebugInformationFormat.PortablePdb); + + var emitResult = compilation.Emit(dllStream, pdbStream, options: emitOptions, cancellationToken: ct); + + if (!emitResult.Success) + throw new CompilationException(nodePath, + CompileDiagnostics.FormatCompileFailure(nodePath, emitResult.Diagnostics)); + + return (dllStream.ToArray(), pdbStream.ToArray()); + } +} diff --git a/src/MeshWeaver.Graph/Configuration/EmittedArtifact.cs b/src/MeshWeaver.Compiler/EmittedArtifact.cs similarity index 100% rename from src/MeshWeaver.Graph/Configuration/EmittedArtifact.cs rename to src/MeshWeaver.Compiler/EmittedArtifact.cs diff --git a/src/MeshWeaver.Graph/Configuration/FileSystemAssemblyStore.cs b/src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs similarity index 94% rename from src/MeshWeaver.Graph/Configuration/FileSystemAssemblyStore.cs rename to src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs index ccb88f4070..40895954b8 100644 --- a/src/MeshWeaver.Graph/Configuration/FileSystemAssemblyStore.cs +++ b/src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs @@ -180,7 +180,7 @@ public IObservable PutWithLocation(string nodeTypePath, l /// /// The root directory this cache lives in — the same directory - /// sweeps and claims against. + /// AssemblyCacheGenerations (MeshWeaver.Graph) sweeps and claims against. /// public string RootDirectory => rootDirectory; @@ -191,15 +191,17 @@ public IObservable PutWithLocation(string nodeTypePath, l /// produce INCOMPATIBLE DLLs. Without this tag a freshly-deployed image's lookup matched (and /// first-write-wins RETURNED) the PREVIOUS image's DLL → System.BadImageFormatException on ALC /// load, which cascaded into failed grain activations and a portal-wide wedge on deploy (prod - /// 2026-06-20). The MVID (Graph module content hash) changes only when the framework bytes - /// change, so a new image misses the old DLLs (clean recompile) while an unchanged framework - /// still hits the cache. + /// 2026-06-20). The identity () + /// changes only when the framework's content-facing surface / toolchain changes, so a new + /// image misses the old DLLs (clean recompile) while an unchanged framework still hits the + /// cache. /// /// It is also the GENERATION key: a whole new set of files is written per image, and - /// nothing in the store removes an old one — see for the - /// retention sweep and for why the growth is by design while the unboundedness was not. + /// nothing in the store removes an old one — see AssemblyCacheGenerations (in + /// MeshWeaver.Graph) for the retention sweep and for why the growth is by design while the + /// unboundedness was not. /// - public static readonly string FrameworkTag = NodeTypeCompilationHelpers.FrameworkVersion[..8]; + public static readonly string FrameworkTag = FrameworkBuildIdentity.FrameworkVersion[..8]; private string GetDllPath(string nodeTypePath, long version, byte[] bytes) { diff --git a/src/MeshWeaver.Graph/Configuration/FrameworkBuildIdentity.cs b/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs similarity index 72% rename from src/MeshWeaver.Graph/Configuration/FrameworkBuildIdentity.cs rename to src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs index 8a5bcfff1e..199e5eb822 100644 --- a/src/MeshWeaver.Graph/Configuration/FrameworkBuildIdentity.cs +++ b/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs @@ -8,11 +8,11 @@ namespace MeshWeaver.Graph.Configuration; /// /// THE framework build identity every compiled NodeType release is pinned to (issue #1660 WS3) — -/// the value resolves and every other -/// reading (the assembly-store filename tag, CompiledFrameworkVersion stamps, the CI bake -/// artifact key, PrebuiltAssemblySeeder's adoption gate, the build-protocol fingerprint) -/// flows from. One identity, one resolution — a producer and a consumer can never disagree about -/// what "the framework" is. +/// the value resolves and every other reading (the assembly-store +/// filename tag, CompiledFrameworkVersion stamps, the CI bake artifact key, +/// PrebuiltAssemblySeeder's adoption gate, the build-protocol fingerprint) flows from. One +/// identity, one resolution — a producer and a consumer can never disagree about what "the +/// framework" is. /// /// Three schemes, in resolution order: /// @@ -36,8 +36,14 @@ namespace MeshWeaver.Graph.Configuration; /// (clean checkout at a named commit). The fallback for CI-built processes WITHOUT a surface /// manifest (test hosts on CI), and retained everywhere as DIAGNOSTIC PROVENANCE — logged beside /// the surface identity so an operator can always answer "which commit built this". -/// The Graph MVID — local builds with neither: a content identity, -/// exact for a dirty working tree. +/// The anchor MVID — local builds with neither: the +/// MeshWeaver.Compiler assembly's MVID, a content identity of the toolchain, exact for a +/// dirty working tree. Deliberately a SINGLE file's identity so a producer can attribute it +/// without loading anything (MeshWeaver.Plugin.Build.FrameworkIdentity.ReadIdentity). +/// The fallback governs only manifest-less processes — test hosts and ad-hoc tools; every host +/// that compiles against a PERSISTENT assembly store (the portals, the CI bake host, +/// mw-plugin-test) ships a surface manifest and resolves the surface identity, locally +/// included. /// /// /// Why a canonical LIST and not the ambient TPA: the bake host (mw-plugin-test) and @@ -83,6 +89,7 @@ public static class FrameworkBuildIdentity "MeshWeaver.AI", "MeshWeaver.Application.Styles", "MeshWeaver.Approvals", + "MeshWeaver.Compiler", "MeshWeaver.ContentCollections", "MeshWeaver.ContentCollections.Indexing", "MeshWeaver.ContentCollections.Indexing.Graph", @@ -124,17 +131,26 @@ public static class FrameworkBuildIdentity /// NodeType compile — a body-only change there alters what Roslyn is fed without any API /// change, so the surface hash alone would under-invalidate. Each entry names why: /// - /// MeshWeaver.Graph — carries the NodeType compile pipeline's - /// emitters and source shaping: DynamicMeshNodeAttributeGenerator (the generated - /// attribute/skeleton source injected into every dynamic NodeType compilation), - /// MeshNodeCompilationService (source aggregation, @@ include resolution and - /// rebasing, #r/NuGet directive handling), and CodeQueryResolver (which source - /// files a compile consumes). Swept 2026-08-16: the other generator in the repo - /// (SkeletonGenerator in MeshWeaver.Plugin.Build) is plugin BUILD tooling — not loaded - /// by the portal's compile path and not in the canonical set. + /// MeshWeaver.Compiler — THE compile toolchain (#1707): the + /// skeleton generator (DynamicMeshNodeAttributeGenerator — the generated + /// attribute/provider source injected into every dynamic NodeType compilation), source-query + /// resolution (CodeQueryResolver — which source files a compile consumes), the + /// @@-include resolution and rebasing, source aggregation/filtering/join order, + /// parse/compilation options, generator execution, and the emit itself. Before #1707 this + /// code lived in MeshWeaver.Graph and pinned ALL of Graph — the highest-churn assembly + /// in the repo — so nearly every merge rebaked the world; the extraction is what makes + /// "rebuild only when we need to" hold in practice. Swept 2026-08-16: the other generator in + /// the repo (SkeletonGenerator in MeshWeaver.Plugin.Build) is plugin BUILD tooling — + /// not loaded by the portal's compile path and not in the canonical set. + /// MeshWeaver.NuGet — NuGetDirectiveParser shapes what + /// Roslyn is fed (which #r "nuget:" lines are stripped and what they resolve to), and + /// the resolver decides which assemblies a directive adds to the reference set. A body-only + /// change to either alters compile inputs with no API change — before #1707 this was a HOLE: + /// the assembly was surface-hashed only. /// /// - public static readonly ImmutableArray FullMvidAssemblies = ["MeshWeaver.Graph"]; + public static readonly ImmutableArray FullMvidAssemblies = + ["MeshWeaver.Compiler", "MeshWeaver.NuGet"]; /// Marker recorded for a canonical assembly absent from the manifest/process — the /// absence itself is part of the identity (two hosts with different presence sets must never @@ -192,14 +208,37 @@ public static IReadOnlyDictionary ParseSurfaceManifest(string ma /// /// The pure resolution rule for the FALLBACK layer (no surface manifest): a non-blank stamped - /// commit identity wins; otherwise the caller's content identity (the Graph MVID) applies. + /// commit identity wins; otherwise the caller's content identity (the anchor assembly's MVID) + /// applies. /// /// The attribute value, or null when the /// assembly carries none (every local build). - /// The fallback content identity (Graph's MVID, "N" format). + /// The fallback content identity (the anchor's MVID, "N" + /// format). public static string Resolve(string? stamped, string contentIdentity) => string.IsNullOrWhiteSpace(stamped) ? contentIdentity : stamped; + /// + /// The live MeshWeaver framework identity a compiled NodeType release is pinned to — THE one + /// process-lifetime resolution, anchored on this (the MeshWeaver.Compiler) assembly. Every + /// consumer — the assembly-store filename tag, CompiledFrameworkVersion stamps, the CI + /// bake artifact key, the seeder's adoption gate, the build-protocol fingerprint — reads it + /// from here (directly or through the delegating shims in MeshWeaver.Graph), so a + /// producer and a consumer can never disagree about what "the framework" is. A mismatch + /// against a NodeType's CompiledFrameworkVersion means "recompile". + /// + public static string FrameworkVersion => Resolved.Value.Identity; + + /// Degradation warning from the identity resolution (a torn/unusable surface + /// manifest fell back to the stamp/MVID layer), or null on the happy path — cached with the + /// identity itself so the pre-warmer can log it beside the identity it announces. + public static string? FrameworkVersionWarning => Resolved.Value.Warning; + + private static readonly Lazy<(string Identity, string? Warning)> Resolved = new(() => + ResolveProcessIdentityWithDiagnostics( + AppContext.BaseDirectory, + typeof(FrameworkBuildIdentity).Assembly)); + /// /// Reads the stamped value off a LOADED assembly, or null when the /// assembly carries none. (For reading the same stamp off an assembly FILE without loading it, @@ -217,15 +256,16 @@ public static string Resolve(string? stamped, string contentIdentity) => /// /// The full resolution the process identity uses, in order: surface manifest beside the app - /// (s<hash>) → stamped commit identity (g<sha>) → Graph MVID. + /// (s<hash>) → stamped commit identity (g<sha>) → MVID-set identity. /// See — this is its identity-only /// convenience reading. /// /// Where to look for — /// the app base directory in production. - /// The MeshWeaver.Graph assembly (identity anchor). - public static string ResolveProcessIdentity(string baseDirectory, Assembly graphAssembly) - => ResolveProcessIdentityWithDiagnostics(baseDirectory, graphAssembly).Identity; + /// The MeshWeaver.Compiler assembly (identity anchor — carries + /// the commit stamp and roots the by-simple-name MVID resolution). + public static string ResolveProcessIdentity(string baseDirectory, Assembly anchorAssembly) + => ResolveProcessIdentityWithDiagnostics(baseDirectory, anchorAssembly).Identity; /// /// The full resolution chain as one PURE, testable seam, returning the identity plus the @@ -238,9 +278,10 @@ public static string ResolveProcessIdentity(string baseDirectory, Assembly graph /// /// Where to look for — /// the app base directory in production. - /// The MeshWeaver.Graph assembly (identity anchor). + /// The MeshWeaver.Compiler assembly (identity anchor — carries + /// the commit stamp and roots the by-simple-name MVID resolution). public static (string Identity, string? Warning) ResolveProcessIdentityWithDiagnostics( - string baseDirectory, Assembly graphAssembly) + string baseDirectory, Assembly anchorAssembly) { var manifestPath = Path.Combine(baseDirectory, SurfaceManifestFileName); string? warning = null; @@ -250,7 +291,7 @@ public static (string Identity, string? Warning) ResolveProcessIdentityWithDiagn { var pairs = ParseSurfaceManifest(File.ReadAllText(manifestPath)); if (pairs.Count > 0) - return (ComputeSurfaceIdentity(pairs, name => ImplMvidOf(name, graphAssembly)), null); + return (ComputeSurfaceIdentity(pairs, name => ImplMvidOf(name, anchorAssembly)), null); warning = $"surface manifest at {manifestPath} held no usable pairs — " + "resolved the stamp/MVID fallback identity instead"; @@ -264,19 +305,19 @@ public static (string Identity, string? Warning) ResolveProcessIdentityWithDiagn + "identity instead"; } return (Resolve( - StampedIdentityOf(graphAssembly), - graphAssembly.ManifestModule.ModuleVersionId.ToString("N")), warning); + StampedIdentityOf(anchorAssembly), + anchorAssembly.ManifestModule.ModuleVersionId.ToString("N")), warning); } /// - /// An assembly's implementation MVID by simple name: the loaded assembly when present (Graph — - /// the only current member — is always loaded, since this - /// code IS Graph), else a metadata-only read of the DLL beside the entry assembly, else null. + /// An assembly's implementation MVID by simple name: the anchor itself, else the loaded + /// assembly when present, else a metadata-only read of the DLL beside the entry assembly, + /// else null. /// - private static string? ImplMvidOf(string simpleName, Assembly graphAssembly) + private static string? ImplMvidOf(string simpleName, Assembly anchorAssembly) { - if (string.Equals(graphAssembly.GetName().Name, simpleName, StringComparison.Ordinal)) - return graphAssembly.ManifestModule.ModuleVersionId.ToString("N"); + if (string.Equals(anchorAssembly.GetName().Name, simpleName, StringComparison.Ordinal)) + return anchorAssembly.ManifestModule.ModuleVersionId.ToString("N"); var loaded = AppDomain.CurrentDomain.GetAssemblies() .FirstOrDefault(a => string.Equals(a.GetName().Name, simpleName, StringComparison.Ordinal) && !a.IsDynamic); diff --git a/src/MeshWeaver.Compiler/GeneratorPipeline.cs b/src/MeshWeaver.Compiler/GeneratorPipeline.cs new file mode 100644 index 0000000000..8ae4760a1a --- /dev/null +++ b/src/MeshWeaver.Compiler/GeneratorPipeline.cs @@ -0,0 +1,91 @@ +using MeshWeaver.Graph.Configuration; +using MeshWeaver.NuGet; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.Compiler; + +/// +/// Source-generator shaping for dynamic NodeType compiles: which generator assemblies run, and +/// which legacy #r "nuget:" directives are stripped before NuGet resolution. Both change +/// what Roslyn emits without any API change, so they live inside the toolchain identity boundary +/// (#1707). +/// +public static class GeneratorPipeline +{ + /// + /// Paths of source-generator assemblies fed to EVERY dynamic-node compilation, resolved once + /// per process. 🚨 The platform does NOT ship any — business rules / scopes moved OUT of the + /// platform (see the comment blocks in MeshWeaver.Graph.csproj and + /// Memex.Portal.Distributed.csproj): the scope runtime is a shared-source library node + /// in the MeshWeaver.Plugins/BusinessRules plugin, pulled into a consumer's compilation + /// via shared=@BusinessRules/Scope/Source, and the plugin carries the + /// ScopeCodeGenerator SOURCE for the generator-injection seam. So on a deployed image + /// this list is EMPTY. It only fills when a MeshWeaver.BusinessRules.Generator.dll is + /// physically present next to the app (a dev/self-host tree that placed one there) — kept as + /// graceful degradation for such trees, and because the legacy-#r strip keys off it. + /// + internal static readonly IReadOnlyList BuiltInGeneratorPaths = ResolveBuiltInGenerators(); + + /// + /// NuGet package id (and, with .dll, assembly file name) of the BusinessRules scope + /// source generator. When a copy is present in the app base ( + /// non-empty), a legacy #r "nuget:MeshWeaver.BusinessRules.Generator" is redundant and is + /// filtered out of BOTH the generator list (avoid a double-run → CS0101, see + /// ) and the NuGet resolve set (avoid a dead round-trip). + /// + internal const string BuiltInScopeGeneratorId = "MeshWeaver.BusinessRules.Generator"; + + private static IReadOnlyList ResolveBuiltInGenerators() + { + var path = Path.Combine(AppContext.BaseDirectory, BuiltInScopeGeneratorId + ".dll"); + return File.Exists(path) ? [path] : []; + } + + /// + /// Removes a legacy #r "nuget:MeshWeaver.BusinessRules.Generator" from the NuGet resolve + /// set when the generator ships built-in (). The generator is + /// now part of the platform, so that #r is redundant — and RESOLVING it hard-fails on a + /// deployed image: after BakeMeshLocalFeed was removed (#395) the mesh-local feed + /// (dist/packages) is gone, so NuGet throws + /// "The local source '/app/dist/packages' doesn't exist" and breaks every deployed scope + /// node still carrying the legacy #r (the prod BalanceSheet failure). Behaviour is + /// unchanged: the built-in generator still emits the IScope<,> implementations, and + /// already de-dups the generator itself (CS0101). When the + /// built-in is somehow absent the #r is kept so the generator can still resolve via NuGet. + /// Other package references are never touched. + /// + internal static void StripBuiltInScopeGeneratorRef(List refs, bool builtInPresent) + { + if (builtInPresent) + refs.RemoveAll(r => string.Equals( + r.Id, BuiltInScopeGeneratorId, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Runs the built-in scope generator plus any OTHER generator a node #r'd over + /// . A node's own + /// #r "nuget:MeshWeaver.BusinessRules.Generator" is filtered OUT — otherwise the same + /// generator loads from two paths (built-in + baked) and runs twice → duplicate + /// IScope<,> implementations → CS0101. Legacy nodes that still carry that + /// #r keep compiling (built-in supersedes it, and the strip above keeps it out of the + /// NuGet resolve set so it never round-trips). + /// + internal static CSharpCompilation RunSourceGenerators( + CSharpCompilation compilation, IReadOnlyList generatorAssemblyPaths, ILogger logger, CancellationToken ct) + { + IReadOnlyList allPaths = BuiltInGeneratorPaths.Count == 0 + ? generatorAssemblyPaths + : [.. BuiltInGeneratorPaths, + .. generatorAssemblyPaths.Where(p => !string.Equals( + Path.GetFileName(p), BuiltInScopeGeneratorId + ".dll", StringComparison.OrdinalIgnoreCase))]; + if (allPaths.Count == 0) + return compilation; + var generators = SourceGeneratorLoader.Discover(allPaths, logger); + if (generators.IsDefaultOrEmpty) + return compilation; + var driver = CSharpGeneratorDriver.Create(generators); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var updated, out _, ct); + return (CSharpCompilation)updated; + } +} diff --git a/src/MeshWeaver.Compiler/MeshWeaver.Compiler.csproj b/src/MeshWeaver.Compiler/MeshWeaver.Compiler.csproj new file mode 100644 index 0000000000..7652ef7a29 --- /dev/null +++ b/src/MeshWeaver.Compiler/MeshWeaver.Compiler.csproj @@ -0,0 +1,36 @@ + + + + The MeshWeaver NodeType compile toolchain: skeleton generation, source-query resolution, @@-include and #r shaping, Roslyn compilation/emit, and the framework build identity. Everything that shapes a dynamic NodeType compile's input lives here, so the full-MVID framework-identity rule pins THIS small assembly instead of MeshWeaver.Graph (issue #1707). + MeshWeaver.Compiler + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MeshWeaver.Compiler/NodeCompileShaping.cs b/src/MeshWeaver.Compiler/NodeCompileShaping.cs new file mode 100644 index 0000000000..c775bcd145 --- /dev/null +++ b/src/MeshWeaver.Compiler/NodeCompileShaping.cs @@ -0,0 +1,284 @@ +using System.Collections.Immutable; +using System.Reactive.Linq; +using System.Text.RegularExpressions; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.Compiler; + +/// +/// The pure source-shaping half of a dynamic NodeType compile — everything between "the mesh +/// answered these Code nodes" and "this is the text handed to Roslyn". Extracted from +/// MeshNodeCompilationService (#1707) so that every rule that shapes the GENERATED INPUT +/// of a compile lives inside the full-MVID identity boundary: the include pattern and rebasing, +/// the query-result fold, the snapshot race semantics, the executable filter, the dedup, and the +/// join order. The mesh-actor half (which hub reads a node, under which identity, with which +/// timeout) stays in MeshWeaver.Graph and is threaded in as delegates. +/// +public static class NodeCompileShaping +{ + /// + /// Regex matching @@path references in code files. The capture must LOOK LIKE A NODE PATH — + /// it starts with a word character and continues with path characters only — because this + /// pattern runs over RAW C# SOURCE, where @@ also appears in prose: XML doc comments + /// citing the markdown embed idiom (@@("area/Search")) and string literals in tests + /// asserting exactly that idiom. + /// + /// 🚨 The permissive predecessor (@@([^\s#\]]+), shared with the AI + /// InlineReferenceResolver, which reads PROSE where that is correct) scraped those fragments + /// as include paths — ("area/CoverCta")</c>, ("Install/area/CoverCta")"), — + /// and each garbage match cost a SERIAL 15s GetMeshNode timeout on the resolving hub. On memex + /// 2026-07-29 that stall starved the Store root's activation reads (its subtree holds ~44 Code + /// nodes): SubscribeRequest hit its 60s ceiling and the page died with "activation faulted". + /// A scanner over source code must reject anything a node path cannot begin with — quotes, + /// parentheses, XML markup. + /// + internal static readonly Regex CodeIncludePattern = new(@"@@([\w][\w\-./]*)", RegexOptions.Compiled); + + /// + /// Rebases a mount-relative @@ include path onto the prefix the INCLUDING node lives + /// under, so the same content resolves from whichever mount point it is served. + /// + /// 🚨 Why this exists: include paths are authored mount-relative. A sample tree that sits + /// at the mesh root in the Monolith (FutuRe/GroupAnalysis/Source/…, which is what + /// FutuReAnalysisTest exercises) is served from a PREFIX in a statically-imported + /// partition (MeshWeaver/samples/Graph/Data/FutuRe/…). Resolving the authored path + /// verbatim there finds nothing, and an unresolved include is left VERBATIM in the source — + /// so Roslyn parses the @@ line itself and reports CS9008 / CS8803 / CS0103 on symbol + /// names that are really path segments. On memex-cloud 2026-08-12 that parked 15 NodeTypes + /// (FutuRe, Cession, SocialMedia, Northwind/AnalyticsCatalog) and cost a serial + /// 15s read per unresolved include on the ACTIVATION path — the "waiting for code" stall. + /// + /// The anchor is the DEEPEST occurrence of the include's first segment in the including + /// node's path — the most local reading. An already-absolute include anchors at index 0 and is + /// returned unchanged, so a root mount keeps behaving exactly as before. + /// + internal static string AnchorIncludePath(string includePath, string? anchorPath) + { + if (string.IsNullOrEmpty(anchorPath) || string.IsNullOrEmpty(includePath)) + return includePath; + + var slash = includePath.IndexOf('/'); + var first = slash < 0 ? includePath : includePath[..slash]; + var segments = anchorPath.Split('/'); + for (var i = segments.Length - 1; i > 0; i--) + { + if (string.Equals(segments[i], first, StringComparison.Ordinal)) + return string.Join('/', segments, 0, i) + '/' + includePath; + } + return includePath; + } + + /// + /// Resolves every @@ include in recursively, left-to-right, + /// substituting the included node's code (or leaving the directive VERBATIM when the read + /// finds nothing — the caller's compiler then reports on the @@ line itself). + /// The mesh read is the caller's business: receives the + /// ANCHORED path plus the authored path as fallback (null when identical) and reports the + /// node together with the path that actually produced it, so nested includes anchor there — + /// MeshWeaver.Graph supplies the System-impersonated, bounded read. + /// + internal static IObservable ResolveCodeIncludes( + string code, + HashSet resolved, + string? anchorPath, + Func> readInclude, + ILogger logger) + { + if (string.IsNullOrWhiteSpace(code) || !code.Contains("@@")) + return Observable.Return(code); + + var matches = CodeIncludePattern.Matches(code); + if (matches.Count == 0) + return Observable.Return(code); + + // For each @@ match, fetch the referenced node via the caller's reader (NEVER await — + // that's a 100% deadlock). Each result feeds the recursive resolution; the final + // substituted string is built up in left-to-right order by serially aggregating the + // per-match observables. + IObservable chain = Observable.Return(code); + foreach (Match match in matches) + { + var authored = match.Groups[1].Value; + var anchored = AnchorIncludePath(authored, anchorPath); + var matchValue = match.Value; + chain = chain.SelectMany(current => + { + if (!resolved.Add(anchored)) + return Observable.Return(current.Replace(matchValue, string.Empty)); + + // The anchored candidate is tried FIRST and the authored path only as a fallback: + // on a root mount the two are identical (one read, unchanged behaviour), and the + // second read is reached only where today's single read already failed. + return readInclude(anchored, anchored == authored ? null : authored) + .SelectMany(hit => + { + if (hit.Node?.Content is CodeConfiguration cf + && !string.IsNullOrWhiteSpace(cf.Code)) + { + logger.LogDebug("Resolved code include @@{Path}", hit.Path); + // Nested includes anchor from where THIS one was found, so a chain of + // mount-relative includes stays inside the same mount. + return ResolveCodeIncludes( + cf.Code, resolved, hit.Path, readInclude, logger) + .Select(resolvedInner => current.Replace(matchValue, resolvedInner)); + } + logger.LogWarning( + "Could not resolve code include @@{Path} referenced from {Anchor} " + + "— it stays VERBATIM in the source, so the compiler will report " + + "errors on the @@ line itself", + authored, anchorPath ?? "(no anchor)"); + return Observable.Return(current); + }); + }); + } + + return chain; + } + + /// + /// Fold one into the accumulating path→node map — pure, so + /// the chunk-accumulation contract is unit-testable. Initial/Reset/Added/Updated set; Removed + /// deletes. Keyed by path, so a re-delivered chunk is idempotent rather than a duplicate. + /// + internal static ImmutableDictionary ApplyQueryChange( + ImmutableDictionary acc, QueryResultChange change) + { + if (change?.Items is not { Count: > 0 } items) + return acc; + foreach (var node in items) + { + if (string.IsNullOrEmpty(node?.Path)) + continue; + acc = change.ChangeType == QueryChangeType.Removed + ? acc.Remove(node.Path) + : acc.SetItem(node.Path, node); + } + return acc; + } + + /// + /// The snapshot race between the authoritative direct mesh read ( — + /// emits at most one NON-EMPTY source set, stays silent and completes when it finds nothing) and + /// the cached synced query's first emission (). A pure combinator + /// so the race's correctness semantics are deterministically unit-testable + /// (CodeEditRecompileTest.SourceSnapshot_*). + /// + /// 🚨 An EMPTY cached answer must never WIN the race (issue #612, CI run 30004790036 + /// "sub-case b"). The cached synced query replays its latest set SYNCHRONOUSLY on subscribe, + /// while the probe cannot answer before its chunk quiet window — so under the old + /// Merge(...).FirstAsync() shape a cached query that had latched EMPTY (a missed + /// source-create update under load — the stale-synced-query class) ALWAYS beat the probe, the + /// compile consumed ZERO sources, the configuration lambda's CS0103 parked the type, and every + /// retry — including the explicit un-parking RequestedReleaseAt re-trigger — re-failed + /// identically: a permanent wedge at Status=Error with no release. The probe leg already + /// refuses to emit empty for exactly this reason ("compiling the type against NOTHING [is] + /// strictly worse than the stall"); this applies the same rule to the cached leg. + /// + /// Semantics: the first ESTABLISHED NON-EMPTY answer from either side wins immediately (a + /// healthy cached query still settles the snapshot with zero probe latency — the #690 regression + /// guard). EMPTY settles only by CONSENSUS: both legs completed without producing a source — + /// then a source-less, configuration-only NodeType still compiles. A cached leg that never + /// emits at all leaves the race to the probe / the caller's outer Timeout, unchanged. + /// + /// 🚨 An UNESTABLISHED report (a leg whose queries errored — issue #1218) never WINS the + /// race, and never loses it silently either. It is held back until the merged stream completes, + /// so a probe with one dead query cannot veto a perfectly healthy cached answer; but if no leg + /// ever produces a real source set, the unestablished report is what settles the race — and the + /// caller then refuses to compile rather than handing Roslyn a set it knows to be short. The + /// old shape simply dropped the failed leg's contribution (.Catch(_ => empty)) and let + /// the PARTIAL remainder win, which is precisely how a starved cross-silo read became a + /// phantom CS0246 on 14 of 56 types during a memex-cloud rollout. + /// + internal static IObservable RaceSourceSnapshot( + IObservable directProbe, + IObservable cachedFirst) + => directProbe + .Merge(cachedFirst) + .Publish(shared => Observable.Merge( + // A real set — wins the instant it arrives, from either leg. + shared.Where(static s => s.IsEstablished && s.Sources.Count > 0), + // "I could not read the sources" — consulted only once BOTH legs have had + // their say (TakeLast emits on completion), so it can never pre-empt a + // healthy answer that is merely slower. + shared.Where(static s => !s.IsEstablished).TakeLast(1))) + .Take(1) + // Both legs completed, neither found anything, neither reported a failure: the + // sources genuinely do not exist. That is a CONTENT fact and the compile proceeds. + .DefaultIfEmpty(SourceSnapshot.Empty); + + /// + /// The emit path's source-set fold: dedup by path (case-insensitive), keep only + /// non-executable Code nodes with non-blank code, in the order the snapshot delivered them. + /// Executable scripts run via the kernel (ExecuteScriptRequest), never folded into the parent + /// NodeType's Roslyn unit — top-level statements would collide with class declarations from + /// Source/ siblings; Test/ commonly mixes both shapes, and this filter lets them coexist. + /// Returns the matched configurations together with the matched paths (activity-log + /// material for the caller). + /// + internal static (List Sources, List MatchedPaths) + CollectCompileSources(IEnumerable matches, string nodePath, ILogger logger) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var acc = new List(); + var matchedPaths = new List(); + foreach (var n in matches) + { + if (string.IsNullOrEmpty(n.Path) || !seen.Add(n.Path)) + continue; + if (n.Content is CodeConfiguration cf + && !string.IsNullOrWhiteSpace(cf.Code)) + { + if (cf.IsExecutable) + { + logger.LogDebug( + "Source discovery for {NodePath}: skipping executable Code {CodePath} — runs via kernel only", + nodePath, n.Path); + continue; + } + acc.Add(cf); + matchedPaths.Add(n.Path); + } + } + return (acc, matchedPaths); + } + + /// + /// The LSP path's variant of : the same dedup + + /// executable filter, but retaining paths and versions alongside configurations so language + /// services can address each file. + /// + internal static List<(string Path, CodeConfiguration Config, long LastModifiedTicks)> + CollectSourcePairs(IEnumerable matches) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var pairs = new List<(string, CodeConfiguration, long)>(); + foreach (var n in matches) + { + if (string.IsNullOrEmpty(n.Path) || !seen.Add(n.Path)) continue; + if (n.Content is CodeConfiguration cf + && !string.IsNullOrWhiteSpace(cf.Code) + && !cf.IsExecutable) + { + pairs.Add((n.Path, cf, n.LastModified.UtcTicks)); + } + } + return pairs; + } + + /// + /// Combines the discovered source files into the one compile unit the emit path hands to the + /// skeleton generator: none → null, one → as-is, several → joined with "\n\n" in + /// snapshot order. The join order and separator are part of the emitted bytes, which is why + /// this lives inside the identity boundary. + /// + internal static CodeConfiguration? CombineSources(IReadOnlyList codeFiles) + => codeFiles.Count switch + { + 0 => null, + 1 => codeFiles[0], + _ => new CodeConfiguration { Code = string.Join("\n\n", codeFiles.Select(cf => cf.Code)) } + }; +} diff --git a/src/MeshWeaver.Compiler/README.md b/src/MeshWeaver.Compiler/README.md new file mode 100644 index 0000000000..7a3e732158 --- /dev/null +++ b/src/MeshWeaver.Compiler/README.md @@ -0,0 +1,28 @@ +# MeshWeaver.Compiler + +The MeshWeaver NodeType compile toolchain, factored out of `MeshWeaver.Graph` (issue #1707) so +the framework build identity's full-MVID rule pins a small, low-churn assembly. + +Everything that shapes the GENERATED INPUT of a dynamic NodeType compile lives here: + +- **Skeleton generation** — `DynamicMeshNodeAttributeGenerator` emits the + `MeshNodeProviderAttribute` subclass wrapping the NodeType's `configuration` lambda. +- **Source-query resolution** — `CodeQueryResolver` expands a NodeType's `Sources`/`Tests` + queries; `NodeCompileShaping` folds query results, filters executables, resolves `@@` includes + (through a caller-supplied node reader), and combines sources into the compile unit. +- **Reference assembly** — the process-wide TPA baseline plus installed-module composition + (`CompileReferences`). +- **Generator pipeline** — `#r "nuget:"` shaping and Roslyn source-generator discovery/execution. +- **Emit** — staged, verified, atomic disk emit (`EmitPipeline`), the emit canary, and the + in-memory emit. +- **Identity** — `FrameworkBuildIdentity` / `FrameworkIdentity`: the one framework build identity + every compiled NodeType release is pinned to, and the assembly-store filename tag + (`FileSystemAssemblyStore`). + +The mesh-actor half of the pipeline — source discovery against the live mesh, access +impersonation, IoPool scheduling, compile status write-backs — stays in `MeshWeaver.Graph` +(`MeshNodeCompilationService`, `NodeTypeCompilationHelpers`) and orchestrates this toolchain. + +Types moved from `MeshWeaver.Graph` keep their original `MeshWeaver.Graph.Configuration` +namespace and are type-forwarded from `MeshWeaver.Graph`, so modules compiled against earlier +releases keep binding. New toolchain code uses the `MeshWeaver.Compiler` namespace. diff --git a/src/MeshWeaver.Graph/Configuration/SourceGeneratorLoader.cs b/src/MeshWeaver.Compiler/SourceGeneratorLoader.cs similarity index 100% rename from src/MeshWeaver.Graph/Configuration/SourceGeneratorLoader.cs rename to src/MeshWeaver.Compiler/SourceGeneratorLoader.cs diff --git a/src/MeshWeaver.Graph/Configuration/SourceSnapshot.cs b/src/MeshWeaver.Compiler/SourceSnapshot.cs similarity index 98% rename from src/MeshWeaver.Graph/Configuration/SourceSnapshot.cs rename to src/MeshWeaver.Compiler/SourceSnapshot.cs index 00e8ac4f8e..e020287c06 100644 --- a/src/MeshWeaver.Graph/Configuration/SourceSnapshot.cs +++ b/src/MeshWeaver.Compiler/SourceSnapshot.cs @@ -79,7 +79,7 @@ public static SourceSnapshot Unavailable(string reason, IReadOnlyList? /// /// The compile refused to run because its SOURCE SET COULD NOT BE ESTABLISHED — a discovery /// query errored, or never answered within -/// . +/// CompilationCacheOptions.SourceSnapshotTimeout (MeshWeaver.Graph). /// /// 🚨 Deliberately its own type so the terminal write-back can record /// ("the compile state could not be diff --git a/src/MeshWeaver.Graph/Configuration/SpeculativeCompilation.cs b/src/MeshWeaver.Compiler/SpeculativeCompilation.cs similarity index 98% rename from src/MeshWeaver.Graph/Configuration/SpeculativeCompilation.cs rename to src/MeshWeaver.Compiler/SpeculativeCompilation.cs index 8059237de7..9d511cde5a 100644 --- a/src/MeshWeaver.Graph/Configuration/SpeculativeCompilation.cs +++ b/src/MeshWeaver.Compiler/SpeculativeCompilation.cs @@ -28,7 +28,7 @@ namespace MeshWeaver.Graph.Configuration; /// internal sealed class SpeculativeCompilation(INuGetAssemblyResolver nugetResolver) { - private const string SkeletonDocumentPath = "__skeleton__.cs"; + private const string SkeletonDocumentPath = Compiler.CompileDiagnostics.SkeletonDiagnosticsPath; public async Task> GetDiagnosticsAsync( CompilationInputs inputs, diff --git a/src/MeshWeaver.Documentation/Data/Architecture/BuildCoordination.md b/src/MeshWeaver.Documentation/Data/Architecture/BuildCoordination.md index b33b055e80..c3881d8b57 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/BuildCoordination.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/BuildCoordination.md @@ -41,7 +41,8 @@ Admin/Build/{chunkName}/_Activity ← the chunk's execution activity (standard A ``` - The **root** carries the build's identity — the framework fingerprint it targets - (`MeshWeaver.Graph`'s MVID, the same value `HasUsableBuild` compares against), the + (the resolved framework build identity, `FrameworkBuildIdentity.FrameworkVersion` — + the same value `HasUsableBuild` compares against), the commit set of the synced sources, the chunk plan, and the aggregate `Status`. - A **chunk** is a named unit of work defined by an array of mesh queries — as simple as a list of paths, or a module such as `namespace:MyPlugin scope:subtree nodeType:Code`. @@ -233,8 +234,8 @@ hub), the assembly store under `/data` (content-addressed writes), and the sourc Every portal silo — old and new — **subscribes** to the build root and holds its health probe accordingly: -- On startup a silo computes its own framework fingerprint (its Graph MVID — a local, - mesh-independent computation) and subscribes to `Admin/Build`. +- On startup a silo computes its own framework fingerprint (its resolved framework + identity — a local, mesh-independent computation) and subscribes to `Admin/Build`. - The readiness probe reports **not ready** until the observed root says `Status = Ready && FrameworkVersion == `. - Old pods keep serving through a rollout untouched: the GO for *their* fingerprint was diff --git a/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md b/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md index 4e9c25a4a5..acdd070c3f 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md @@ -51,23 +51,28 @@ hosts that matter here — the bake host and the portals, which both ship a `meshweaver-surface.manifest` — that identity is the **API-surface hash** `s`: per compile reference, the SHA-256 of its *reference assembly* (the compiler's own definition of the API surface — byte-stable under body-only and private-member edits, changed by any surface change), -hashed over the canonical content-surface set, with the generator-bearing exception -(`MeshWeaver.Graph`, whose code shapes the *generated input* of every NodeType compile) -contributing its full implementation MVID. +hashed over the canonical content-surface set, with the generated-input-shaping exceptions +contributing their full implementation MVID: `MeshWeaver.Compiler` (THE compile toolchain since +#1707 — skeleton generation, source-query resolution, `@@`-include shaping, aggregation, options, +generator execution, emit) and `MeshWeaver.NuGet` (the `#r "nuget:"` parser/resolver — what Roslyn +is fed and which assemblies a directive adds). Before #1707 the toolchain lived inside +`MeshWeaver.Graph` and pinned ALL of Graph — the highest-churn assembly — so nearly every merge +rebaked the world; the extraction is what makes "rebuild only when we need to" hold in practice. Three consequences: - **a bundle is adoptable across CI runs, images, and internal-only merges** — the bake for commit X seeds at boot on the image of commit Y whenever nothing in the content-facing surface changed between them ("rebuild only when we need to"); -- **a breaking surface change (or any Graph change) mints a new identity** — every cached and +- **a breaking surface change (or any toolchain change) mints a new identity** — every cached and published build for the old surface is stale, and the next Build-and-Test run bakes fresh; - **a declined bundle costs exactly what today costs — a compile.** Shipping bundles is strictly safe; declines are logged with both identities. Manifest-less CI processes (test hosts) fall back to the commit identity `g` stamped by -`Directory.Build.props`; local builds fall back to the Graph MVID. The commit stamp doubles as -provenance everywhere. +`Directory.Build.props`; local manifest-less builds fall back to the identity anchor's MVID +(`MeshWeaver.Compiler.dll` — single-file attributable, which is what lets a packer read it without +loading anything). The commit stamp doubles as provenance everywhere. ## The image: `prebuilt/` beside the app diff --git a/src/MeshWeaver.Documentation/Data/Architecture/Modules.md b/src/MeshWeaver.Documentation/Data/Architecture/Modules.md index 148aa34285..29b6ea5875 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/Modules.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/Modules.md @@ -166,7 +166,8 @@ transport end to end — there is deliberately no second distribution channel: card, price, install funnel, pre-install eligibility all unchanged. 2. **Build** — `MeshWeaver.Plugin.Build`'s `module-pack` mode packs a built module's closure into a bundle recording the `minMeshVersion` floor (`--min-mesh-version`) and, as diagnostics, the - MVID of the `MeshWeaver.Graph.dll` in the build output. It is a plain dotnet invocation over + MVID of the identity anchor (`MeshWeaver.Compiler.dll`, #1707) in the build output. It is a + plain dotnet invocation over an output folder, so ANY node repo's CI can drive it — SocialMedia builds its own module bundle the same way the platform repo does — and because the gate is the floor, ONE bundle serves every compatible platform build: nothing is rebundled per CI build. The closure is an diff --git a/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md b/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md index 7dbcd00812..50f0f0047e 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md @@ -23,6 +23,17 @@ recompiled. > For compiling the same source **outside** the portal — in CI, so the bytes can be shipped rather > than recomputed — see [Plugin Packaging](/Doc/Architecture/PluginPackaging). +**Where the code lives (since #1707):** everything that shapes a compile's *generated input* — +the skeleton generator (`DynamicMeshNodeAttributeGenerator`), source-query resolution +(`CodeQueryResolver`), `@@`-include shaping, source aggregation/filter/join order, the reference +set, parse/compilation options, source-generator execution, and the emit itself — lives in the +dedicated **`MeshWeaver.Compiler`** assembly, whose full MVID pins the framework build identity. +The mesh-actor half — source discovery against the live mesh, access impersonation, scheduling, +compile-status write-backs — stays in `MeshWeaver.Graph` (`MeshNodeCompilationService`, +`NodeTypeCompilationHelpers`) and orchestrates that toolchain. One pipeline serves every path: +the portal's on-demand compile, the batch bake, and the CI bake host all call the same +`MeshWeaver.Compiler` code. + @@ -341,21 +352,35 @@ compile freely, then unpin (or re-point) when you're ready to adopt the new buil The kickoff does **not** trust a bare `CompilationStatus == Ok`. That value is persisted into the NodeType MeshNode's JSON, so a stale `Ok` can easily outlive -the assembly that produced it. `NodeTypeCompilationHelpers.HasUsableBuild` is the -gate: a compile is **skipped only when all three conditions hold** — - -1. `CompilationStatus == Ok` -2. `MeshNode.AssemblyLocation` points at a DLL that **still exists on disk** -3. `CompiledFrameworkVersion` equals the **current** framework version (`NodeTypeCompilationHelpers.FrameworkVersion`) - -Anything else triggers a recompile. This makes a cold hub start **self-healing** against a range of real-world conditions: - -| Situation | Why the bare `Ok` lies | Caught by | +the assembly that produced it — and, conversely, a later failed compile can leave +`Status=Error` behind a perfectly usable earlier build. +`NodeTypeCompilationHelpers.HasUsableBuild` is the gate: a compile is **skipped +only when all of these hold** — + +1. `LatestAssemblyCollection` is populated (only a *successful* compile write-back sets it) +2. `LatestAssemblyPath` is populated +3. `CompiledFrameworkVersion` equals the **current** framework identity + (`FrameworkBuildIdentity.FrameworkVersion` in `MeshWeaver.Compiler`) +4. `CompiledModulesHash`, when both it and the mesh's live + `InstalledModulesFingerprint` are non-null, matches (a module-only update must + invalidate builds that could bind the replaced module; null stamp or null + caller keep the framework-only behavior) + +`CompilationStatus` is deliberately **not** a condition — the assembly fields +self-heal across a stray `Status=Error`. The check is also **metadata-only**: no +store probe, no `File.Exists` — the kickoff prefers a redundant compile over a +blocking store round-trip, and a store that has lost the bytes is caught at +activation (`TryGetAssemblyPath` misses → `TriggerRecompileAndRetry`); the bake +probe's `NodeTypeBakeStatus.Classify` has the `BytesMissing` state for exactly +that gap. + +Anything else triggers a recompile. This makes a cold hub start **self-healing**: + +| Situation | Why the bare record lies | Caught by | |---|---|---| -| Seed-data pollution — a prior run stamped `Ok` into sample/seed JSON | The DLL was a per-process temp artefact | Rule 2 | -| Cleaned-up `.mesh-cache` / temp DLL | File deleted since | Rule 2 | -| Cross-machine checkout / fresh CI agent | The DLL never existed here | Rule 2 | -| **MeshWeaver redeployed at a new version** | The cached DLL bound against the *old* framework assemblies (ABI-stale) | Rule 3 | +| Cleaned-up cache / lost store bytes | The record still points at them | Activation store probe (`BytesMissing`) | +| **MeshWeaver redeployed with a breaking change** | The cached DLL bound against the *old* framework surface (ABI-stale) | Rule 3 | +| Module updated | The cached DLL may bind the replaced module's old ABI | Rule 4 | ### Framework-version freezing @@ -374,9 +399,12 @@ resolved once per process (`FrameworkBuildIdentity`, `s`: per compile reference, the SHA-256 of its *reference assembly* (the compiler's own definition of the API surface — byte-stable under body-only and private-member edits, changed by any surface change), hashed - over the canonical content-surface set, with `MeshWeaver.Graph` contributing - its full implementation MVID because its code shapes the *generated input* - of every NodeType compile. "Rebuild only when we need to": an internal-only + over the canonical content-surface set, with the generated-input-shaping + exceptions contributing their full implementation MVID: `MeshWeaver.Compiler` + (THE compile toolchain since #1707 — skeleton generation, source-query + resolution, include shaping, aggregation, options, generator execution, emit) + and `MeshWeaver.NuGet` (the `#r "nuget:"` parser/resolver). + "Rebuild only when we need to": an internal-only framework release keeps the identity, so every cached and CI-published build stays valid; a breaking surface change mints a new one, and the CI bake for the new surface seeds at boot instead of recompiling per pod. @@ -385,10 +413,14 @@ resolved once per process (`FrameworkBuildIdentity`, `Directory.Build.props` (CI compile inputs are commit-deterministic — no run number or timestamp reaches any compiled attribute). Kept everywhere as logged PROVENANCE. -- **Local builds** — the `MeshWeaver.Graph` assembly's **MVID** (a content hash - of the compiled module; no stamp is present). Content-exact for a dirty - working tree — stable across rebuilds that don't change Graph's bytes, - changed whenever they do. +- **Manifest-less local builds** — the identity anchor's **MVID** + (`MeshWeaver.Compiler.dll`; a content hash of the compiled module, no stamp + present). Content-exact for a dirty working tree — stable across rebuilds + that don't change the toolchain's bytes, changed whenever they do. Note that + every host compiling against a *persistent* assembly store (the portals, the + CI bake host, mw-plugin-test) ships a surface manifest and resolves the + surface identity even locally — the MVID fallback governs test hosts and + ad-hoc tools. On a framework-version mismatch the NodeType recompiles and **mints a new release** for the new framework. The old release is left intact as history so instances still diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md b/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md index 21ca49a14a..9d8c1bf40c 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md @@ -86,10 +86,12 @@ The `PublicRelease` flag picks the channel (think **CI** vs **CD**): `NodeTypeCompilationHelpers.FrameworkVersion` (`FrameworkBuildIdentity`): hosts that ship a `meshweaver-surface.manifest` (portals, the CI bake host) resolve the **API-surface hash** (`s` — stable across internal-only changes, moved by - breaking surface changes and by any `MeshWeaver.Graph` change); manifest-less CI + breaking surface changes and by any change to the toolchain full-MVID exceptions, + `MeshWeaver.Compiler` / `MeshWeaver.NuGet`, #1707); manifest-less CI processes fall back to the **stamped commit identity** (`g`, `AssemblyMetadata("MeshWeaverFrameworkIdentity")`, kept as provenance everywhere); - local builds fall back to the **Graph MVID**. + manifest-less local builds fall back to the **identity anchor's MVID** + (`MeshWeaver.Compiler.dll`). - **`-p:Version=…`** still overrides everything (escape hatch) — and it is what the tag-driven release workflows actually pass. diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md new file mode 100644 index 0000000000..3a37f4c6a5 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md @@ -0,0 +1,25 @@ +--- +Name: The compiler is its own assembly +Category: Feature +Description: The NodeType compile toolchain moved out of the graph into MeshWeaver.Compiler — the framework identity now pins a small, low-churn assembly, so routine platform changes stop invalidating every compiled type. +Icon: Sparkle +Order: -20260817 +--- + +# The compiler is its own assembly + +Everything that shapes what a dynamic NodeType compile is fed — the generated provider skeleton, +which source files a type's queries select, `@@`-include resolution, the reference set, +compilation options, source-generator execution, and the emit itself — now lives in a dedicated +`MeshWeaver.Compiler` assembly instead of inside the graph. + +Why it matters: a compiled type stays valid exactly as long as nothing it was built from changed. +The identity that decides this used to pin the whole graph assembly — the busiest code in the +platform — so nearly any platform change forced every compiled type to rebuild. It now pins the +small toolchain assembly (plus the `#r` directive resolver, whose effect on compiles was +previously invisible to the identity), so rebuilds happen when the toolchain or a public surface +actually changes — and only then. + +One pipeline, every path: the portal compiling an edited type on demand, the startup batch bake, +and the CI bake that ships prebuilt assemblies all run the same toolchain code, so what CI built +and what the portal would build can no longer drift. diff --git a/src/MeshWeaver.Graph/Configuration/CodeNodeType.cs b/src/MeshWeaver.Graph/Configuration/CodeNodeType.cs index 4310a3a50c..416c9e9c57 100644 --- a/src/MeshWeaver.Graph/Configuration/CodeNodeType.cs +++ b/src/MeshWeaver.Graph/Configuration/CodeNodeType.cs @@ -16,9 +16,11 @@ namespace MeshWeaver.Graph.Configuration; public static class CodeNodeType { /// - /// The NodeType value used to identify code nodes. + /// The NodeType value used to identify code nodes. Aliases + /// — the compile toolchain keys on the + /// same convention (#1707), and a const alias cannot drift. /// - public const string NodeType = "Code"; + public const string NodeType = Compiler.CodeConventions.CodeNodeType; /// /// Registers the built-in "Code" MeshNode on the mesh builder. @@ -35,14 +37,14 @@ public static TBuilder AddCodeType(this TBuilder builder) where TBuild /// {NodeTypePath}/Source/ alongside (not inside) their parent NodeType. /// This is a content folder, not a satellite namespace. /// - public const string SourceSubNamespace = "Source"; + public const string SourceSubNamespace = Compiler.CodeConventions.SourceSubNamespace; /// /// The sub-namespace for test code files. Tests live under /// {NodeTypePath}/Test/ alongside (not inside) their parent NodeType. /// This is a content folder, not a satellite namespace. /// - public const string TestSubNamespace = "Test"; + public const string TestSubNamespace = Compiler.CodeConventions.TestSubNamespace; /// /// Creates a MeshNode definition for the Code node type. diff --git a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs index b2197da962..7892f162ea 100644 --- a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs +++ b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs @@ -6,6 +6,7 @@ using System.Reactive.Threading.Tasks; using System.Text.Json; using System.Text.RegularExpressions; +using MeshWeaver.Compiler; using MeshWeaver.ContentCollections; using MeshWeaver.Data; using MeshWeaver.Mesh; @@ -169,213 +170,29 @@ private static IObservable BoundLeg( private readonly ConcurrentDictionary>> _inflightCompiles = new(StringComparer.Ordinal); - // Query expansion lives in CodeQueryResolver now so the NodeType Configuration - // side menu can evaluate the *same* queries the compiler uses — the Sources / + // Query expansion lives in CodeQueryResolver (MeshWeaver.Compiler) so the NodeType + // Configuration side menu can evaluate the *same* queries the compiler uses — the Sources / // Tests lists displayed in the UI are guaranteed to match the files compiled. // - // Default Roslyn references are process-wide: TPA list + a few well-known - // additions never change at runtime. Eager static-field init runs once at - // type load and the result is then a plain field read on every compile — - // no Lazy property dispatch, no synchronization, zero per-compile cost. - private static readonly IReadOnlyList _references = GetDefaultReferences(); - - /// - /// The compile reference set: the process-wide TPA baseline () plus - /// THIS mesh's installed module assemblies (Modules:Assemblies → - /// , design #1644). A module published into - /// modules/<name>/ is not in TRUSTED_PLATFORM_ASSEMBLIES, so in-mesh - /// consumers (e.g. a scope class using a map control) would silently lose it without this. - /// Deduped by path — a module still riding the app closure appears in both sets. Lazy per - /// service instance: modules install at boot, before any compile runs. - /// + // The reference-set construction lives in CompileReferences (MeshWeaver.Compiler, #1707) — + // which assemblies Roslyn can see is part of the compile input. This service only resolves + // THIS mesh's installed-module composition from its service tree and threads it in. Lazy per + // service instance: modules install at boot, before any compile runs. private IReadOnlyList References => meshReferences.Value; private readonly Lazy> meshReferences = new(() => - { - var modules = hub.ServiceProvider.GetServices().ToArray(); - if (modules.Length == 0) - return _references; - var seen = new HashSet( - _references.Select(r => r.Display ?? string.Empty), - StringComparer.OrdinalIgnoreCase); - var composed = new List(_references); - foreach (var module in modules) - { - var location = module.Assembly.Location; - if (!string.IsNullOrEmpty(location) && File.Exists(location) && seen.Add(location)) - composed.Add(MetadataReference.CreateFromFile(location)); - } - return composed; - }); + CompileReferences.ComposeWithModules( + hub.ServiceProvider.GetServices().ToArray())); /// - /// Builds the process-wide MetadataReference list — TPA assemblies plus a few - /// well-known additions. Uses - /// (mmap, lazy read) — Roslyn typically reads only a small fraction of each - /// assembly's metadata, so the upfront cost is tiny. An earlier attempt at - /// - /// to avoid finalizer pressure ended up reading the whole DLL into managed - /// memory eagerly — net 10%+ slower in the autocomplete-test CPU profile, - /// since most of those bytes were never touched. The file-handle finalizer - /// pressure those references add is also tiny in practice (the static field - /// holds them for the process lifetime; finalizers only run at shutdown). + /// Resolves every @@ include via the toolchain's shaping + /// (), supplying the mesh-actor half: + /// the System-impersonated, bounded include read (). /// - private static List GetDefaultReferences() - { - var references = new List(); - - var trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; - if (trustedAssemblies != null) - { - foreach (var path in trustedAssemblies.Split(Path.PathSeparator)) - { - if (string.IsNullOrEmpty(path) || !File.Exists(path)) - continue; - try - { - references.Add(MetadataReference.CreateFromFile(path)); - } - catch - { - // Skip assemblies that can't be loaded - } - } - } - - // Three well-known additions in case TPA didn't include them. Dedup - // against TPA-derived set by Display path so we don't double-load. - var seen = new HashSet( - references.Select(r => r.Display ?? string.Empty), - StringComparer.OrdinalIgnoreCase); - foreach (var assembly in new[] - { - typeof(object).Assembly, // System.Runtime - typeof(System.ComponentModel.DataAnnotations.KeyAttribute).Assembly, // DataAnnotations - typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute).Assembly, // System.Text.Json - }) - { - if (!string.IsNullOrEmpty(assembly.Location) - && File.Exists(assembly.Location) - && seen.Add(assembly.Location)) - { - try - { - references.Add(MetadataReference.CreateFromFile(assembly.Location)); - } - catch - { - // Skip if it can't be loaded - } - } - } - - return references; - } - - /// - /// Regex matching @@path references in code files. The capture must LOOK LIKE A NODE PATH — - /// it starts with a word character and continues with path characters only — because this - /// pattern runs over RAW C# SOURCE, where @@ also appears in prose: XML doc comments - /// citing the markdown embed idiom (@@("area/Search")) and string literals in tests - /// asserting exactly that idiom. - /// - /// 🚨 The permissive predecessor (@@([^\s#\]]+), shared with the AI - /// InlineReferenceResolver, which reads PROSE where that is correct) scraped those fragments - /// as include paths — ("area/CoverCta")</c>, ("Install/area/CoverCta")"), — - /// and each garbage match cost a SERIAL 15s GetMeshNode timeout on the resolving hub. On memex - /// 2026-07-29 that stall starved the Store root's activation reads (its subtree holds ~44 Code - /// nodes): SubscribeRequest hit its 60s ceiling and the page died with "activation faulted". - /// A scanner over source code must reject anything a node path cannot begin with — quotes, - /// parentheses, XML markup. - /// - internal static readonly Regex CodeIncludePattern = new(@"@@([\w][\w\-./]*)", RegexOptions.Compiled); - - /// - /// Rebases a mount-relative @@ include path onto the prefix the INCLUDING node lives - /// under, so the same content resolves from whichever mount point it is served. - /// - /// 🚨 Why this exists: include paths are authored mount-relative. A sample tree that sits - /// at the mesh root in the Monolith (FutuRe/GroupAnalysis/Source/…, which is what - /// FutuReAnalysisTest exercises) is served from a PREFIX in a statically-imported - /// partition (MeshWeaver/samples/Graph/Data/FutuRe/…). Resolving the authored path - /// verbatim there finds nothing, and an unresolved include is left VERBATIM in the source — - /// so Roslyn parses the @@ line itself and reports CS9008 / CS8803 / CS0103 on symbol - /// names that are really path segments. On memex-cloud 2026-08-12 that parked 15 NodeTypes - /// (FutuRe, Cession, SocialMedia, Northwind/AnalyticsCatalog) and cost a serial - /// 15s read per unresolved include on the ACTIVATION path — the "waiting for code" stall. - /// - /// The anchor is the DEEPEST occurrence of the include's first segment in the including - /// node's path — the most local reading. An already-absolute include anchors at index 0 and is - /// returned unchanged, so a root mount keeps behaving exactly as before. - /// - internal static string AnchorIncludePath(string includePath, string? anchorPath) - { - if (string.IsNullOrEmpty(anchorPath) || string.IsNullOrEmpty(includePath)) - return includePath; - - var slash = includePath.IndexOf('/'); - var first = slash < 0 ? includePath : includePath[..slash]; - var segments = anchorPath.Split('/'); - for (var i = segments.Length - 1; i > 0; i--) - { - if (string.Equals(segments[i], first, StringComparison.Ordinal)) - return string.Join('/', segments, 0, i) + '/' + includePath; - } - return includePath; - } - private IObservable ResolveCodeIncludes( string code, HashSet resolved, string? anchorPath) - { - if (string.IsNullOrWhiteSpace(code) || !code.Contains("@@")) - return Observable.Return(code); - - var matches = CodeIncludePattern.Matches(code); - if (matches.Count == 0) - return Observable.Return(code); - - // For each @@ match, fetch the referenced node via composed hub.GetMeshNode - // (NEVER await — that's a 100% deadlock). Each result feeds the recursive - // resolution; the final substituted string is built up in left-to-right order - // by serially aggregating the per-match observables. - IObservable chain = Observable.Return(code); - foreach (Match match in matches) - { - var authored = match.Groups[1].Value; - var anchored = AnchorIncludePath(authored, anchorPath); - var matchValue = match.Value; - chain = chain.SelectMany(current => - { - if (!resolved.Add(anchored)) - return Observable.Return(current.Replace(matchValue, string.Empty)); - - // The anchored candidate is tried FIRST and the authored path only as a fallback: - // on a root mount the two are identical (one read, unchanged behaviour), and the - // second read is reached only where today's single read already failed. - return ReadIncludeNode(anchored, anchored == authored ? null : authored) - .SelectMany(hit => - { - if (hit.Node?.Content is CodeConfiguration cf - && !string.IsNullOrWhiteSpace(cf.Code)) - { - logger.LogDebug("Resolved code include @@{Path}", hit.Path); - // Nested includes anchor from where THIS one was found, so a chain of - // mount-relative includes stays inside the same mount. - return ResolveCodeIncludes(cf.Code, resolved, hit.Path) - .Select(resolvedInner => current.Replace(matchValue, resolvedInner)); - } - logger.LogWarning( - "Could not resolve code include @@{Path} referenced from {Anchor} " - + "— it stays VERBATIM in the source, so the compiler will report " - + "errors on the @@ line itself", - authored, anchorPath ?? "(no anchor)"); - return Observable.Return(current); - }); - }); - } - - return chain; - } + => NodeCompileShaping.ResolveCodeIncludes( + code, resolved, anchorPath, ReadIncludeNode, logger); /// /// 🚨 EVERY compile-path node read runs under the well-known System identity — the same rule @@ -794,7 +611,7 @@ private IObservable SnapshotSourcesCore( // nothing stays SILENT and completes, so it can never beat the cached query with an empty // set (compiling against no sources is worse than the stall this exists to dodge). That // filter is also the likeliest reason #682's probe "never fired" — not the delay alone. - return RaceSourceSnapshot( + return NodeCompileShaping.RaceSourceSnapshot( DirectSourceProbe(ntDef, selfPath, TimeSpan.Zero), // 🚨 The cached leg's FAULT is unestablishment, not emptiness. A synced query // that errors used to propagate straight out of the snapshot as a raw exception @@ -818,56 +635,6 @@ private IObservable SnapshotSourcesCore( + $"('{NodeSources.CacheId(selfPath)}') answered"))); } - /// - /// The snapshot race between the authoritative direct mesh read ( — - /// emits at most one NON-EMPTY source set, stays silent and completes when it finds nothing) and - /// the cached synced query's first emission (). Extracted as a pure - /// combinator so the race's correctness semantics are deterministically unit-testable - /// (CodeEditRecompileTest.SourceSnapshot_*). - /// - /// 🚨 An EMPTY cached answer must never WIN the race (issue #612, CI run 30004790036 - /// "sub-case b"). The cached synced query replays its latest set SYNCHRONOUSLY on subscribe, - /// while the probe cannot answer before its chunk quiet window — so under the old - /// Merge(...).FirstAsync() shape a cached query that had latched EMPTY (a missed - /// source-create update under load — the stale-synced-query class) ALWAYS beat the probe, the - /// compile consumed ZERO sources, the configuration lambda's CS0103 parked the type, and every - /// retry — including the explicit un-parking RequestedReleaseAt re-trigger — re-failed - /// identically: a permanent wedge at Status=Error with no release. The probe leg already - /// refuses to emit empty for exactly this reason ("compiling the type against NOTHING [is] - /// strictly worse than the stall"); this applies the same rule to the cached leg. - /// - /// Semantics: the first ESTABLISHED NON-EMPTY answer from either side wins immediately (a - /// healthy cached query still settles the snapshot with zero probe latency — the #690 regression - /// guard). EMPTY settles only by CONSENSUS: both legs completed without producing a source — - /// then a source-less, configuration-only NodeType still compiles. A cached leg that never - /// emits at all leaves the race to the probe / the caller's outer Timeout, unchanged. - /// - /// 🚨 An UNESTABLISHED report (a leg whose queries errored — issue #1218) never WINS the - /// race, and never loses it silently either. It is held back until the merged stream completes, - /// so a probe with one dead query cannot veto a perfectly healthy cached answer; but if no leg - /// ever produces a real source set, the unestablished report is what settles the race — and the - /// caller then refuses to compile rather than handing Roslyn a set it knows to be short. The - /// old shape simply dropped the failed leg's contribution (.Catch(_ => empty)) and let - /// the PARTIAL remainder win, which is precisely how a starved cross-silo read became a - /// phantom CS0246 on 14 of 56 types during a memex-cloud rollout. - /// - internal static IObservable RaceSourceSnapshot( - IObservable directProbe, - IObservable cachedFirst) - => directProbe - .Merge(cachedFirst) - .Publish(shared => Observable.Merge( - // A real set — wins the instant it arrives, from either leg. - shared.Where(static s => s.IsEstablished && s.Sources.Count > 0), - // "I could not read the sources" — consulted only once BOTH legs have had - // their say (TakeLast emits on completion), so it can never pre-empt a - // healthy answer that is merely slower. - shared.Where(static s => !s.IsEstablished).TakeLast(1))) - .Take(1) - // Both legs completed, neither found anything, neither reported a failure: the - // sources genuinely do not exist. That is a CONTENT fact and the compile proceeds. - .DefaultIfEmpty(SourceSnapshot.Empty); - /// /// How long the cached synced source query gets before the uncached probe is even subscribed. /// Comfortably past a warm cache hit or one cold storage read, so the probe stays dormant in @@ -879,27 +646,6 @@ internal static IObservable RaceSourceSnapshot( /// treating the accumulated set as complete. private static readonly TimeSpan SourceProbeQuietWindow = TimeSpan.FromSeconds(1); - /// - /// Fold one into the accumulating path→node map — pure, so - /// the chunk-accumulation contract is unit-testable. Initial/Reset/Added/Updated set; Removed - /// deletes. Keyed by path, so a re-delivered chunk is idempotent rather than a duplicate. - /// - internal static ImmutableDictionary ApplyQueryChange( - ImmutableDictionary acc, QueryResultChange change) - { - if (change?.Items is not { Count: > 0 } items) - return acc; - foreach (var node in items) - { - if (string.IsNullOrEmpty(node?.Path)) - continue; - acc = change.ChangeType == QueryChangeType.Removed - ? acc.Remove(node.Path) - : acc.SetItem(node.Path, node); - } - return acc; - } - /// One expanded source query's result — the nodes it matched, or the reason it did /// not answer. A failed leg is no longer interchangeable with an empty one (issue #1218). private readonly record struct SourceProbeLeg( @@ -982,7 +728,7 @@ private IObservable DirectSourceProbe( // about two comments up, and how a starved read surfaced as a completely // genuine-looking CS0246 (#1218). See Doc/Architecture/QueryIdentity. Observable.Defer(() => mesh.Query(MeshQueryRequest.FromQuery(q).AsSystem())) - .Scan(ImmutableDictionary.Empty, ApplyQueryChange) + .Scan(ImmutableDictionary.Empty, NodeCompileShaping.ApplyQueryChange) .Throttle(SourceProbeQuietWindow) .Take(1) .Select(map => new SourceProbeLeg( @@ -1082,33 +828,11 @@ private IObservable DirectSourceProbe( var discoverCodeFiles = SnapshotSources(ntDef, selfPath, sourcesOverride) .Select(matches => { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var acc = new List(); - foreach (var n in matches) - { - if (string.IsNullOrEmpty(n.Path) || !seen.Add(n.Path)) - continue; - if (n.Content is CodeConfiguration cf - && !string.IsNullOrWhiteSpace(cf.Code)) - { - // Skip executable scripts — they run via the kernel - // (ExecuteScriptRequest), not folded into the parent - // NodeType's Roslyn unit. Top-level statements would - // collide with class declarations from Source/ siblings - // ("Top-level statements must precede namespace and - // type declarations"). Test/ commonly mixes both - // shapes; this filter lets both coexist. - if (cf.IsExecutable) - { - logger.LogDebug( - "Source discovery for {NodePath}: skipping executable Code {CodePath} — runs via kernel only", - node.Path, n.Path); - continue; - } - acc.Add(cf); - matchedCodePaths.Add(n.Path); - } - } + // The dedup + executable filter + join order live in the toolchain + // (NodeCompileShaping, #1707) — they shape the compile input. + var (acc, matched) = NodeCompileShaping.CollectCompileSources( + matches, node.Path, logger); + matchedCodePaths.AddRange(matched); logger.LogDebug( "Source discovery for {NodePath}: matched {Count} Code nodes from {QueryCount} queries", node.Path, matchedCodePaths.Count, executedQueries.Count); @@ -1150,13 +874,9 @@ private IObservable DirectSourceProbe( .SelectMany(codeFiles => { // Final stage: combine + compile. The Roslyn `Compile` call itself is - // the only Task→Observable bridge in this whole method. - CodeConfiguration? codeFile = codeFiles.Count switch - { - 0 => null, - 1 => codeFiles[0], - _ => new CodeConfiguration { Code = string.Join("\n\n", codeFiles.Select(cf => cf.Code)) } - }; + // the only Task→Observable bridge in this whole method. The join order + // lives in the toolchain (it shapes the emitted bytes). + var codeFile = NodeCompileShaping.CombineSources(codeFiles); var configuration = ntDef?.Configuration; var contentCollections = ntDef?.ContentCollections; @@ -1248,7 +968,7 @@ private IObservable DirectSourceProbe( // CompilationException`. .Catch<(string?, ActivityLog), CompilationException>(ex => { - var diag = BuildSourceDiscoveryReport(executedQueries, matchedCodePaths); + var diag = CompileDiagnostics.BuildSourceDiscoveryReport(executedQueries, matchedCodePaths); logger.LogError(ex, "Failed to compile assembly for node {NodePath}. {Diagnostics}", node.Path, diag); var failedLog = AppendError(discoveryLog, @@ -1325,38 +1045,6 @@ private IObservable ValidateCellSurfaceSingleHome( }); } - /// - /// Formats a failed Roslyn Emit's diagnostics into a complete, never-empty error - /// message — each line carries the diagnostic CS#### id, severity, source line and - /// message. Falls back to Warning-severity diagnostics when there are no Errors, and to an - /// explanatory sentence when Emit failed with NO diagnostics at all (typically a missing - /// source file or a configuration lambda referencing a type that was never compiled). The - /// previous Where(Severity == Error).Select(GetMessage) produced a bare - /// "Compilation failed for 'X':" whenever the failure carried no Error-severity diagnostic. - /// - private static string FormatCompileFailure(string nodePath, IEnumerable diagnostics) - { - var joined = string.Join('\n', diagnostics - .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning) - .OrderByDescending(d => d.Severity) - .Select(d => - { - var loc = d.Location.IsInSource - ? $" (line {d.Location.GetLineSpan().StartLinePosition.Line + 1})" - : ""; - return $"{d.Id} {d.Severity}{loc}: {d.GetMessage()}"; - })); - return !string.IsNullOrEmpty(joined) - ? $"Compilation failed for '{nodePath}':\n{joined}" - : $"Compilation failed for '{nodePath}': Roslyn emit failed but produced no error/warning " - + "diagnostics — this usually means a source file was not found, or the configuration " - + "lambda references a type that was never compiled (see the source-discovery report below)."; - } - - // Sentinel FilePath for the generated skeleton tree — must match the one the LSP uses - // so skeleton-internal diagnostics (framework noise the user can't act on) are filtered out. - private const string SkeletonDiagnosticsPath = "__skeleton__.cs"; - /// /// On a FAILED compile, re-derive the diagnostics in their structured, per-source-file /// form by assembling ONE LSP-style compilation (skeleton tree + one tree per src/test @@ -1374,7 +1062,7 @@ private static string FormatCompileFailure(string nodePath, IEnumerable inputs is null ? Observable.Return>(Array.Empty()) - : OnThreadPool(() => DiagnoseInputs(inputs))) + : OnThreadPool(() => CompileDiagnostics.DiagnoseInputs(inputs))) // 🚨 BOUNDED with the same clock as the emit leg — this runs on the FAILURE path, // where a hang is worst: the compile has already failed and this is what stands // between that failure and its terminal Error write. Unbounded, a stalled @@ -1390,83 +1078,6 @@ private static string FormatCompileFailure(string nodePath, IEnumerable>(Array.Empty()); }); - private static IReadOnlyList DiagnoseInputs(CompilationInputs inputs) - { - var trees = new List(inputs.Sources.Length + 1) - { - CSharpSyntaxTree.ParseText( - Microsoft.CodeAnalysis.Text.SourceText.From(inputs.SkeletonSource), - inputs.ParseOptions, path: SkeletonDiagnosticsPath), - }; - foreach (var (path, code) in inputs.Sources) - trees.Add(CSharpSyntaxTree.ParseText( - Microsoft.CodeAnalysis.Text.SourceText.From(code), inputs.ParseOptions, path: path)); - - // Structured failure diagnostics are best-effort: pass no generator candidates so - // RunSourceGenerators is a no-op here (the authoritative flat summary from the production - // compile already reflects generation). Avoids loading any generator on every failed compile. - var compilation = RunSourceGenerators( - CSharpCompilation.Create(inputs.AssemblyName, trees, inputs.References, inputs.CompilationOptions), - Array.Empty(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, CancellationToken.None); - - var diags = compilation.GetDiagnostics(); - if (diags.IsDefaultOrEmpty) return Array.Empty(); - - var result = new List(diags.Length); - foreach (var d in diags) - { - if (d.Severity is not (DiagnosticSeverity.Error or DiagnosticSeverity.Warning)) continue; - // Skeleton-internal diagnostics are framework noise the user can't act on. - if (d.Location.SourceTree?.FilePath == SkeletonDiagnosticsPath) continue; - result.Add(ToDiagnosticInfo(d)); - } - // Errors first, then by file then position — stable order for the GUI. - return result - .OrderByDescending(d => d.Severity) - .ThenBy(d => d.Location?.SourcePath, StringComparer.Ordinal) - .ThenBy(d => d.Location?.Range.Start.Line ?? 0) - .ToList(); - } - - private static Lsp.DiagnosticInfo ToDiagnosticInfo(Diagnostic d) - { - Lsp.SourceLocation? location = null; - if (d.Location.IsInSource && d.Location.SourceTree?.FilePath is { Length: > 0 } path) - { - var span = d.Location.GetLineSpan(); - location = new Lsp.SourceLocation( - path, - new Lsp.SourceRange( - new Lsp.SourcePosition(span.StartLinePosition.Line, span.StartLinePosition.Character), - new Lsp.SourcePosition(span.EndLinePosition.Line, span.EndLinePosition.Character))); - } - return new Lsp.DiagnosticInfo(d.Id, MapDiagnosticSeverity(d.Severity), d.GetMessage(), location); - } - - private static Lsp.DiagnosticSeverity MapDiagnosticSeverity(DiagnosticSeverity s) => s switch - { - DiagnosticSeverity.Hidden => Lsp.DiagnosticSeverity.Hidden, - DiagnosticSeverity.Info => Lsp.DiagnosticSeverity.Info, - DiagnosticSeverity.Warning => Lsp.DiagnosticSeverity.Warning, - DiagnosticSeverity.Error => Lsp.DiagnosticSeverity.Error, - _ => Lsp.DiagnosticSeverity.Info, - }; - - private static string BuildSourceDiscoveryReport(IReadOnlyList executedQueries, IReadOnlyList matchedCodePaths) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"Executed source queries ({executedQueries.Count}):"); - foreach (var q in executedQueries) - sb.AppendLine($" - {q}"); - sb.AppendLine($"Matched Code nodes ({matchedCodePaths.Count}):"); - if (matchedCodePaths.Count == 0) - sb.AppendLine(" (none) — the configuration lambda cannot reference types because no source files were included. Check that your Source Code nodes exist and that the NodeType's `sources` list points at them."); - else - foreach (var p in matchedCodePaths) - sb.AppendLine($" - {p}"); - return sb.ToString(); - } - /// public IObservable CompileAndGetConfigurations( MeshNode node, @@ -1690,7 +1301,7 @@ private static string BuildSourceDiscoveryReport(IReadOnlyList executedQ SnapshotSources(ntDef, selfPath, sourcesOverride) .SelectMany(matches => { - var pairs = CollectSourcePairs(matches); + var pairs = NodeCompileShaping.CollectSourcePairs(matches); return ResolveIncludesForPairs(pairs) .SelectMany(resolvedPairs => // Reactive input assembly — NOT in the pool (see @@ -1700,29 +1311,6 @@ private static string BuildSourceDiscoveryReport(IReadOnlyList executedQ })); } - /// - /// Discovers source (Path, CodeConfiguration, LastModifiedTicks) triples — mirrors - /// the dedup + IsExecutable filter from 's discovery step but - /// retains paths alongside configurations so language services can address each file. - /// - private static List<(string Path, CodeConfiguration Config, long LastModifiedTicks)> - CollectSourcePairs(IEnumerable matches) - { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var pairs = new List<(string, CodeConfiguration, long)>(); - foreach (var n in matches) - { - if (string.IsNullOrEmpty(n.Path) || !seen.Add(n.Path)) continue; - if (n.Content is CodeConfiguration cf - && !string.IsNullOrWhiteSpace(cf.Code) - && !cf.IsExecutable) - { - pairs.Add((n.Path, cf, n.LastModified.UtcTicks)); - } - } - return pairs; - } - /// Resolves @@ includes for each source independently, preserving paths. Sequential aggregation matches . private IObservable> ResolveIncludesForPairs(IReadOnlyList<(string Path, CodeConfiguration Config, long LastModifiedTicks)> pairs) @@ -1775,8 +1363,9 @@ private static string BuildSourceDiscoveryReport(IReadOnlyList executedQ } // A legacy `#r "nuget:MeshWeaver.BusinessRules.Generator"` must NOT reach the NuGet resolver - // (the generator ships built-in now) — see StripBuiltInScopeGeneratorRef. - StripBuiltInScopeGeneratorRef(allNugetRefs, builtInPresent: BuiltInGeneratorPaths.Count > 0); + // (the generator ships built-in now) — see GeneratorPipeline.StripBuiltInScopeGeneratorRef. + GeneratorPipeline.StripBuiltInScopeGeneratorRef( + allNugetRefs, builtInPresent: GeneratorPipeline.BuiltInGeneratorPaths.Count > 0); // 🚨 100% reactive — NO await, and the input assembly is NOT wrapped in // _ioPool.Run. The only async leaf is NuGet restore (network IO), and it runs @@ -1794,10 +1383,8 @@ private static string BuildSourceDiscoveryReport(IReadOnlyList executedQ return referencesObs.Select(references => { - var parseOptions = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose); - var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) - .WithOptimizationLevel(OptimizationLevel.Debug) - .WithPlatform(Platform.AnyCpu); + var parseOptions = EmitPipeline.CreateParseOptions(); + var compilationOptions = EmitPipeline.CreateCompilationOptions(); var sourcesArray = strippedSources .Select(s => (s.Path, s.Code)) @@ -2004,7 +1591,8 @@ private static string BuildSourceDiscoveryReport(IReadOnlyList executedQ // (see the release-folder compile below for the full story: resolving the legacy // generator #r hard-fails now that the mesh-local feed is gone). var nugetRefList = extractedRefs.ToList(); - StripBuiltInScopeGeneratorRef(nugetRefList, builtInPresent: BuiltInGeneratorPaths.Count > 0); + GeneratorPipeline.StripBuiltInScopeGeneratorRef( + nugetRefList, builtInPresent: GeneratorPipeline.BuiltInGeneratorPaths.Count > 0); var nugetRefs = nugetRefList.ToArray(); IEnumerable references = References; IReadOnlyList nugetAssemblyPaths = []; @@ -2028,556 +1616,33 @@ private static string BuildSourceDiscoveryReport(IReadOnlyList executedQ logger.LogInformation("Compiling assembly for {NodeName} ({Mode}, {NuGetRefs} NuGet refs)", nodeName, cacheService.IsDiskCacheEnabled ? "disk" : "in-memory", nugetRefs.Length); - // Parse with source path and encoding embedded (critical for PDB source linking) - var sourceText = Microsoft.CodeAnalysis.Text.SourceText.From(source, System.Text.Encoding.UTF8); - var parseOptions = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose); - var syntaxTree = CSharpSyntaxTree.ParseText( - sourceText, - parseOptions, - path: cacheService.IsDiskCacheEnabled && _cacheOptions.EnableSourceDebugging ? sourcePath : "", - cancellationToken: ct); - + // Parse + compile via the toolchain (EmitPipeline, #1707) — source path and encoding + // embedded (critical for PDB source linking); canonical options; generators applied. var assemblyName = $"DynamicNode_{nodeName}"; - - var compilation = RunSourceGenerators(CSharpCompilation.Create( - assemblyName, - syntaxTrees: [syntaxTree], - references: references, - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) - .WithOptimizationLevel(OptimizationLevel.Debug) - .WithPlatform(Platform.AnyCpu)), nugetAssemblyPaths, logger, ct); + var compilation = GeneratorPipeline.RunSourceGenerators( + EmitPipeline.CreateEmitCompilation( + source, + assemblyName, + references, + parsePath: cacheService.IsDiskCacheEnabled && _cacheOptions.EnableSourceDebugging ? sourcePath : "", + ct), + nugetAssemblyPaths, logger, ct); string? actualPath; if (cacheService.IsDiskCacheEnabled) { - actualPath = await CompileToDiskAsync(compilation, nodeName, node.Path, ct); + actualPath = EmitPipeline.EmitToDiskWithRetry( + cacheService.CacheDirectory, nodeName, EmitPipeline.DiskEmitAttempts, logger, + releaseDir => EmitPipeline.EmitCompilationToDirectory(compilation, nodeName, node.Path, releaseDir, ct)); } else { - CompileToMemory(compilation, nodeName, node.Path, ct); + var (assemblyBytes, pdbBytes) = EmitPipeline.EmitToMemory(compilation, node.Path, ct); + cacheService.LoadAssemblyFromBytes(nodeName, assemblyBytes, pdbBytes); actualPath = $"memory://{nodeName}"; } logger.LogInformation("Successfully compiled assembly for {NodePath} to {ActualPath}", node.Path, actualPath); return actualPath; } - - /// - /// Paths of source-generator assemblies fed to EVERY dynamic-node compilation, resolved once - /// per process. 🚨 The platform does NOT ship any — business rules / scopes moved OUT of the - /// platform (see the comment blocks in MeshWeaver.Graph.csproj and - /// Memex.Portal.Distributed.csproj): the scope runtime is a shared-source library node - /// in the MeshWeaver.Plugins/BusinessRules plugin, pulled into a consumer's compilation - /// via shared=@BusinessRules/Scope/Source, and the plugin carries the - /// ScopeCodeGenerator SOURCE for the generator-injection seam. So on a deployed image - /// this list is EMPTY. It only fills when a MeshWeaver.BusinessRules.Generator.dll is - /// physically present next to the app (a dev/self-host tree that placed one there) — kept as - /// graceful degradation for such trees, and because the legacy-#r strip below keys off it. - /// - private static readonly IReadOnlyList BuiltInGeneratorPaths = ResolveBuiltInGenerators(); - - /// - /// NuGet package id (and, with .dll, assembly file name) of the BusinessRules scope - /// source generator. When a copy is present in the app base ( - /// non-empty), a legacy #r "nuget:MeshWeaver.BusinessRules.Generator" is redundant and is - /// filtered out of BOTH the generator list (avoid a double-run → CS0101, see - /// ) and the NuGet resolve set (avoid a dead round-trip, see - /// ). - /// - private const string BuiltInScopeGeneratorId = "MeshWeaver.BusinessRules.Generator"; - - private static IReadOnlyList ResolveBuiltInGenerators() - { - var path = Path.Combine(AppContext.BaseDirectory, BuiltInScopeGeneratorId + ".dll"); - return File.Exists(path) ? [path] : []; - } - - /// - /// Removes a legacy #r "nuget:MeshWeaver.BusinessRules.Generator" from the NuGet resolve - /// set when the generator ships built-in (). The generator is - /// now part of the platform, so that #r is redundant — and RESOLVING it hard-fails on a - /// deployed image: after BakeMeshLocalFeed was removed (#395) the mesh-local feed - /// (dist/packages) is gone, so NuGet throws - /// "The local source '/app/dist/packages' doesn't exist" and breaks every deployed scope - /// node still carrying the legacy #r (the prod BalanceSheet failure). Behaviour is - /// unchanged: the built-in generator still emits the IScope<,> implementations, and - /// already de-dups the generator itself (CS0101). When the - /// built-in is somehow absent the #r is kept so the generator can still resolve via NuGet. - /// Other package references are never touched. - /// - internal static void StripBuiltInScopeGeneratorRef(List refs, bool builtInPresent) - { - if (builtInPresent) - refs.RemoveAll(r => string.Equals( - r.Id, BuiltInScopeGeneratorId, StringComparison.OrdinalIgnoreCase)); - } - - private static CSharpCompilation RunSourceGenerators( - CSharpCompilation compilation, IReadOnlyList generatorAssemblyPaths, ILogger logger, CancellationToken ct) - { - // Always include the built-in scope generator, plus any OTHER generator a node #r'd. Filter a - // node's own `#r "nuget:MeshWeaver.BusinessRules.Generator"` OUT — otherwise the same generator - // loads from two paths (built-in + baked) and runs twice → duplicate IScope<,> implementations - // → CS0101. Legacy nodes that still carry that #r keep compiling (built-in supersedes it, and - // AssembleCompilationInputs strips the #r from the NuGet resolve set so it never round-trips). - IReadOnlyList allPaths = BuiltInGeneratorPaths.Count == 0 - ? generatorAssemblyPaths - : [.. BuiltInGeneratorPaths, - .. generatorAssemblyPaths.Where(p => !string.Equals( - Path.GetFileName(p), BuiltInScopeGeneratorId + ".dll", StringComparison.OrdinalIgnoreCase))]; - if (allPaths.Count == 0) - return compilation; - var generators = SourceGeneratorLoader.Discover(allPaths, logger); - if (generators.IsDefaultOrEmpty) - return compilation; - var driver = CSharpGeneratorDriver.Create(generators); - driver.RunGeneratorsAndUpdateCompilation(compilation, out var updated, out _, ct); - return (CSharpCompilation)updated; - } - - /// - /// Compiles and emits assembly to a unique per-compile subdirectory, then VERIFIES the - /// assembly actually landed on disk and re-emits when it did not. Each compile writes to - /// {cacheDir}/{nodeName}_{ticks_hex}/ so V1 and V2 DLLs coexist on disk without overwriting. - /// - /// 🩹 Self-heal: on container deployments the cache directory is an ephemeral - /// /tmp/...; a "successful" Roslyn emit can leave NO file on disk when the just-written - /// assembly is evicted before the next read. That used to poison the NodeType permanently with - /// a sticky "Compilation succeeded but DLL not found" error (prod AgenticPension/Datenpunkt, - /// 2026-06-22) — the grain never recompiled. We now re-emit the lost artifact (a genuine compile - /// error is NOT retried) and, if it still cannot be persisted, surface a clear, loud failure - /// instead of a silent poison. See . - /// - /// ⚠️ The self-heal above is about a LOST WRITE, not about a failing emit. A Roslyn - /// emit that reports errors is a normal, deterministic outcome (the node's C# does not - /// compile) and travels out of here as a — which is why - /// shows up on the stack of every compile failure without - /// having failed at anything. Do not read that frame as an IO fault. - /// - private Task CompileToDiskAsync(CSharpCompilation compilation, string nodeName, string nodePath, CancellationToken ct) - => Task.FromResult(EmitToDiskWithRetry( - cacheService.CacheDirectory, nodeName, DiskEmitAttempts, logger, - releaseDir => EmitCompilationToDirectory(compilation, nodeName, nodePath, releaseDir, ct))); - - /// - /// Runs the real Roslyn emit for into - /// (dll + pdb + XML doc) and returns the DLL path it wrote TOGETHER WITH the digest of the image - /// it produced. A failed emit throws a carrying the formatted - /// diagnostics. - /// - /// 🚨 It emits into memory and writes the bytes out, rather than streaming Roslyn straight - /// at the file, for one reason: the publisher has to be able to prove the file on disk IS the - /// image that was emitted. Streaming leaves nothing to compare against, which is how the old - /// Length > 0 gate came to publish an artifact whose metadata had an unwritten region - /// in it and PARK the NodeType for good (#1412). Peak memory is unchanged in practice — Roslyn - /// already serialises the whole PE into an in-memory BlobBuilder before it writes a single - /// byte. See . - /// - /// 🚨 It does NOT log. A compile failure is reported EXACTLY ONCE, by the compile - /// pipeline's single .Catch<…, CompilationException> funnel in - /// CompileAsyncCore — the only place that also has the exception, its stack and the - /// source-discovery report (which queries ran, which Code nodes matched). Logging the same - /// diagnostics here as well double-counted EVERY compile failure in production: the ~150 - /// ERROR lines/24h across the memex/memex-cloud/atioz portals were ~72 real failures logged - /// twice, and the duplicate came FIRST — context-free and exception-free, so red-log - /// fingerprinting (which keys on category+eventId+exception+frame) filed it as a second, - /// distinct fault whose only visible frame was the emit path. That is what made a plain - /// "your C# does not compile" read like an emit/IO defect. Extracted and internal so - /// the log-once contract is unit-testable against a real broken compilation. - /// - internal static EmittedArtifact EmitCompilationToDirectory( - CSharpCompilation compilation, string nodeName, string nodePath, string releaseDir, CancellationToken ct) - { - var dllPath = Path.Combine(releaseDir, $"{nodeName}.dll"); - var pdbPath = Path.Combine(releaseDir, $"{nodeName}.pdb"); - var xmlDocPath = Path.Combine(releaseDir, $"DynamicNode_{nodeName}.xml"); - - using var dllImage = new MemoryStream(); - using var pdbImage = new MemoryStream(); - using var xmlDoc = new MemoryStream(); - - var emitOptions = new EmitOptions( - debugInformationFormat: DebugInformationFormat.PortablePdb, - pdbFilePath: pdbPath); - - EmitResult emitResult; - try - { - emitResult = compilation.Emit( - dllImage, pdbImage, xmlDocumentationStream: xmlDoc, - options: emitOptions, cancellationToken: ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Roslyn THREW instead of returning diagnostics — an emit-phase fault, not a - // compile error. Stamp the canary verdict on the exception and rethrow the - // ORIGINAL untouched: the type name is what CI triage keys on, so this must - // never become a wrapper. The verdict travels to the pipeline's single - // reporting funnel via SummarizeCompileError — no second log from here, the - // log-once contract above still holds. - ex.Data[EmitCanaryDataKey] = ProbeSharedEmitState(compilation); - throw; - } - - if (!emitResult.Success) - // Deterministic compile error — propagates straight out of the retry loop, - // unlogged, to the pipeline's single reporting funnel. - throw new CompilationException(nodePath, FormatCompileFailure(nodePath, emitResult.Diagnostics)); - - // The DLL last: it is the discovery key of the release directory, so a reader that ever sees - // the staging dir mid-write still finds the symbols and docs already beside it. (Publication - // itself is the atomic Directory.Move in EmitToDiskWithRetry; this is belt and braces.) - File.WriteAllBytes(pdbPath, Bytes(pdbImage)); - File.WriteAllBytes(xmlDocPath, Bytes(xmlDoc)); - var image = Bytes(dllImage); - File.WriteAllBytes(dllPath, image); - - return EmittedArtifact.For(dllPath, image); - - // Expandable MemoryStreams created with the parameterless ctor expose their buffer, so the - // common path hands out a span over it instead of copying a multi-megabyte image. - static ReadOnlySpan Bytes(MemoryStream s) - => s.TryGetBuffer(out var seg) ? seg.AsSpan() : s.ToArray(); - } - - /// - /// Key under which stamps the canary verdict on a - /// thrown-from-Emit exception, and under which - /// NodeTypeCompilationHelpers.SummarizeCompileError reads it back. - /// - internal const string EmitCanaryDataKey = "MeshWeaver.EmitCanary"; - - /// - /// A minimal, self-contained compilation used ONLY by . - /// Three levels of nested generics on purpose: that is what makes Roslyn's metadata writer - /// walk a type's containing chain (GetConsolidatedTypeParameters recursing through - /// ContainingTypeDefinition) — the exact path issue #890's NRE dies on. A flat class - /// would emit fine even on a poisoned writer and the canary would answer "healthy" wrongly. - /// - private const string EmitCanarySource = - "public class MwEmitCanary { public class Inner { public class Leaf " - + "{ public T A; public U B; public V C; } } }"; - - /// - /// Answers, at the moment a Roslyn Emit throws, which state is actually broken — in - /// TWO legs, because the first leg alone cannot tell "the shared reference set is poisoned" - /// from "the process is broken below Roslyn". - /// - /// Leg 1 — same references. Re-emit a trivial nested-generic compilation built - /// against the same instances as the compilation that - /// just failed. Succeeds ⇒ shared state is healthy and the fault is a property of this node's - /// own compilation inputs (dump its generated source). This is the leg #1378 shipped, and on - /// 2026-08-13 it returned THREW — closing the "generated source" half of the search - /// space. - /// - /// Leg 2 — pristine references. Run only when leg 1 fails: the SAME source - /// against a freshly created, minimal reference set that has never been handed to Roslyn - /// before and is shared with nothing. This is the discriminator, and it exists because - /// Roslyn's own source settles who can supply the null. The NRE's guard - /// (NamedTypeSymbolAdapter.AsNestedTypeDefinitionImpl) admits a type only when - /// ContainingModule == moduleBeingBuilt.SourceModule — so the symbol whose - /// ContainingType reads null is a source symbol of the compilation being - /// emitted, never a PE symbol arriving from a reference. Leg 1 therefore proves the fault is - /// process-wide without proving the reference set carries it. - /// - /// canary=REFERENCES — pristine emits, shared does not: the poison travels - /// with the reference instances (or the symbols Roslyn caches on their - /// AssemblyMetadata), and scoping the set per-mesh is on the right axis. - /// canary=BELOW-ROSLYN — neither emits: brand-new references and freshly - /// parsed source still cannot emit, so nothing about the reference set explains it. The - /// broken state is under Roslyn (CLR heap / JIT / GC) and no reference-set change can fix - /// it. Roslyn keeps no cross-emit state — the metadata writer's indices are per-emit, its - /// object pools hold only scratch buffers, and AssemblyMetadata.CachedSymbols is a - /// weak list of assembly symbols — so there is no Roslyn cache left to blame. - /// canary=INCONCLUSIVE — the pristine control could not be BUILT (no on-disk - /// CoreLib to reference), so leg 2 never ran. Reported as its own verdict rather than - /// folded into BELOW-ROSLYN: a probe that answers its scariest branch on its own - /// inability would send triage after a CLR heap bug nothing observed. - /// - /// - /// 🚨 It cannot fail into the fault path it is diagnosing: every outcome — including - /// the canary throwing — returns a STRING. A diagnostic that throws while diagnosing would - /// replace the original exception and destroy the evidence it exists to preserve. It is also - /// bounded: one tiny in-memory emit, only ever on an already-failing path, never on the - /// success path or on a normal compile error. - /// - /// The compilation whose Emit threw; only its references are used. - /// A one-line verdict, safe to append to a log message. - internal static string ProbeSharedEmitState(CSharpCompilation faulted) - { - var shared = EmitCanary(() => faulted.References); - if (shared.StartsWith("OK", StringComparison.Ordinal)) - return "canary=OK (a trivial nested-generic emit against the SAME reference set " - + "still succeeds ⇒ shared Roslyn/reference state is healthy; the fault is " - + "specific to THIS compilation's inputs — dump its generated source)"; - - // The shared-reference canary failed. Re-run the SAME source against a reference set that - // shares NOTHING with this process's other compilations — freshly created, minimal - // (System.Private.CoreLib is all the canary source needs), and never handed to Roslyn - // before. - // - // 🚨 BUILDING the pristine reference is a SEPARATE step from emitting against it, and its - // failure is a SEPARATE verdict. If CreateFromFile cannot produce it (an empty - // Assembly.Location on a single-file host, a missing file), the discriminator was never - // run — and folding that into BELOW-ROSLYN would make the probe answer its scariest - // branch on its own inability, sending triage after a CLR heap bug that nothing observed. - // A diagnostic that cannot report "I could not run" is the same defect as a gate that - // passes when its input is missing. - string? pristineUnavailable = null; - IReadOnlyList pristineRefs = []; - try - { - var coreLib = typeof(object).Assembly.Location; - if (string.IsNullOrEmpty(coreLib) || !File.Exists(coreLib)) - pristineUnavailable = "no on-disk System.Private.CoreLib to reference"; - else - pristineRefs = [MetadataReference.CreateFromFile(coreLib)]; - } - catch (Exception buildError) - { - pristineUnavailable = $"{buildError.GetType().Name}: {buildError.Message}"; - } - - if (pristineUnavailable is not null) - return $"canary=INCONCLUSIVE shared:{shared} pristine:UNAVAILABLE({pristineUnavailable}) " - + "— the shared reference set cannot emit, but the pristine control could not be " - + "BUILT, so this says nothing about whether the reference set is the cause"; - - var pristine = EmitCanary(() => pristineRefs); - - return pristine.StartsWith("OK", StringComparison.Ordinal) - ? $"canary=REFERENCES shared:{shared} pristine:{pristine} — the same source emits fine " - + "against BRAND-NEW references but fails against the shared set ⇒ the poison " - + "travels with the MetadataReference instances / the Roslyn symbols cached on " - + "them (reference-set scoping is the right axis)" - : $"canary=BELOW-ROSLYN shared:{shared} pristine:{pristine} — a trivial compilation " - + "with BRAND-NEW references and freshly parsed source cannot emit either ⇒ " - + "nothing about the reference set explains this; the broken state is below " - + "Roslyn (CLR heap / JIT / GC), so no reference-set change can fix it — " - + "capture a core dump and re-run with tiering disabled"; - } - - /// - /// One canary leg: emit against the references - /// produces, into memory, and reduce the outcome to a short - /// token. Never throws — see the "cannot fail into the fault path it is diagnosing" note on - /// . The references arrive as a FACTORY so that building - /// them is inside this method's try as well: on a poisoned process even - /// MetadataReference.CreateFromFile is a candidate to fail. - /// - private static string EmitCanary(Func> references) - { - try - { - var canary = CSharpCompilation.Create( - "MeshWeaverEmitCanary", - syntaxTrees: [CSharpSyntaxTree.ParseText(EmitCanarySource)], - references: references(), - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - using var canaryStream = new MemoryStream(); - var result = canary.Emit(canaryStream); - if (result.Success) - return "OK"; - - var ids = result.Diagnostics - .Where(d => d.Severity == DiagnosticSeverity.Error) - .Select(d => d.Id).Distinct().Take(5); - return $"DIAGNOSTICS({string.Join(",", ids)})"; - } - catch (Exception probeError) - { - return $"THREW {probeError.GetType().Name}: {probeError.Message}"; - } - } - - /// - /// Number of times re-emits when a "successful" Roslyn - /// emit leaves no assembly on disk (ephemeral-cache eviction). Three attempts recover a - /// transient lost write while still failing fast on a genuinely unwritable cache directory. - /// - /// Why a retry is legitimate here (and is NOT covering for a defect of ours): the - /// condition is genuinely EXTERNAL and transient — a container runtime reclaiming an - /// ephemeral /tmp under memory pressure between our write and our read. Nothing in - /// this process can prevent it, there is no lock/slot/budget being leaked, and the retry is - /// bounded, stateless and timer-free: three synchronous attempts, then a loud terminal - /// failure. A deterministic compile error is explicitly NOT retried. Its counterfactual is a - /// permanently poisoned NodeType (prod AgenticPension/Datenpunkt, 2026-06-22), not a slower - /// recovery. Evidence that it is not masking anything: across memex + memex-cloud + atioz it - /// has fired ZERO times in 7 days (31M log lines) — every ERROR the compile service emits in - /// production is a genuine Roslyn diagnostic, never a lost write. - /// - internal const int DiskEmitAttempts = 3; - - /// - /// Emits to a fresh per-attempt subdirectory under and - /// confirms the assembly on disk IS the image that was emitted, re-emitting up to - /// times when it is not. runs - /// the real Roslyn emit into the supplied directory and returns the DLL path together with the - /// digest of the image it produced (); it may throw - /// for a genuine compile error, which propagates immediately - /// (NEVER retried — only a lost or mismatched artifact triggers a re-emit). Extracted and - /// internal so the publication contract is unit-testable without a real flaky filesystem. - /// - internal static string EmitToDiskWithRetry( - string cacheDirectory, - string nodeName, - int maxAttempts, - ILogger logger, - Func emitToReleaseDir) - { - string? lastDllPath = null; - // Why the LAST attempt failed to publish, carried into the terminal exception: "could not - // be persisted" alone sent operators looking for a read-only cache directory when the real - // answer was an artifact that is present and unreadable. - var lastReason = "the artifact never appeared"; - - static void TryDeleteDir(string dir) - { - try { Directory.Delete(dir, recursive: true); } - catch { /* best-effort cleanup */ } - } - - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - var timestamp = DateTimeOffset.UtcNow.Ticks.ToString("x"); - // Unique published name. Discovery orders by the dir's LastWriteTime, NOT by parsing the - // ticks out of the name (see TryGetLatestCachedDllPath), so a GUID suffix guarantees the - // atomic Directory.Move below never collides — on a coarse clock or two rapid compiles — - // while still matching the `{nodeName}_*` glob. - var releaseDir = Path.Combine(cacheDirectory, $"{nodeName}_{timestamp}_{Guid.NewGuid():N}"); - lastDllPath = Path.Combine(releaseDir, $"{nodeName}.dll"); - - // 🚨 Emit into a STAGING dir whose name does NOT match the `{nodeName}_*` discovery glob - // (TryGetLatestCachedDllPath), then atomically publish it by renaming to the discoverable - // name only AFTER the DLL is fully written + verified. The DLL file exists at 0 bytes and - // grows during compilation.Emit (File.Create + Emit is NOT atomic); without staging, a - // concurrent reader can discover the half-written DLL and LoadFromAssemblyPath a truncated - // image → a native crash (SIGSEGV) or a BadImageFormat that deletes the artifact and churns - // the compile. A directory rename on the same filesystem is atomic, so a reader sees either - // nothing or the COMPLETE artifact. - var stagingDir = Path.Combine(cacheDirectory, $".staging-{nodeName}-{timestamp}-{Guid.NewGuid():N}"); - Directory.CreateDirectory(stagingDir); - - // The real emit (a genuine compile error throws straight through — never retried). Discard - // the half-written staging dir first so a failed emit leaves no partial artifact behind (the - // old code leaked a glob-discoverable `{nodeName}_{ticks}` dir here — the same hazard). - EmittedArtifact staged; - try - { - staged = emitToReleaseDir(stagingDir); - } - catch - { - TryDeleteDir(stagingDir); - throw; - } - - // Confirm the staged file IS the image the emit produced, then atomically publish. EVERY - // fault here is a RETRYABLE publish failure — an ephemeral-cache eviction racing the - // read, a lost or partial write, or a transient rename IO error — so discard staging and - // re-emit rather than aborting the compile. - // - // 🚨 The predicate is "these are the bytes we emitted", NOT "this file is non-empty". - // `Length > 0` accepts a 1-byte file, a truncated PE, and — the case that survived - // #1387 — a full-length image with an unwritten region inside its metadata. None of - // those is rejected by the loader in any way the pipeline survives: the first two make - // LoadNodeAssembly return null, the third loads fine and throws - // ReflectionTypeLoadException "…because the format is invalid" on the first GetTypes(). - // CompileResultFromAssembly records either as CompilationStatus.Error, and the - // first-build kickoff is gated on Status == null, so it NEVER retries — the bytes may - // heal, the verdict does not, and the NodeType is parked for good (#1412). Proving the - // artifact before it enters the discovery namespace is the publication contract, the - // same one AtomicFileWrite gives the assembly store; it is emphatically NOT a retry - // around a load. See EmittedArtifact for why a digest and not a metadata walk. - try - { - if (staged.MatchesFileOnDisk(out lastReason)) - { - Directory.Move(stagingDir, releaseDir); - return lastDllPath; - } - - logger.LogWarning( - "Emit for {NodeName} reported success but the staged assembly at {DllPath} is " + - "not the image that was emitted — {Reason} (attempt {Attempt}/{Max}); re-emitting.", - nodeName, staged.DllPath, lastReason, attempt, maxAttempts); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - lastReason = $"publishing failed — {ex.GetType().Name}: {ex.Message}"; - logger.LogWarning(ex, - "Publishing the emitted assembly for {NodeName} failed (attempt {Attempt}/{Max}); re-emitting.", - nodeName, attempt, maxAttempts); - } - - // Drop the staging directory so the retry starts clean. - TryDeleteDir(stagingDir); - } - - throw new CompilationException(nodeName, - $"Compilation succeeded but the emitted assembly for '{nodeName}' could not be published to " + - $"'{cacheDirectory}' after {maxAttempts} attempts (last target '{lastDllPath}'; last failure: " + - $"{lastReason}). The compilation host's cache directory may be read-only, evicting files, or " + - "losing writes."); - } - - /// - /// Compiles and loads assembly directly to memory (no disk I/O). - /// - /// 🚨 Like , a failed emit throws UNLOGGED — - /// the pipeline's single .Catch<…, CompilationException> funnel is the one - /// reporter of a compile failure. - /// - private void CompileToMemory(CSharpCompilation compilation, string nodeName, string nodePath, CancellationToken ct) - { - using var dllStream = new MemoryStream(); - using var pdbStream = new MemoryStream(); - - var emitOptions = new EmitOptions( - debugInformationFormat: DebugInformationFormat.PortablePdb); - - var emitResult = compilation.Emit(dllStream, pdbStream, options: emitOptions, cancellationToken: ct); - - if (!emitResult.Success) - throw new CompilationException(nodePath, FormatCompileFailure(nodePath, emitResult.Diagnostics)); - - // Load assembly from bytes immediately - var assemblyBytes = dllStream.ToArray(); - var pdbBytes = pdbStream.ToArray(); - cacheService.LoadAssemblyFromBytes(nodeName, assemblyBytes, pdbBytes); - } -} - -/// -/// Exception thrown when compilation fails. -/// -public class CompilationException : Exception -{ - /// The mesh path of the node whose compilation failed. - public string NodePath { get; } - - /// - /// Initializes a new instance of the exception for a failed compilation. - /// - /// The mesh path of the node whose compilation failed. - /// The error message describing the failure. - public CompilationException(string nodePath, string message) - : base(message) - { - NodePath = nodePath; - } - - /// - /// Initializes a new instance of the exception for a failed compilation, wrapping an - /// underlying cause. - /// - /// The mesh path of the node whose compilation failed. - /// The error message describing the failure. - /// The underlying exception that caused the failure. - public CompilationException(string nodePath, string message, Exception innerException) - : base(message, innerException) - { - NodePath = nodePath; - } } diff --git a/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs b/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs index 99a660fae3..c451e77adb 100644 --- a/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs +++ b/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs @@ -39,7 +39,8 @@ internal sealed class MeshNodeLanguageService( { // Path used as the SyntaxTree.FilePath for the generated skeleton tree. // Distinct from any user MeshNode path so callers can never address it accidentally. - private const string SkeletonDocumentPath = "__skeleton__.cs"; + // Aliases the toolchain's sentinel — the failure-diagnostics filter keys on the SAME value. + private const string SkeletonDocumentPath = MeshWeaver.Compiler.CompileDiagnostics.SkeletonDiagnosticsPath; // Per-NodeType cached workspace, invalidated when source versions change. // Concurrent because hub-message handlers may invoke language-service queries diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs index 82cf61c4db..92da4cb9fe 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// @@ -992,38 +993,18 @@ public void Advance(DateTimeOffset candidate) } /// - /// The live MeshWeaver framework identity a compiled NodeType release is pinned to — see - /// for the scheme. Hosts that ship a surface manifest - /// (the portals, the CI bake host) resolve the API-SURFACE identity (s<hash> — - /// stable across internal-only framework changes, so content rebakes ONLY on a breaking - /// surface change, #1660 WS3 refinement "rebuild only when we need to"); manifest-less CI - /// processes fall back to the stamped COMMIT identity (g<sha>, kept as logged - /// provenance everywhere); local builds fall back to the MeshWeaver.Graph assembly's - /// MVID (content-exact for a dirty working tree). A mismatch against a NodeType's - /// CompiledFrameworkVersion means "recompile". Computed once per process. + /// The live MeshWeaver framework identity a compiled NodeType release is pinned to — + /// delegates to (MeshWeaver.Compiler, + /// the toolchain assembly the identity is anchored on since #1707). A mismatch against a + /// NodeType's CompiledFrameworkVersion means "recompile". Kept as a delegating shim so + /// the many Graph-internal consumers (and the IVT'd bake pipeline) need no re-pointing. /// - internal static string FrameworkVersion => _frameworkVersion.Value.Identity; + internal static string FrameworkVersion => FrameworkBuildIdentity.FrameworkVersion; /// Degradation warning from the identity resolution (a torn/unusable surface - /// manifest fell back to the stamp/MVID layer), or null on the happy path — cached with the - /// identity itself so the pre-warmer can log it beside the identity it announces. - internal static string? FrameworkVersionWarning => _frameworkVersion.Value.Warning; - - // Resolution once per process, through the ONE chain (FrameworkBuildIdentity - // .ResolveProcessIdentityWithDiagnostics): surface manifest → commit stamp → Graph MVID. The MVID fallback - // rationale (local builds): the MVID is part of the compiled module, so it is STABLE whenever - // the DLL bytes are identical and CHANGES whenever Graph is rebuilt with different content. - // The surface identity supersedes both where a manifest ships because the commit key - // OVER-invalidated (every merge — internal-only included — rebaked all content; measured - // pending=82 on the ci.3979 roll) while the Graph MVID alone UNDER-invalidates (it only - // covers Graph's own compile inputs, not the content-facing surface of the rest of the - // framework). The ref-assembly-based surface hash sits exactly between: it moves when — and - // only when — the API surface content compiles against changes (plus the Graph-impl - // exception for the compile pipeline's own emitters). - private static readonly Lazy<(string Identity, string? Warning)> _frameworkVersion = new(() => - FrameworkBuildIdentity.ResolveProcessIdentityWithDiagnostics( - AppContext.BaseDirectory, - typeof(NodeTypeCompilationHelpers).Assembly)); + /// manifest fell back to the stamp/MVID layer), or null on the happy path — see + /// . + internal static string? FrameworkVersionWarning => FrameworkBuildIdentity.FrameworkVersionWarning; /// /// True when a NodeType's persisted compile state is backed by a compiled @@ -1198,11 +1179,11 @@ internal static string SummarizeCompileError(NodeCompilationResult? result, Exce : "Compilation produced no assembly", CompilationException ce => ce.Message, // A non-Roslyn abort out of Emit carries the canary verdict - // (MeshNodeCompilationService.ProbeSharedEmitState) — appended HERE, in the one + // (EmitPipeline.ProbeSharedEmitState) — appended HERE, in the one // funnel, so the answer to "is this compilation's inputs or the whole process?" // rides the record triage already reads, without a second log line. { } other => $"{other.GetType().Name}: {other.Message}" - + (other.Data[MeshNodeCompilationService.EmitCanaryDataKey] is string canary + + (other.Data[EmitPipeline.EmitCanaryDataKey] is string canary ? $" [{canary}]" : string.Empty), }; diff --git a/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs b/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs index 625a84e639..e12d44210a 100644 --- a/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs +++ b/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs @@ -22,12 +22,14 @@ public static class PrebuiltAssemblySeeder /// Why a prebuilt assembly may NOT be adopted, or null when it may. /// /// 🚨 This is the whole safety argument, kept as one pure function so it can be tested - /// without standing up a mesh. FrameworkVersion is MeshWeaver.Graph's MVID — a CONTENT - /// identity — and the assembly-store key carries its first eight characters. Seeding bytes built - /// against different framework content therefore writes them under the LIVE framework's tag, - /// where the store reports them as a usable build: the rebuild that was needed is suppressed, - /// and the ABI mismatch surfaces as a TypeLoadException inside a collectible ALC at - /// activation — no overlay, no compile error, nothing to grep. + /// without standing up a mesh. FrameworkVersion is the resolved framework build + /// identity ( — surface hash / commit + /// stamp / toolchain-anchor MVID) — and the assembly-store key carries its first eight + /// characters. Seeding bytes built against a different framework therefore writes them under + /// the LIVE framework's tag, where the store reports them as a usable build: the rebuild that + /// was needed is suppressed, and the ABI mismatch surfaces as a TypeLoadException + /// inside a collectible ALC at activation — no overlay, no compile error, nothing to + /// grep. /// /// Declining is always safe (the caller compiles, as it does today). Adopting on faith is /// not. So anything short of an exact match declines — including an absent identity, which is @@ -54,10 +56,11 @@ public static class PrebuiltAssemblySeeder : null; /// - /// The LIVE framework identity a producer must record beside its bytes — MeshWeaver.Graph's - /// MVID, exactly as compares it. The one public reading of - /// this identity, so a producer (the CI bake, the registry bundle lane) and the consuming gate - /// can never disagree about what "the framework identity" is. + /// The LIVE framework identity a producer must record beside its bytes — the resolved + /// framework build identity (), exactly + /// as compares it. The one public reading of this + /// identity, so a producer (the CI bake, the registry bundle lane) and the consuming gate can + /// never disagree about what "the framework identity" is. /// public static string LiveFrameworkMvid => NodeTypeCompilationHelpers.FrameworkVersion; @@ -93,12 +96,13 @@ public static IObservable Seed( string? frameworkMvid, ILogger? logger = null) { - // 🚨 THE GATE. FrameworkVersion is MeshWeaver.Graph's MVID — a CONTENT identity, not a - // version string — and the assembly-store key carries the first eight characters of it. So - // seeding bytes built against different framework content writes them under the LIVE - // framework's tag, where the store reports them as a usable build: the rebuild that was - // needed is suppressed, and the ABI mismatch surfaces as a TypeLoadException inside a - // collectible ALC at activation — no overlay, no compile error, nothing to grep. + // 🚨 THE GATE. FrameworkVersion is the resolved framework build identity — a content/ + // surface identity, not a version string — and the assembly-store key carries the first + // eight characters of it. So seeding bytes built against a different framework writes + // them under the LIVE framework's tag, where the store reports them as a usable build: + // the rebuild that was needed is suppressed, and the ABI mismatch surfaces as a + // TypeLoadException inside a collectible ALC at activation — no overlay, no compile + // error, nothing to grep. // // Declining is always safe; adopting on faith is not. So an identity that is absent or // different declines, and the caller compiles. diff --git a/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj b/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj index ea0ba9c639..4c4d567271 100644 --- a/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj +++ b/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj @@ -44,6 +44,11 @@ + + diff --git a/src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs b/src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs new file mode 100644 index 0000000000..27f4ea7f36 --- /dev/null +++ b/src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; + +// The NodeType compile toolchain moved to MeshWeaver.Compiler (#1707). The moved public types +// keep their MeshWeaver.Graph.Configuration namespace and are forwarded here so BINARY consumers +// compiled against earlier releases — installed modules pin their compile-time assembly +// references — keep resolving them through MeshWeaver.Graph. +[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.CodeQueryResolver))] +[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.CodeQueryGroup))] +[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.CompilationException))] +[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.FrameworkBuildIdentity))] +[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.FileSystemAssemblyStore))] +[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.AssemblyStoreExtensions))] +[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.SourceDiscoveryUnavailableException))] diff --git a/src/MeshWeaver.Hosting/NodeTypeBatchBake.cs b/src/MeshWeaver.Hosting/NodeTypeBatchBake.cs index 187a3d369e..f1c5d4964d 100644 --- a/src/MeshWeaver.Hosting/NodeTypeBatchBake.cs +++ b/src/MeshWeaver.Hosting/NodeTypeBatchBake.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting; /// @@ -324,7 +325,7 @@ internal sealed class SourceDiscoveryFailedException(string message) : Exception /// /// One mesh query, chunk-accumulated to a settled path→node map: fold every /// (the compiler's own pure fold, - /// ) and treat a + /// ) and treat a /// of silence as completion — a bare Take(1) on a /// chunked Initial would hand the compiler a PARTIAL source set, which compiles wrong. /// @@ -361,7 +362,7 @@ private static IObservable> RunQuery( }.AsSystem())) .Scan( ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase), - MeshNodeCompilationService.ApplyQueryChange) + NodeCompileShaping.ApplyQueryChange) .Throttle(QueryQuietWindow) .Take(1) .SelectMany(nodes => nodes.Count < SourceDiscoveryLimit diff --git a/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs b/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs index 8eac64958a..c6bdd76602 100644 --- a/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs +++ b/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs @@ -9,12 +9,12 @@ namespace MeshWeaver.Plugin.Build; /// /// 🚨 The identity is , not the package version. /// The runtime compares NodeTypeDefinition.CompiledFrameworkVersion against -/// NodeTypeCompilationHelpers.FrameworkVersion: for a CI-built framework that is the stamped -/// COMMIT identity (AssemblyMetadata("MeshWeaverFrameworkIdentity", "g<sha>") on -/// MeshWeaver.Graph — identical for every CI build of the same commit, #1660 WS3); for a local -/// build it is Graph's MVID — a content identity, chosen over -/// AssemblyInformationalVersion because deriving it from the version string forced a fresh -/// stamp into every build and destroyed incremental compilation. +/// : for a CI-built framework that is the +/// stamped COMMIT identity (AssemblyMetadata("MeshWeaverFrameworkIdentity", "g<sha>") +/// on — identical for every CI build of the same commit, #1660 +/// WS3); for a manifest-less local build it is the toolchain assembly's MVID — a content +/// identity, chosen over AssemblyInformationalVersion because deriving it from the version +/// string forced a fresh stamp into every build and destroyed incremental compilation. /// /// So a package recording only 3.0.0-rc2 records something the runtime never looks at: /// two builds can share a version string and differ in content, and the identity is what says @@ -27,8 +27,9 @@ namespace MeshWeaver.Plugin.Build; /// public static class FrameworkIdentity { - /// The assembly whose stamp/MVID IS the framework identity. - public const string IdentityAssembly = "MeshWeaver.Graph"; + /// The assembly whose stamp/MVID IS the framework identity — the compile toolchain + /// assembly the identity is anchored on (moved from MeshWeaver.Graph in #1707). + public const string IdentityAssembly = "MeshWeaver.Compiler"; /// /// Reads an assembly's framework identity without loading it — metadata only, so it cannot diff --git a/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs b/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs index 06a84c67c9..f58c94cd64 100644 --- a/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs +++ b/src/MeshWeaver.Plugin.Build/ModulePackCommand.cs @@ -29,9 +29,10 @@ namespace MeshWeaver.Plugin.Build; /// plain assembly binding by simple name; its contract is API compatibility, so the bundle records /// the platform floor the module requires (absent = no constraint) and the consumer lands anything /// whose floor it satisfies — one bundle serves every compatible platform build, and nothing needs -/// rebundling per CI build. The MVID of the MeshWeaver.Graph.dll in the build output is -/// still recorded when found, as DIAGNOSTIC metadata naming the exact build behind the bytes; -/// MVID equality remains the NodeType (bake) lane's gate only. +/// rebundling per CI build. The MVID of the identity anchor +/// (MeshWeaver.Compiler.dll, #1707) in the build output is still recorded when found, as +/// DIAGNOSTIC metadata naming the exact build behind the bytes; MVID equality remains the +/// NodeType (bake) lane's gate only. /// public static class ModulePackCommand { @@ -83,11 +84,13 @@ a bundle at an invented version collides with the consumer's landing gate (API compatibility as a semver floor). Omit for no constraint; mirror the package's content.minMeshVersion when it declares one - --graph-dll the MeshWeaver.Graph.dll the module was built against - (default: /MeshWeaver.Graph.dll). - Its MVID is recorded as DIAGNOSTIC metadata — the - exact build behind the bytes — never a gate; a - missing DLL warns and records none + --graph-dll the identity-anchor assembly the module was built + against — MeshWeaver.Compiler.dll since #1707 + (default: /MeshWeaver.Compiler.dll; + the flag name predates the anchor move). Its MVID is + recorded as DIAGNOSTIC metadata — the exact build + behind the bytes — never a gate; a missing DLL warns + and records none --with an additional closure file from (repeatable). .dll is always included, and its .pdb rides along when present. @@ -178,11 +181,12 @@ .pdb rides along when present. } // The framework identity is DIAGNOSTIC metadata (which exact platform build produced these - // bytes) — recorded when the restored MeshWeaver.Graph.dll is at hand, warned-and-omitted - // when not. It is deliberately not required and never a gate: the consumer lands on the - // minMeshVersion floor (API compatibility), and identity equality stays with the NodeType - // bake lane. ReadIdentity (not ReadMvid) so a CI-built Graph records its stamped commit - // identity — the value the runtime actually compares (#1660 WS3). + // bytes) — recorded when the restored identity-anchor DLL (MeshWeaver.Compiler.dll, #1707) + // is at hand, warned-and-omitted when not. It is deliberately not required and never a + // gate: the consumer lands on the minMeshVersion floor (API compatibility), and identity + // equality stays with the NodeType bake lane. ReadIdentity (not ReadMvid) so a CI-built + // anchor records its stamped commit identity — the value the runtime actually compares + // (#1660 WS3). graphDll ??= Path.Combine(moduleDirectory, FrameworkIdentity.IdentityAssembly + ".dll"); string? frameworkMvid = null; if (File.Exists(graphDll)) diff --git a/test/MeshWeaver.Graph.Test/CodeIncludePatternTest.cs b/test/MeshWeaver.Graph.Test/CodeIncludePatternTest.cs index 286a805ae0..1bb2959fe9 100644 --- a/test/MeshWeaver.Graph.Test/CodeIncludePatternTest.cs +++ b/test/MeshWeaver.Graph.Test/CodeIncludePatternTest.cs @@ -2,11 +2,12 @@ using MeshWeaver.Graph.Configuration; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// /// Pins the compile-source include scanner's pattern -/// () against the 2026-07-29 Store +/// () against the 2026-07-29 Store /// outage: the permissive predecessor (@@([^\s#\]]+)) ran over RAW C# source and scraped /// prose — XML doc comments citing the markdown embed idiom, and test string literals asserting /// it — as include paths. Each garbage match cost a SERIAL 15s GetMeshNode timeout on the @@ -29,7 +30,7 @@ public class CodeIncludePatternTest [InlineData("@@")] public void ProseNeverMatches(string sourceLine) { - Assert.Empty(MeshNodeCompilationService.CodeIncludePattern.Matches(sourceLine)); + Assert.Empty(NodeCompileShaping.CodeIncludePattern.Matches(sourceLine)); } /// Genuine include directives — a node path after the marker — must keep resolving. @@ -40,7 +41,7 @@ public void ProseNeverMatches(string sourceLine) [InlineData("@@my-space/sub_dir/File", "my-space/sub_dir/File")] public void NodePathsStillMatch(string sourceLine, string expectedPath) { - var match = Assert.Single(MeshNodeCompilationService.CodeIncludePattern.Matches(sourceLine).Cast()); + var match = Assert.Single(NodeCompileShaping.CodeIncludePattern.Matches(sourceLine).Cast()); Assert.Equal(expectedPath, match.Groups[1].Value); } @@ -51,13 +52,13 @@ public void NodePathsStillMatch(string sourceLine, string expectedPath) [InlineData("(@@Doc/Guide)", "Doc/Guide")] public void CaptureStopsAtPathBoundary(string sourceLine, string expectedPath) { - var match = Assert.Single(MeshNodeCompilationService.CodeIncludePattern.Matches(sourceLine).Cast()); + var match = Assert.Single(NodeCompileShaping.CodeIncludePattern.Matches(sourceLine).Cast()); Assert.Equal(expectedPath, match.Groups[1].Value); } /// /// An include path is authored MOUNT-relative, so it must resolve from whichever prefix the - /// including node is served under (). + /// including node is served under (). /// /// memex-cloud 2026-08-12: samples/Graph/Data/FutuRe/GroupAnalysis/Source/ExternalDependencies /// is nothing but @@FutuRe/<sibling>/Source/… lines. Mounted at the mesh root (what @@ -98,7 +99,7 @@ public void CaptureStopsAtPathBoundary(string sourceLine, string expectedPath) public void IncludePathAnchorsOntoTheIncludingNodesMount( string authored, string anchorPath, string expected) { - Assert.Equal(expected, MeshNodeCompilationService.AnchorIncludePath(authored, anchorPath)); + Assert.Equal(expected, NodeCompileShaping.AnchorIncludePath(authored, anchorPath)); } /// With no anchor there is nothing to rebase against — the authored path stands. @@ -109,6 +110,6 @@ public void NoAnchorLeavesThePathAlone(string? anchorPath) { Assert.Equal( "FutuRe/AmountType/Source/AmountType", - MeshNodeCompilationService.AnchorIncludePath("FutuRe/AmountType/Source/AmountType", anchorPath)); + NodeCompileShaping.AnchorIncludePath("FutuRe/AmountType/Source/AmountType", anchorPath)); } } diff --git a/test/MeshWeaver.Graph.Test/CompileFailureReportedOnceTest.cs b/test/MeshWeaver.Graph.Test/CompileFailureReportedOnceTest.cs index 55e28c7eae..3ffa032eb3 100644 --- a/test/MeshWeaver.Graph.Test/CompileFailureReportedOnceTest.cs +++ b/test/MeshWeaver.Graph.Test/CompileFailureReportedOnceTest.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// @@ -114,9 +115,9 @@ public void Failed_emit_throws_the_full_diagnostics_and_logs_nothing() var logger = new RecordingLogger(); var ex = Assert.Throws(() => - MeshNodeCompilationService.EmitToDiskWithRetry( - _cacheDir, nodeName, MeshNodeCompilationService.DiskEmitAttempts, logger, - releaseDir => MeshNodeCompilationService.EmitCompilationToDirectory( + EmitPipeline.EmitToDiskWithRetry( + _cacheDir, nodeName, EmitPipeline.DiskEmitAttempts, logger, + releaseDir => EmitPipeline.EmitCompilationToDirectory( BrokenCompilation(nodeName), nodeName, "Acme/BrokenSource", releaseDir, CancellationToken.None))); @@ -142,12 +143,12 @@ public void A_failing_emit_is_attempted_once_and_leaves_no_artifact() var emits = 0; Assert.Throws(() => - MeshNodeCompilationService.EmitToDiskWithRetry( - _cacheDir, nodeName, MeshNodeCompilationService.DiskEmitAttempts, new RecordingLogger(), + EmitPipeline.EmitToDiskWithRetry( + _cacheDir, nodeName, EmitPipeline.DiskEmitAttempts, new RecordingLogger(), releaseDir => { emits++; - return MeshNodeCompilationService.EmitCompilationToDirectory( + return EmitPipeline.EmitCompilationToDirectory( BrokenCompilation(nodeName), nodeName, "Acme/BrokenOnce", releaseDir, CancellationToken.None); })); @@ -167,9 +168,9 @@ public void A_successful_emit_writes_the_assembly_and_logs_nothing() const string nodeName = "Demo_ValidSource"; var logger = new RecordingLogger(); - var dllPath = MeshNodeCompilationService.EmitToDiskWithRetry( - _cacheDir, nodeName, MeshNodeCompilationService.DiskEmitAttempts, logger, - releaseDir => MeshNodeCompilationService.EmitCompilationToDirectory( + var dllPath = EmitPipeline.EmitToDiskWithRetry( + _cacheDir, nodeName, EmitPipeline.DiskEmitAttempts, logger, + releaseDir => EmitPipeline.EmitCompilationToDirectory( ValidCompilation(nodeName), nodeName, "Acme/ValidSource", releaseDir, CancellationToken.None)); @@ -190,12 +191,12 @@ public void The_lost_write_self_heal_still_warns_because_it_never_reaches_the_fu var logger = new RecordingLogger(); var emits = 0; - MeshNodeCompilationService.EmitToDiskWithRetry( - _cacheDir, nodeName, MeshNodeCompilationService.DiskEmitAttempts, logger, + EmitPipeline.EmitToDiskWithRetry( + _cacheDir, nodeName, EmitPipeline.DiskEmitAttempts, logger, releaseDir => { emits++; - var emitted = MeshNodeCompilationService.EmitCompilationToDirectory( + var emitted = EmitPipeline.EmitCompilationToDirectory( ValidCompilation(nodeName), nodeName, "Acme/LostWrite", releaseDir, CancellationToken.None); if (emits == 1) @@ -217,7 +218,7 @@ public void The_lost_write_self_heal_still_warns_because_it_never_reaches_the_fu [Fact] public void The_emit_canary_reports_healthy_shared_state_on_a_healthy_process() { - var verdict = MeshNodeCompilationService.ProbeSharedEmitState(ValidCompilation("Demo_Canary")); + var verdict = EmitPipeline.ProbeSharedEmitState(ValidCompilation("Demo_Canary")); verdict.Should().StartWith("canary=OK", "nothing has poisoned this process, so a trivial nested-generic emit against the " @@ -240,7 +241,7 @@ public void The_emit_canary_answers_instead_of_throwing_when_the_reference_set_i references: [], options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var verdict = MeshNodeCompilationService.ProbeSharedEmitState(unusable); + var verdict = EmitPipeline.ProbeSharedEmitState(unusable); verdict.Should().NotBeNullOrWhiteSpace(); verdict.Should().NotStartWith("canary=OK", @@ -268,7 +269,7 @@ public void The_emit_canary_distinguishes_a_broken_reference_set_from_a_broken_p references: [], options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var verdict = MeshNodeCompilationService.ProbeSharedEmitState(unusable); + var verdict = EmitPipeline.ProbeSharedEmitState(unusable); verdict.Should().StartWith("canary=REFERENCES", "the pristine leg must be able to emit on a healthy process — otherwise the " @@ -294,7 +295,7 @@ public void The_emit_canary_never_reports_below_roslyn_just_because_it_could_not references: [], options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var verdict = MeshNodeCompilationService.ProbeSharedEmitState(unusable); + var verdict = EmitPipeline.ProbeSharedEmitState(unusable); // On any host where this test can run at all, CoreLib is on disk, so the control builds // and the verdict is a real discrimination — never INCONCLUSIVE, never BELOW-ROSLYN. diff --git a/test/MeshWeaver.Graph.Test/EmitToDiskWithRetryTest.cs b/test/MeshWeaver.Graph.Test/EmitToDiskWithRetryTest.cs index faf9d5521b..45faa5f8d7 100644 --- a/test/MeshWeaver.Graph.Test/EmitToDiskWithRetryTest.cs +++ b/test/MeshWeaver.Graph.Test/EmitToDiskWithRetryTest.cs @@ -11,10 +11,11 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// -/// The publication contract of : +/// The publication contract of : /// an artifact becomes discoverable only when the file on disk IS the image the emit produced, /// and a lost write is re-emitted rather than poisoning the NodeType. /// @@ -108,7 +109,7 @@ public void Happy_path_persists_on_first_attempt() const string nodeName = "Demo_Happy"; var attempts = 0; - var result = MeshNodeCompilationService.EmitToDiskWithRetry( + var result = EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { attempts++; return Stage(releaseDir, nodeName, RealAssemblyBytes()); }); @@ -125,7 +126,7 @@ public void Re_emits_when_first_artifact_is_lost_then_succeeds() const string nodeName = "Demo_Datenpunkt"; var attempts = 0; - var result = MeshNodeCompilationService.EmitToDiskWithRetry( + var result = EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { @@ -149,7 +150,7 @@ public void Treats_empty_artifact_as_lost_and_re_emits() var attempts = 0; var whole = RealAssemblyBytes(); - var result = MeshNodeCompilationService.EmitToDiskWithRetry( + var result = EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { @@ -176,7 +177,7 @@ public void Re_emits_when_the_staged_artifact_is_truncated() var attempts = 0; var whole = RealAssemblyBytes(); - var result = MeshNodeCompilationService.EmitToDiskWithRetry( + var result = EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { @@ -209,7 +210,7 @@ public void Re_emits_when_the_staged_artifact_is_full_length_but_corrupt_inside( LoaderVerdict(WriteTemp(holed)).Should().NotBeNull( "the fixture must be damage the real loader actually refuses"); - var result = MeshNodeCompilationService.EmitToDiskWithRetry( + var result = EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { @@ -231,7 +232,7 @@ public void Throws_clear_error_when_artifact_never_persists() var ex = Assert.Throws(() => { - MeshNodeCompilationService.EmitToDiskWithRetry( + EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { @@ -262,7 +263,7 @@ public void Throws_naming_the_damage_when_every_attempt_is_corrupt() var holed = WithMetadataHole(whole); var ex = Assert.Throws(() => - MeshNodeCompilationService.EmitToDiskWithRetry( + EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { @@ -288,7 +289,7 @@ public void Does_not_retry_a_genuine_compile_error() var ex = Assert.Throws(() => { - MeshNodeCompilationService.EmitToDiskWithRetry( + EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { @@ -311,7 +312,7 @@ public void Successful_emit_publishes_to_a_discoverable_dir_and_leaves_no_stagin // and no `.staging-*` dir is left behind. const string nodeName = "Demo_AtomicPublish"; - var result = MeshNodeCompilationService.EmitToDiskWithRetry( + var result = EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => Stage(releaseDir, nodeName, RealAssemblyBytes())); @@ -332,7 +333,7 @@ public void Compile_error_leaves_no_discoverable_partial_artifact() const string nodeName = "Demo_ErrorNoPartial"; Assert.Throws(() => - MeshNodeCompilationService.EmitToDiskWithRetry( + EmitPipeline.EmitToDiskWithRetry( _cacheDir, nodeName, maxAttempts: 3, NullLogger.Instance, releaseDir => { diff --git a/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs b/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs index 13bebdfb5f..fd892524de 100644 --- a/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs +++ b/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs @@ -8,15 +8,20 @@ namespace MeshWeaver.Graph.Test; /// -/// Pins the ONE framework build identity (#1660 WS3): the API-surface scheme ("rebuild only when -/// we need to" — content rebakes exactly when the surface it compiles against changes), its -/// fallback chain (commit stamp → Graph MVID), and — the pin the whole CI bake stands on — that -/// every reader resolves the SAME value for the same inputs. The process-level tests run in both -/// build flavors on purpose: this test host ships no surface manifest, so it exercises the -/// fallback chain exactly as a manifest-less CI test process does. +/// Pins the ONE framework build identity (#1660 WS3, anchored on MeshWeaver.Compiler since +/// #1707): the API-surface scheme ("rebuild only when we need to" — content rebakes exactly when +/// the surface it compiles against changes), its fallback chain (commit stamp → toolchain-anchor +/// MVID), and — the pin the whole CI bake stands on — that every reader resolves the SAME value +/// for the same inputs. The process-level tests run in both build flavors on purpose: this test +/// host ships no surface manifest, so it exercises the fallback chain exactly as a manifest-less +/// CI test process does. /// public class FrameworkBuildIdentityTest { + /// The identity anchor — the toolchain assembly (#1707). + private static readonly System.Reflection.Assembly AnchorAssembly = + typeof(FrameworkBuildIdentity).Assembly; + private static readonly IReadOnlyDictionary BaselinePairs = FrameworkBuildIdentity.ContentSurfaceAssemblies .ToDictionary(n => n, n => "hash-of-" + n, StringComparer.Ordinal); @@ -66,27 +71,41 @@ public void SurfaceIdentity_IgnoresAssembliesOutsideTheCanonicalList() [Fact] public void SurfaceIdentity_UsesTheFullImplMvidForGeneratorAssemblies() { - // MeshWeaver.Graph carries the NodeType compile pipeline's emitters: a BODY-ONLY change + // MeshWeaver.Compiler IS the NodeType compile toolchain (#1707): a BODY-ONLY change // there alters the GENERATED input of every compile without any API change, so its full // implementation MVID — not its reference-assembly hash — joins the identity. - string? MvidV1(string name) => name == "MeshWeaver.Graph" ? "impl-mvid-1" : null; - string? MvidV2(string name) => name == "MeshWeaver.Graph" ? "impl-mvid-2" : null; + string? MvidV1(string name) => name == "MeshWeaver.Compiler" ? "impl-mvid-1" : null; + string? MvidV2(string name) => name == "MeshWeaver.Compiler" ? "impl-mvid-2" : null; var v1 = FrameworkBuildIdentity.ComputeSurfaceIdentity(BaselinePairs, MvidV1); var v2 = FrameworkBuildIdentity.ComputeSurfaceIdentity(BaselinePairs, MvidV2); v1.Should().NotBe(v2, "an emitter change must rebake even though the surface pairs are identical"); - // …and with the impl MVID pinned, Graph's REF-ASM hash no longer participates: its - // surface entry may change without moving the identity (the impl MVID already covers it). - var graphSurfaceChanged = new Dictionary(BaselinePairs, StringComparer.Ordinal) + // …and with the impl MVID pinned, the toolchain's REF-ASM hash no longer participates: + // its surface entry may change without moving the identity (the impl MVID already + // covers it). + var compilerSurfaceChanged = new Dictionary(BaselinePairs, StringComparer.Ordinal) { - ["MeshWeaver.Graph"] = "different-surface", + ["MeshWeaver.Compiler"] = "different-surface", }; - FrameworkBuildIdentity.ComputeSurfaceIdentity(graphSurfaceChanged, MvidV1) + FrameworkBuildIdentity.ComputeSurfaceIdentity(compilerSurfaceChanged, MvidV1) .Should().Be(v1); } + [Fact] + public void FullMvidMembership_IsTheToolchainAndTheDirectiveResolver() + { + // The membership IS the design (#1707): the toolchain assembly (everything that shapes + // generated compile input) plus MeshWeaver.NuGet (the #r directive parser/resolver — + // shapes what Roslyn is fed and which assemblies a directive adds; was a HOLE before + // #1707: surface-hashed only). Anything else here means either the extraction leaked + // input-shaping code back out, or an assembly is being over-pinned. + FrameworkBuildIdentity.FullMvidAssemblies + .OrderBy(n => n, StringComparer.Ordinal) + .Should().Equal("MeshWeaver.Compiler", "MeshWeaver.NuGet"); + } + [Fact] public void SurfaceIdentity_RecordsAbsence() { @@ -156,20 +175,24 @@ public void ProcessIdentity_UsesTheManifestWhenPresent() Directory.CreateDirectory(dir); try { - var graph = typeof(NodeTypeCompilationHelpers).Assembly; File.WriteAllText( Path.Combine(dir, FrameworkBuildIdentity.SurfaceManifestFileName), string.Join('\n', FrameworkBuildIdentity.ContentSurfaceAssemblies.Select(n => $"{n}=stub"))); - var identity = FrameworkBuildIdentity.ResolveProcessIdentity(dir, graph); + var identity = FrameworkBuildIdentity.ResolveProcessIdentity(dir, AnchorAssembly); identity.Should().StartWith("s"); - // Graph is a full-MVID exception and IS loaded, so its live MVID joins the hash — the - // rest resolve their manifest stubs. + // The full-MVID exceptions (the toolchain anchor + MeshWeaver.NuGet) are loaded in + // this test host, so their live MVIDs join the hash — the rest resolve their + // manifest stubs. + var nuget = typeof(MeshWeaver.NuGet.NuGetDirectiveParser).Assembly; var expected = FrameworkBuildIdentity.ComputeSurfaceIdentity( FrameworkBuildIdentity.ContentSurfaceAssemblies.ToDictionary(n => n, _ => "stub"), - n => n == "MeshWeaver.Graph" - ? graph.ManifestModule.ModuleVersionId.ToString("N") - : LoadedMvidOf(n)); + n => n switch + { + "MeshWeaver.Compiler" => AnchorAssembly.ManifestModule.ModuleVersionId.ToString("N"), + "MeshWeaver.NuGet" => nuget.ManifestModule.ModuleVersionId.ToString("N"), + _ => null, + }); identity.Should().Be(expected); } finally @@ -188,7 +211,6 @@ public void ProcessIdentity_DegradesToTheFallback_WhenTheManifestIsUnreadable() Assert.Skip("stages unreadability via unix file modes — POSIX hosts only (CI is linux)"); return; } - var graph = typeof(NodeTypeCompilationHelpers).Assembly; var dir = Path.Combine(Path.GetTempPath(), "mw-torn-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var manifest = Path.Combine(dir, FrameworkBuildIdentity.SurfaceManifestFileName); @@ -202,10 +224,10 @@ public void ProcessIdentity_DegradesToTheFallback_WhenTheManifestIsUnreadable() Assert.SkipWhen(!unreadable, "cannot stage an unreadable file on this host"); var (identity, warning) = - FrameworkBuildIdentity.ResolveProcessIdentityWithDiagnostics(dir, graph); + FrameworkBuildIdentity.ResolveProcessIdentityWithDiagnostics(dir, AnchorAssembly); identity.Should().Be(FrameworkBuildIdentity.Resolve( - FrameworkBuildIdentity.StampedIdentityOf(graph), - graph.ManifestModule.ModuleVersionId.ToString("N")), + FrameworkBuildIdentity.StampedIdentityOf(AnchorAssembly), + AnchorAssembly.ManifestModule.ModuleVersionId.ToString("N")), "an unreadable manifest degrades to the stamp/MVID layer"); warning.Should().NotBeNullOrEmpty("the degradation must be sayable where the identity is announced"); } @@ -219,7 +241,6 @@ public void ProcessIdentity_DegradesToTheFallback_WhenTheManifestIsUnreadable() [Fact] public void ProcessIdentity_DegradesToTheFallback_WhenTheManifestHoldsNothingUsable() { - var graph = typeof(NodeTypeCompilationHelpers).Assembly; var dir = Path.Combine(Path.GetTempPath(), "mw-empty-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try @@ -228,7 +249,7 @@ public void ProcessIdentity_DegradesToTheFallback_WhenTheManifestHoldsNothingUsa Path.Combine(dir, FrameworkBuildIdentity.SurfaceManifestFileName), "not-a-pair\n\n"); var (identity, warning) = - FrameworkBuildIdentity.ResolveProcessIdentityWithDiagnostics(dir, graph); + FrameworkBuildIdentity.ResolveProcessIdentityWithDiagnostics(dir, AnchorAssembly); identity.Should().NotStartWith("s"); warning.Should().NotBeNullOrEmpty(); } @@ -241,15 +262,14 @@ public void ProcessIdentity_DegradesToTheFallback_WhenTheManifestHoldsNothingUsa [Fact] public void ProcessIdentity_FallsBackToStampThenMvid_WithoutAManifest() { - var graph = typeof(NodeTypeCompilationHelpers).Assembly; var dir = Path.Combine(Path.GetTempPath(), "mw-nomanifest-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { - FrameworkBuildIdentity.ResolveProcessIdentity(dir, graph) + FrameworkBuildIdentity.ResolveProcessIdentity(dir, AnchorAssembly) .Should().Be(FrameworkBuildIdentity.Resolve( - FrameworkBuildIdentity.StampedIdentityOf(graph), - graph.ManifestModule.ModuleVersionId.ToString("N"))); + FrameworkBuildIdentity.StampedIdentityOf(AnchorAssembly), + AnchorAssembly.ManifestModule.ModuleVersionId.ToString("N"))); } finally { @@ -258,13 +278,15 @@ public void ProcessIdentity_FallsBackToStampThenMvid_WithoutAManifest() } [Fact] - public void FrameworkVersion_IsTheProcessIdentityOfTheGraphAssembly() + public void FrameworkVersion_IsTheProcessIdentityOfTheCompilerAssembly() { - var graph = typeof(NodeTypeCompilationHelpers).Assembly; - var expected = FrameworkBuildIdentity.ResolveProcessIdentity(AppContext.BaseDirectory, graph); + var expected = FrameworkBuildIdentity.ResolveProcessIdentity( + AppContext.BaseDirectory, AnchorAssembly); - NodeTypeCompilationHelpers.FrameworkVersion.Should().Be(expected, + FrameworkBuildIdentity.FrameworkVersion.Should().Be(expected, "there is exactly one identity resolution; every consumer flows from it"); + NodeTypeCompilationHelpers.FrameworkVersion.Should().Be(expected, + "the Graph-side shim must delegate, never re-resolve"); PrebuiltAssemblySeeder.LiveFrameworkMvid.Should().Be(expected, "the producer-facing public reading must never diverge from the gate"); } @@ -272,14 +294,16 @@ public void FrameworkVersion_IsTheProcessIdentityOfTheGraphAssembly() [Fact] public void PeRead_AgreesWithTheLoadedAssembly_OnTheFallbackLayer() { - // The PE-level reading (a producer inspecting a restored Graph.dll) covers the FALLBACK - // layer — stamp-or-MVID. In this manifest-less test host that IS the live identity, so - // the two must agree; in a manifest-shipping host the surface identity supersedes both - // and the PE reading serves as provenance only. - var graph = typeof(NodeTypeCompilationHelpers).Assembly; - graph.Location.Should().NotBeNullOrEmpty(); - - FrameworkIdentity.ReadIdentity(graph.Location) + // The PE-level reading (a producer inspecting a restored MeshWeaver.Compiler.dll — the + // identity anchor, Plugin.Build's IdentityAssembly) covers the FALLBACK layer — + // stamp-or-MVID. In this manifest-less test host that IS the live identity, so the two + // must agree; in a manifest-shipping host the surface identity supersedes both and the + // PE reading serves as provenance only. + FrameworkIdentity.IdentityAssembly.Should().Be(AnchorAssembly.GetName().Name, + "the packer reads the identity off the SAME assembly the runtime anchors on"); + AnchorAssembly.Location.Should().NotBeNullOrEmpty(); + + FrameworkIdentity.ReadIdentity(AnchorAssembly.Location) .Should().Be(PrebuiltAssemblySeeder.LiveFrameworkMvid); } diff --git a/test/MeshWeaver.Graph.Test/RecursiveCompilationTest.cs b/test/MeshWeaver.Graph.Test/RecursiveCompilationTest.cs index ee5de1257f..3cfe8b7252 100644 --- a/test/MeshWeaver.Graph.Test/RecursiveCompilationTest.cs +++ b/test/MeshWeaver.Graph.Test/RecursiveCompilationTest.cs @@ -6,6 +6,7 @@ using MeshWeaver.Mesh; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// @@ -155,7 +156,7 @@ private static ImmutableDictionary Fold( params QueryResultChange[] changes) => changes.Aggregate( ImmutableDictionary.Empty, - MeshNodeCompilationService.ApplyQueryChange); + NodeCompileShaping.ApplyQueryChange); /// /// 🚨 A query's Initial arrives in CHUNKS. Taking the first emission yields a partial source @@ -231,11 +232,11 @@ public void EmptyOrPathlessChanges_AreInert() { var seeded = Fold(Change(QueryChangeType.Initial, "Store/Plugin/Source/Localizer")); - MeshNodeCompilationService.ApplyQueryChange( + NodeCompileShaping.ApplyQueryChange( seeded, new QueryResultChange { ChangeType = QueryChangeType.Initial }) .Should().BeSameAs(seeded, "an empty chunk changes nothing"); - MeshNodeCompilationService.ApplyQueryChange(seeded, null!) + NodeCompileShaping.ApplyQueryChange(seeded, null!) .Should().BeSameAs(seeded, "a null change must not throw mid-discovery"); } } diff --git a/test/MeshWeaver.Hosting.Monolith.Test/CodeEditRecompileTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/CodeEditRecompileTest.cs index 3644045998..df05de9345 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/CodeEditRecompileTest.cs +++ b/test/MeshWeaver.Hosting.Monolith.Test/CodeEditRecompileTest.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Monolith.Test; /// @@ -751,7 +752,7 @@ public async Task SourceSnapshot_StaleEmptyCachedAnswer_MustNotOutraceDirectProb var probe = Observable.Timer(TimeSpan.FromMilliseconds(200)) .Select(_ => SourceSnapshot.Established(new[] { source })); - var snapshot = await MeshNodeCompilationService.RaceSourceSnapshot(probe, cachedFirst) + var snapshot = await NodeCompileShaping.RaceSourceSnapshot(probe, cachedFirst) .FirstAsync().Timeout(TimeSpan.FromSeconds(10)).ToTask(ct); snapshot.IsEstablished.Should().BeTrue(); @@ -776,7 +777,7 @@ public async Task SourceSnapshot_ProbeSilentAndCachedEmpty_SettlesEmptyConsensus .SelectMany(_ => Observable.Empty()); var cachedFirst = Observable.Return(SourceSnapshot.Established(Array.Empty())); - var snapshot = await MeshNodeCompilationService.RaceSourceSnapshot(probe, cachedFirst) + var snapshot = await NodeCompileShaping.RaceSourceSnapshot(probe, cachedFirst) .FirstAsync().Timeout(TimeSpan.FromSeconds(10)).ToTask(ct); snapshot.IsEstablished.Should().BeTrue( @@ -803,7 +804,7 @@ public async Task SourceSnapshot_HealthyCachedNonEmpty_WinsWithoutWaitingForProb var probe = Observable.Never(); var cachedFirst = Observable.Return(SourceSnapshot.Established(new[] { source })); - var snapshot = await MeshNodeCompilationService.RaceSourceSnapshot(probe, cachedFirst) + var snapshot = await NodeCompileShaping.RaceSourceSnapshot(probe, cachedFirst) .FirstAsync().Timeout(TimeSpan.FromSeconds(5)).ToTask(ct); snapshot.Sources.Should().ContainSingle( diff --git a/test/MeshWeaver.Hosting.Monolith.Test/SourceSnapshotEstablishmentTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/SourceSnapshotEstablishmentTest.cs index 91ce01dbf0..d7c5c34ba0 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/SourceSnapshotEstablishmentTest.cs +++ b/test/MeshWeaver.Hosting.Monolith.Test/SourceSnapshotEstablishmentTest.cs @@ -8,6 +8,7 @@ using MeshWeaver.Mesh.Services; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Monolith.Test; /// @@ -44,7 +45,7 @@ public async Task FailedSourceQuery_ReportsUnestablished_NeverItsPartialRemainde // stale-synced-query class), which has never been allowed to settle the snapshot (#612). var cachedFirst = Observable.Return(SourceSnapshot.Established(Array.Empty())); - var snapshot = await MeshNodeCompilationService.RaceSourceSnapshot(probe, cachedFirst) + var snapshot = await NodeCompileShaping.RaceSourceSnapshot(probe, cachedFirst) .FirstAsync().Timeout(TimeSpan.FromSeconds(10)).ToTask(ct); snapshot.IsEstablished.Should().BeFalse( @@ -70,7 +71,7 @@ public async Task FailedProbe_DoesNotVeto_AHealthyCachedAnswerThatArrivesLater() var cachedFirst = Observable.Timer(TimeSpan.FromMilliseconds(200)) .Select(_ => SourceSnapshot.Established(new[] { Source("Acme/Scope/Source/self") })); - var snapshot = await MeshNodeCompilationService.RaceSourceSnapshot(probe, cachedFirst) + var snapshot = await NodeCompileShaping.RaceSourceSnapshot(probe, cachedFirst) .FirstAsync().Timeout(TimeSpan.FromSeconds(10)).ToTask(ct); snapshot.IsEstablished.Should().BeTrue( diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs index cfdf2366b8..5e002fdb43 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Logging; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.PostgreSql.Test; /// @@ -111,7 +112,7 @@ await MeshService.CreateNode(new MeshNode($"Src{i:D2}", $"{typePath}/Source") /// /// The same pure fold the compiler's own probe applies - /// (MeshNodeCompilationService.ApplyQueryChange, internal to MeshWeaver.Graph so it is + /// (NodeCompileShaping.ApplyQueryChange, internal to MeshWeaver.Graph so it is /// restated rather than referenced): every change ACCUMULATES, and only a Removed takes /// anything out. /// diff --git a/test/MeshWeaver.Observability.Test/TestTraceLogFaultTest.cs b/test/MeshWeaver.Observability.Test/TestTraceLogFaultTest.cs index 6dcd8cfe75..0403efb1b4 100644 --- a/test/MeshWeaver.Observability.Test/TestTraceLogFaultTest.cs +++ b/test/MeshWeaver.Observability.Test/TestTraceLogFaultTest.cs @@ -173,7 +173,7 @@ public void AShortFault_IsRecordedVerbatim() public void ALongFault_KeepsBothTheThrowSiteAndOurOwnFramesAtTheEnd() { const string throwSite = " at Microsoft.CodeAnalysis.CSharp.Symbols.NamedTypeSymbol.get_ContainingTypeDefinition()"; - const string ourFrame = " at MeshWeaver.Graph.Configuration.MeshNodeCompilationService.EmitCompilationToDirectory(...)"; + const string ourFrame = " at MeshWeaver.Compiler.EmitPipeline.EmitCompilationToDirectory(...)"; var libraryNoise = string.Join( Environment.NewLine, Enumerable.Range(0, 400).Select(i => $" at Microsoft.Cci.MetadataWriter.Frame{i}()")); From 29f19f0c652d178b96688d42775a48c9d2935cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Mon, 17 Aug 2026 10:02:20 +0200 Subject: [PATCH 2/4] review: drop the unused using and correct the internal-to prose in the PG scale test Co-Authored-By: Claude Fable 5 --- .../BatchBakeSourceDiscoveryScaleTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs index 5e002fdb43..4847546832 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs @@ -18,7 +18,6 @@ using Microsoft.Extensions.Logging; using Xunit; -using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.PostgreSql.Test; /// @@ -112,7 +111,7 @@ await MeshService.CreateNode(new MeshNode($"Src{i:D2}", $"{typePath}/Source") /// /// The same pure fold the compiler's own probe applies - /// (NodeCompileShaping.ApplyQueryChange, internal to MeshWeaver.Graph so it is + /// (NodeCompileShaping.ApplyQueryChange, internal to MeshWeaver.Compiler so it is /// restated rather than referenced): every change ACCUMULATES, and only a Removed takes /// anything out. /// From 3ad9bc29c56b07f1af657a01d092c9bc9da68f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Mon, 17 Aug 2026 10:24:40 +0200 Subject: [PATCH 3/4] ci: re-point the Roslyn reference-holder allowlist at the moved cache (CompileReferences._default) The #890 debt field moved homes in the extraction (MeshNodeCompilationService ._references -> CompileReferences); the guard failed on the auto-property's compiler-generated backing field. Explicit _default field so the allowlist names something readable; same debt, same issue numbers (#890/#1438). Co-Authored-By: Claude Fable 5 --- src/MeshWeaver.Compiler/CompileReferences.cs | 12 ++++++++++-- .../NoStaticCollectionsTest.cs | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/MeshWeaver.Compiler/CompileReferences.cs b/src/MeshWeaver.Compiler/CompileReferences.cs index 055ca9a4ea..2d1a5c997b 100644 --- a/src/MeshWeaver.Compiler/CompileReferences.cs +++ b/src/MeshWeaver.Compiler/CompileReferences.cs @@ -15,9 +15,17 @@ public static class CompileReferences /// /// The process-wide default reference list — TPA assemblies plus a few well-known additions, /// built once at type load (eager static init: a plain field read on every compile, no Lazy - /// dispatch, no synchronization; an immutable constant lookup in the NoStaticState.md sense). + /// dispatch, no synchronization). 🚨 NOT a constant lookup, whatever the collection type says: + /// a PortableExecutableReference owns lazily mmap'd IDisposable metadata and Roslyn + /// caches symbol tables against the instance — this is the process-wide CACHE tracked by #890, + /// allowlisted by name in NoStaticCollectionsTest.AllowedRoslynReferenceHolders + /// (migration held in #1438 pending the emit canary's verdict). An explicit field, not an + /// auto-property, so the allowlist names _default rather than a compiler-generated + /// backing field. /// - public static IReadOnlyList Default { get; } = GetDefaultReferences(); + public static IReadOnlyList Default => _default; + + private static readonly IReadOnlyList _default = GetDefaultReferences(); /// /// Builds the process-wide MetadataReference list — TPA assemblies plus a few diff --git a/test/MeshWeaver.PathResolution.Test/NoStaticCollectionsTest.cs b/test/MeshWeaver.PathResolution.Test/NoStaticCollectionsTest.cs index 7c8f8b84e7..8dba43107b 100644 --- a/test/MeshWeaver.PathResolution.Test/NoStaticCollectionsTest.cs +++ b/test/MeshWeaver.PathResolution.Test/NoStaticCollectionsTest.cs @@ -185,7 +185,9 @@ public void No_static_mutable_collection_fields_outside_allowlist() // The entry stays until the canary reads canary=REFERENCES; #1438 removes both the // field and this line. Listing it is the point: an allowlist entry is a visible debt, // which is precisely what the field lacked when two reviews called it a constant. - ["MeshWeaver.Graph.Configuration.MeshNodeCompilationService._references"] = + // (Home moved in #1707: MeshNodeCompilationService._references → the toolchain's + // CompileReferences._default — same field, same debt, same issue numbers.) + ["MeshWeaver.Compiler.CompileReferences._default"] = "CACHE: process-wide Roslyn reference set — tracked by #890, migration held in #1438 pending the canary verdict", }; From 9d55ac5df648e95daaabe425a239a4021e9b2672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Mon, 17 Aug 2026 10:48:57 +0200 Subject: [PATCH 4/4] refactor(#1712): unify the toolchain under the MeshWeaver.Compiler namespace; the identity pins the toolchain's computed dependency closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Namespace unification (maintainer): everything in the MeshWeaver.Compiler assembly now lives in the MeshWeaver.Compiler namespace; the type-forwards are dropped. Swept before the break: no in-mesh source (repo content trees) and no live-portal content (memex/atioz/systemorph nodeType:Code searches) references any moved type by its old MeshWeaver.Graph.Configuration name; the namespace itself keeps existing in Graph for the actor-side types, so in-mesh 'using MeshWeaver.Graph.Configuration;' lines stay valid. Closure identity (maintainer: 'must track dependencies of the compiler itself — if any have changed, need to recompile'): FullMvidAssemblies is now COMPUTED — the toolchain roots (MeshWeaver.Compiler, MeshWeaver.NuGet) plus their transitive MeshWeaver dependency closure, derived from the shipped assemblies' AssemblyRef metadata so every host computes the identical set and a new toolchain dependency can never be silently outside the identity. A body-only change in a closure member (Mesh.Contract, ContentCollections, …) now recompiles content; under deterministic builds a platform update that touches none of the closure moves nothing. Docs, README, and the identity tests updated (closure membership pin + pure closure-walk test + mirrored ImplMvidOf resolution in the manifest test). Full solution Release -warnaserror green; Graph.Test 1222/1222. Co-Authored-By: Claude Fable 5 --- memex/Memex.Portal.Monolith/Program.cs | 1 + .../Memex.Portal.Distributed/Program.cs | 1 + .../AssemblyStoreExtensions.cs | 2 +- src/MeshWeaver.Compiler/CodeConventions.cs | 2 +- src/MeshWeaver.Compiler/CodeQueryResolver.cs | 3 +- .../CompilationException.cs | 2 +- src/MeshWeaver.Compiler/CompilationInputs.cs | 2 +- src/MeshWeaver.Compiler/CompileDiagnostics.cs | 1 - .../DynamicMeshNodeAttributeGenerator.cs | 2 +- src/MeshWeaver.Compiler/EmitPipeline.cs | 1 - src/MeshWeaver.Compiler/EmittedArtifact.cs | 2 +- .../FileSystemAssemblyStore.cs | 2 +- .../FrameworkBuildIdentity.cs | 78 +++++++++++++++++- src/MeshWeaver.Compiler/GeneratorPipeline.cs | 1 - src/MeshWeaver.Compiler/NodeCompileShaping.cs | 1 - src/MeshWeaver.Compiler/README.md | 9 ++- .../SourceGeneratorLoader.cs | 4 +- src/MeshWeaver.Compiler/SourceSnapshot.cs | 2 +- .../SpeculativeCompilation.cs | 2 +- .../Data/Architecture/CiContentBake.md | 13 ++- .../Data/Architecture/NodeTypeCompilation.md | 11 ++- .../Data/Architecture/ReleaseProcess.md | 5 +- ...-08-17-the-compiler-is-its-own-assembly.md | 7 +- .../Configuration/AssemblyCacheRetention.cs | 1 + .../GraphConfigurationExtensions.cs | 1 + .../Configuration/MeshNodeLanguageService.cs | 1 + .../Configuration/NodeSources.cs | 1 + .../NodeTypeCompileParkRegistry.cs | 1 + .../Configuration/NodeTypeDefinition.cs | 1 + .../Configuration/NodeTypeDependencyGraph.cs | 1 + .../Configuration/PrebuiltAssemblySeeder.cs | 1 + .../Configuration/RecompileClosure.cs | 1 + .../Configuration/ScriptCompilationService.cs | 1 + src/MeshWeaver.Graph/MeshDataSource.cs | 1 + src/MeshWeaver.Graph/MeshWeaver.Graph.csproj | 5 +- src/MeshWeaver.Graph/NodeTypeLayoutAreas.cs | 1 + .../Properties/CompilerTypeForwards.cs | 13 --- .../MonolithMeshTestBase.cs | 1 + .../AssemblyCacheRetentionHostedService.cs | 1 + .../DynamicTypePreWarmer.cs | 5 +- .../DynamicTypePreWarmerHostedService.cs | 5 +- .../Persistence/PersistenceExtensions.cs | 5 +- .../FrameworkIdentity.cs | 1 + .../SkeletonGenerator.cs | 1 + .../ThreadAgentIntegrationTest.cs | 1 + .../NodeTypeProgressAreaTest.cs | 1 + .../CodeQueryResolverTest.cs | 1 + .../DynamicMeshNodeAttributeGeneratorTest.cs | 1 + .../FileSystemAssemblyStoreTest.cs | 1 + .../FrameworkBuildIdentityTest.cs | 79 ++++++++++++++----- .../NodeTypeCompileStampTest.cs | 1 + .../FrameworkStaleProactiveRebuildTest.cs | 1 + .../OrleansCompileActivityAccessTest.cs | 1 + .../OrleansDynamicCompilationTest.cs | 1 + .../OrleansPostgresFanOutTest.cs | 1 + .../OrleansPostgresLifecycleTest.cs | 1 + .../OrleansPostgresPathResolutionTest.cs | 1 + .../OrleansSlideNavigationPostgresTest.cs | 1 + .../OrleansTestBase.cs | 1 + .../OrleansThreadColdLoadHangPostgresTest.cs | 1 + .../BatchBakeSourceDiscoveryScaleTests.cs | 1 + .../MemexInstanceBootstrapTest.cs | 1 + .../SqliteMonolithMeshTest.cs | 1 + .../SqliteRawBootstrapTest.cs | 1 + .../FileSystemAssemblyStoreAtomicityTest.cs | 1 + .../PluginGateRunner.cs | 1 + tools/MeshWeaver.PluginTester/Program.cs | 5 +- 67 files changed, 230 insertions(+), 78 deletions(-) delete mode 100644 src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs diff --git a/memex/Memex.Portal.Monolith/Program.cs b/memex/Memex.Portal.Monolith/Program.cs index e6e4590586..8e4777b9cc 100644 --- a/memex/Memex.Portal.Monolith/Program.cs +++ b/memex/Memex.Portal.Monolith/Program.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; +using MeshWeaver.Compiler; var builder = WebApplication.CreateBuilder(args); // Configure Memex services (pattern from MeshWeaver.Portal's ConfigureWebPortalServices) diff --git a/memex/aspire/Memex.Portal.Distributed/Program.cs b/memex/aspire/Memex.Portal.Distributed/Program.cs index 28e1b9f732..2fa2bdc1c6 100644 --- a/memex/aspire/Memex.Portal.Distributed/Program.cs +++ b/memex/aspire/Memex.Portal.Distributed/Program.cs @@ -17,6 +17,7 @@ using Orleans.Configuration; using Orleans.Hosting; +using MeshWeaver.Compiler; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.Services.AddServerSideBlazor().AddCircuitOptions(o => o.DetailedErrors = true); diff --git a/src/MeshWeaver.Compiler/AssemblyStoreExtensions.cs b/src/MeshWeaver.Compiler/AssemblyStoreExtensions.cs index 14c4e0a0ec..ac4b10b36a 100644 --- a/src/MeshWeaver.Compiler/AssemblyStoreExtensions.cs +++ b/src/MeshWeaver.Compiler/AssemblyStoreExtensions.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// DI helpers for registering implementations. Registration diff --git a/src/MeshWeaver.Compiler/CodeConventions.cs b/src/MeshWeaver.Compiler/CodeConventions.cs index ab94bac010..27931c989f 100644 --- a/src/MeshWeaver.Compiler/CodeConventions.cs +++ b/src/MeshWeaver.Compiler/CodeConventions.cs @@ -2,7 +2,7 @@ namespace MeshWeaver.Compiler; /// /// The Code-node naming conventions the compile toolchain keys on. Defined here — below the -/// graph — because 's default +/// graph — because 's default /// queries and path matching are part of the compile toolchain's identity boundary; /// MeshWeaver.Graph's CodeNodeType aliases these same constants for the mesh-side /// registration, so the two can never drift. diff --git a/src/MeshWeaver.Compiler/CodeQueryResolver.cs b/src/MeshWeaver.Compiler/CodeQueryResolver.cs index c4e2de9bc6..db46855908 100644 --- a/src/MeshWeaver.Compiler/CodeQueryResolver.cs +++ b/src/MeshWeaver.Compiler/CodeQueryResolver.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; using System.Text.RegularExpressions; -using MeshWeaver.Compiler; using MeshWeaver.Mesh; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// Shared query-expansion helpers used by both the compiler (to find which Code diff --git a/src/MeshWeaver.Compiler/CompilationException.cs b/src/MeshWeaver.Compiler/CompilationException.cs index b1f19d84a5..cfa22e4de5 100644 --- a/src/MeshWeaver.Compiler/CompilationException.cs +++ b/src/MeshWeaver.Compiler/CompilationException.cs @@ -1,7 +1,7 @@ // Moved verbatim from MeshNodeCompilationService.cs in MeshWeaver.Graph (#1707). The namespace // stays MeshWeaver.Graph.Configuration and MeshWeaver.Graph type-forwards it, so modules compiled // against earlier releases keep binding. -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// Exception thrown when compilation fails. diff --git a/src/MeshWeaver.Compiler/CompilationInputs.cs b/src/MeshWeaver.Compiler/CompilationInputs.cs index 6a1a41c8a5..39581b7186 100644 --- a/src/MeshWeaver.Compiler/CompilationInputs.cs +++ b/src/MeshWeaver.Compiler/CompilationInputs.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// Everything language services (hover, completion, diagnostics, speculative compile) need diff --git a/src/MeshWeaver.Compiler/CompileDiagnostics.cs b/src/MeshWeaver.Compiler/CompileDiagnostics.cs index a797e49ad6..f73afea88e 100644 --- a/src/MeshWeaver.Compiler/CompileDiagnostics.cs +++ b/src/MeshWeaver.Compiler/CompileDiagnostics.cs @@ -1,4 +1,3 @@ -using MeshWeaver.Graph.Configuration; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Lsp = MeshWeaver.Mesh.Services.LanguageServer; diff --git a/src/MeshWeaver.Compiler/DynamicMeshNodeAttributeGenerator.cs b/src/MeshWeaver.Compiler/DynamicMeshNodeAttributeGenerator.cs index 26834c9916..e444bf832e 100644 --- a/src/MeshWeaver.Compiler/DynamicMeshNodeAttributeGenerator.cs +++ b/src/MeshWeaver.Compiler/DynamicMeshNodeAttributeGenerator.cs @@ -3,7 +3,7 @@ using MeshWeaver.ContentCollections; using MeshWeaver.Mesh; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// Generates C# source code for dynamic MeshNodeProviderAttribute classes. diff --git a/src/MeshWeaver.Compiler/EmitPipeline.cs b/src/MeshWeaver.Compiler/EmitPipeline.cs index facb545ee6..ab9f7bb1a0 100644 --- a/src/MeshWeaver.Compiler/EmitPipeline.cs +++ b/src/MeshWeaver.Compiler/EmitPipeline.cs @@ -1,4 +1,3 @@ -using MeshWeaver.Graph.Configuration; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; diff --git a/src/MeshWeaver.Compiler/EmittedArtifact.cs b/src/MeshWeaver.Compiler/EmittedArtifact.cs index ad109508bc..9cad566d1e 100644 --- a/src/MeshWeaver.Compiler/EmittedArtifact.cs +++ b/src/MeshWeaver.Compiler/EmittedArtifact.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// What an emit produced, and the fingerprint that lets the publisher prove the file on disk still diff --git a/src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs b/src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs index 40895954b8..c4cee98b7e 100644 --- a/src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs +++ b/src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs @@ -4,7 +4,7 @@ using MeshWeaver.Mesh.Services; using Microsoft.Extensions.Logging; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// Filesystem-backed . Used by the monolith portal and diff --git a/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs b/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs index 199e5eb822..1049cbf085 100644 --- a/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs +++ b/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs @@ -4,7 +4,7 @@ using System.Security.Cryptography; using System.Text; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// THE framework build identity every compiled NodeType release is pinned to (issue #1660 WS3) — @@ -147,11 +147,85 @@ public static class FrameworkBuildIdentity /// the resolver decides which assemblies a directive adds to the reference set. A body-only /// change to either alters compile inputs with no API change — before #1707 this was a HOLE: /// the assembly was surface-hashed only. + /// …and each root's MeshWeaver DEPENDENCY CLOSURE (maintainer, + /// 2026-08-17: "must track dependencies of compiler itself — if any have changed, need to + /// recompile"): the toolchain CALLS into what it links (Mesh.Contract's data types, + /// ContentCollections' config shapes, the NuGet resolver), so a body-only change in a closure + /// member can change what the toolchain emits without any API change. The set is COMPUTED + /// from the roots' AssemblyRef metadata — fixed bytes of the shipped assemblies, so every + /// host derives the identical set — rather than hand-listed, so a new toolchain dependency + /// can never be silently outside the identity. Under deterministic builds this still keeps + /// "rebuild only when we need to": a platform update that provably touches none of the + /// closure members' bytes moves nothing. /// /// - public static readonly ImmutableArray FullMvidAssemblies = + public static ImmutableArray FullMvidAssemblies => _fullMvidAssemblies.Value; + + /// The toolchain roots the full-MVID closure is computed from. + internal static readonly ImmutableArray ToolchainRoots = ["MeshWeaver.Compiler", "MeshWeaver.NuGet"]; + private static readonly Lazy> _fullMvidAssemblies = new(() => + ComputeToolchainClosure(ToolchainRoots, ReferencedMeshWeaverAssembliesOf)); + + /// + /// The MeshWeaver-only transitive closure of over + /// — pure and injectable so the closure rule is unit-testable + /// against a staged reference graph. Sorted ordinal; roots always included. + /// + internal static ImmutableArray ComputeToolchainClosure( + IEnumerable roots, + Func> referencedOf) + { + var seen = new SortedSet(StringComparer.Ordinal); + var stack = new Stack(roots); + while (stack.Count > 0) + { + var name = stack.Pop(); + if (!seen.Add(name)) + continue; + foreach (var reference in referencedOf(name)) + if (reference.StartsWith("MeshWeaver.", StringComparison.Ordinal)) + stack.Push(reference); + } + return [.. seen]; + } + + /// + /// An assembly's MeshWeaver.* AssemblyRef simple names: from the loaded assembly when + /// present, else a metadata-only read of the DLL beside the entry assembly, else empty + /// (the name still joins the closure via its root/parent; its MVID then resolves + /// , which is itself identity-relevant). + /// + private static IEnumerable ReferencedMeshWeaverAssembliesOf(string simpleName) + { + var loaded = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => !a.IsDynamic + && string.Equals(a.GetName().Name, simpleName, StringComparison.Ordinal)); + if (loaded is not null) + return loaded.GetReferencedAssemblies() + .Select(n => n.Name) + .Where(n => !string.IsNullOrEmpty(n))!; + + var candidate = Path.Combine(AppContext.BaseDirectory, simpleName + ".dll"); + if (!File.Exists(candidate)) + return []; + try + { + using var stream = File.OpenRead(candidate); + using var pe = new System.Reflection.PortableExecutable.PEReader(stream); + var md = pe.GetMetadataReader(); + return md.AssemblyReferences + .Select(h => md.GetString(md.GetAssemblyReference(h).Name)) + .Where(n => !string.IsNullOrEmpty(n)) + .ToList(); + } + catch + { + return []; + } + } + /// Marker recorded for a canonical assembly absent from the manifest/process — the /// absence itself is part of the identity (two hosts with different presence sets must never /// share one). diff --git a/src/MeshWeaver.Compiler/GeneratorPipeline.cs b/src/MeshWeaver.Compiler/GeneratorPipeline.cs index 8ae4760a1a..6cc8b243bc 100644 --- a/src/MeshWeaver.Compiler/GeneratorPipeline.cs +++ b/src/MeshWeaver.Compiler/GeneratorPipeline.cs @@ -1,4 +1,3 @@ -using MeshWeaver.Graph.Configuration; using MeshWeaver.NuGet; using Microsoft.CodeAnalysis.CSharp; using Microsoft.Extensions.Logging; diff --git a/src/MeshWeaver.Compiler/NodeCompileShaping.cs b/src/MeshWeaver.Compiler/NodeCompileShaping.cs index c775bcd145..a8bacbb272 100644 --- a/src/MeshWeaver.Compiler/NodeCompileShaping.cs +++ b/src/MeshWeaver.Compiler/NodeCompileShaping.cs @@ -1,7 +1,6 @@ using System.Collections.Immutable; using System.Reactive.Linq; using System.Text.RegularExpressions; -using MeshWeaver.Graph.Configuration; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Services; using Microsoft.Extensions.Logging; diff --git a/src/MeshWeaver.Compiler/README.md b/src/MeshWeaver.Compiler/README.md index 7a3e732158..fb6d46cda0 100644 --- a/src/MeshWeaver.Compiler/README.md +++ b/src/MeshWeaver.Compiler/README.md @@ -23,6 +23,9 @@ The mesh-actor half of the pipeline — source discovery against the live mesh, impersonation, IoPool scheduling, compile status write-backs — stays in `MeshWeaver.Graph` (`MeshNodeCompilationService`, `NodeTypeCompilationHelpers`) and orchestrates this toolchain. -Types moved from `MeshWeaver.Graph` keep their original `MeshWeaver.Graph.Configuration` -namespace and are type-forwarded from `MeshWeaver.Graph`, so modules compiled against earlier -releases keep binding. New toolchain code uses the `MeshWeaver.Compiler` namespace. +Everything in this assembly lives in the `MeshWeaver.Compiler` namespace (unified in #1712 — +swept clean: no in-mesh source and no live-portal content references the moved types by their old +`MeshWeaver.Graph.Configuration` names, so no forwards are carried). The framework identity's +full-MVID rule covers this assembly plus its computed MeshWeaver dependency closure — the +toolchain calls into what it links, so a body-only change in a closure member must recompile +content too. diff --git a/src/MeshWeaver.Compiler/SourceGeneratorLoader.cs b/src/MeshWeaver.Compiler/SourceGeneratorLoader.cs index cfbfcc61a9..6f777e5f50 100644 --- a/src/MeshWeaver.Compiler/SourceGeneratorLoader.cs +++ b/src/MeshWeaver.Compiler/SourceGeneratorLoader.cs @@ -3,12 +3,12 @@ using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// Discovers Roslyn [Generator] source generators from the assemblies a node pulls in via /// #r "nuget:...", so generators are modular and travel with the node Source instead of being -/// baked into the low-level project. A generator referenced from Graph +/// baked into the low-level MeshWeaver.Graph project. A generator referenced from Graph /// is a Roslyn analyzer that propagates to every downstream project and bloats every build /// (project_graph_generator_build_bloat); instead the compile pipeline injects /// #r "nuget:MeshWeaver.BusinessRules.Generator" for nodes that declare an IScope<,>, diff --git a/src/MeshWeaver.Compiler/SourceSnapshot.cs b/src/MeshWeaver.Compiler/SourceSnapshot.cs index e020287c06..a5e84b4899 100644 --- a/src/MeshWeaver.Compiler/SourceSnapshot.cs +++ b/src/MeshWeaver.Compiler/SourceSnapshot.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using MeshWeaver.Mesh; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// A compile's point-in-time source set TOGETHER WITH whether that set was actually diff --git a/src/MeshWeaver.Compiler/SpeculativeCompilation.cs b/src/MeshWeaver.Compiler/SpeculativeCompilation.cs index 9d511cde5a..37c73ae719 100644 --- a/src/MeshWeaver.Compiler/SpeculativeCompilation.cs +++ b/src/MeshWeaver.Compiler/SpeculativeCompilation.cs @@ -7,7 +7,7 @@ using RoslynDiagnosticSeverity = Microsoft.CodeAnalysis.DiagnosticSeverity; using LspDiagnosticSeverity = MeshWeaver.Mesh.Services.LanguageServer.DiagnosticSeverity; -namespace MeshWeaver.Graph.Configuration; +namespace MeshWeaver.Compiler; /// /// Builds a from a NodeType's diff --git a/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md b/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md index acdd070c3f..1f196de58a 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md @@ -52,10 +52,15 @@ hosts that matter here — the bake host and the portals, which both ship a reference, the SHA-256 of its *reference assembly* (the compiler's own definition of the API surface — byte-stable under body-only and private-member edits, changed by any surface change), hashed over the canonical content-surface set, with the generated-input-shaping exceptions -contributing their full implementation MVID: `MeshWeaver.Compiler` (THE compile toolchain since -#1707 — skeleton generation, source-query resolution, `@@`-include shaping, aggregation, options, -generator execution, emit) and `MeshWeaver.NuGet` (the `#r "nuget:"` parser/resolver — what Roslyn -is fed and which assemblies a directive adds). Before #1707 the toolchain lived inside +contributing their full implementation MVID: the toolchain roots `MeshWeaver.Compiler` (THE +compile toolchain since #1707 — skeleton generation, source-query resolution, `@@`-include +shaping, aggregation, options, generator execution, emit; namespace `MeshWeaver.Compiler`) and +`MeshWeaver.NuGet` (the `#r "nuget:"` parser/resolver — what Roslyn is fed and which assemblies a +directive adds), **plus their computed MeshWeaver dependency closure** (Mesh.Contract, +ContentCollections, transitives — the toolchain CALLS into what it links, so a body-only change +in a closure member can change what it emits; the set is derived from the shipped assemblies' +AssemblyRef metadata, so every host computes the identical set and a new toolchain dependency can +never be silently outside the identity). Before #1707 the toolchain lived inside `MeshWeaver.Graph` and pinned ALL of Graph — the highest-churn assembly — so nearly every merge rebaked the world; the extraction is what makes "rebuild only when we need to" hold in practice. diff --git a/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md b/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md index 50f0f0047e..d33af24e2b 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md @@ -400,10 +400,13 @@ resolved once per process (`FrameworkBuildIdentity`, (the compiler's own definition of the API surface — byte-stable under body-only and private-member edits, changed by any surface change), hashed over the canonical content-surface set, with the generated-input-shaping - exceptions contributing their full implementation MVID: `MeshWeaver.Compiler` - (THE compile toolchain since #1707 — skeleton generation, source-query - resolution, include shaping, aggregation, options, generator execution, emit) - and `MeshWeaver.NuGet` (the `#r "nuget:"` parser/resolver). + exceptions contributing their full implementation MVID: the toolchain roots + `MeshWeaver.Compiler` (THE compile toolchain since #1707 — skeleton + generation, source-query resolution, include shaping, aggregation, options, + generator execution, emit) and `MeshWeaver.NuGet` (the `#r "nuget:"` + parser/resolver), plus their computed MeshWeaver dependency closure — the + toolchain calls into what it links, so a body-only change in a closure member + (Mesh.Contract, ContentCollections, …) must recompile content too. "Rebuild only when we need to": an internal-only framework release keeps the identity, so every cached and CI-published build stays valid; a breaking surface change mints a new one, and the CI bake for diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md b/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md index 9d8c1bf40c..ea4ab226eb 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/ReleaseProcess.md @@ -86,8 +86,9 @@ The `PublicRelease` flag picks the channel (think **CI** vs **CD**): `NodeTypeCompilationHelpers.FrameworkVersion` (`FrameworkBuildIdentity`): hosts that ship a `meshweaver-surface.manifest` (portals, the CI bake host) resolve the **API-surface hash** (`s` — stable across internal-only changes, moved by - breaking surface changes and by any change to the toolchain full-MVID exceptions, - `MeshWeaver.Compiler` / `MeshWeaver.NuGet`, #1707); manifest-less CI + breaking surface changes and by any change to the toolchain full-MVID set: + `MeshWeaver.Compiler` / `MeshWeaver.NuGet` plus their computed MeshWeaver + dependency closure, #1707); manifest-less CI processes fall back to the **stamped commit identity** (`g`, `AssemblyMetadata("MeshWeaverFrameworkIdentity")`, kept as provenance everywhere); manifest-less local builds fall back to the **identity anchor's MVID** diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md index 3a37f4c6a5..238469e4bb 100644 --- a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-17-the-compiler-is-its-own-assembly.md @@ -16,9 +16,10 @@ compilation options, source-generator execution, and the emit itself — now liv Why it matters: a compiled type stays valid exactly as long as nothing it was built from changed. The identity that decides this used to pin the whole graph assembly — the busiest code in the platform — so nearly any platform change forced every compiled type to rebuild. It now pins the -small toolchain assembly (plus the `#r` directive resolver, whose effect on compiles was -previously invisible to the identity), so rebuilds happen when the toolchain or a public surface -actually changes — and only then. +small toolchain assembly together with everything the toolchain itself depends on (including the +`#r` directive resolver, whose effect on compiles was previously invisible to the identity), so +rebuilds happen when the toolchain, one of its dependencies, or a public surface actually +changes — and only then. One pipeline, every path: the portal compiling an edited type on demand, the startup batch bake, and the CI bake that ships prebuilt assemblies all run the same toolchain code, so what CI built diff --git a/src/MeshWeaver.Graph/Configuration/AssemblyCacheRetention.cs b/src/MeshWeaver.Graph/Configuration/AssemblyCacheRetention.cs index 5ef9669b86..343e154178 100644 --- a/src/MeshWeaver.Graph/Configuration/AssemblyCacheRetention.cs +++ b/src/MeshWeaver.Graph/Configuration/AssemblyCacheRetention.cs @@ -5,6 +5,7 @@ using MeshWeaver.Mesh.Threading; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs b/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs index ff49e223ab..20ff64cdd5 100644 --- a/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs +++ b/src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs b/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs index c451e77adb..01a2897696 100644 --- a/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs +++ b/src/MeshWeaver.Graph/Configuration/MeshNodeLanguageService.cs @@ -19,6 +19,7 @@ using RoslynDiagnosticSeverity = Microsoft.CodeAnalysis.DiagnosticSeverity; using LspDiagnosticSeverity = MeshWeaver.Mesh.Services.LanguageServer.DiagnosticSeverity; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/NodeSources.cs b/src/MeshWeaver.Graph/Configuration/NodeSources.cs index d2afa5f7f7..b4cecf837e 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeSources.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeSources.cs @@ -2,6 +2,7 @@ using MeshWeaver.Data; using MeshWeaver.Mesh; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeCompileParkRegistry.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeCompileParkRegistry.cs index 912ff6c36f..7d62f5460c 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeCompileParkRegistry.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeCompileParkRegistry.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs index 0b5c374903..32feb29ee6 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs @@ -5,6 +5,7 @@ using MeshWeaver.Mesh.Services; using MeshWeaver.Mesh.Services.LanguageServer; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/NodeTypeDependencyGraph.cs b/src/MeshWeaver.Graph/Configuration/NodeTypeDependencyGraph.cs index 90a1f42679..1f030ea1b4 100644 --- a/src/MeshWeaver.Graph/Configuration/NodeTypeDependencyGraph.cs +++ b/src/MeshWeaver.Graph/Configuration/NodeTypeDependencyGraph.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Linq; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs b/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs index e12d44210a..0f0a480adf 100644 --- a/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs +++ b/src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/RecompileClosure.cs b/src/MeshWeaver.Graph/Configuration/RecompileClosure.cs index c7941a4bdf..00220d8c32 100644 --- a/src/MeshWeaver.Graph/Configuration/RecompileClosure.cs +++ b/src/MeshWeaver.Graph/Configuration/RecompileClosure.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Linq; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/Configuration/ScriptCompilationService.cs b/src/MeshWeaver.Graph/Configuration/ScriptCompilationService.cs index 46ab18d444..262feace5c 100644 --- a/src/MeshWeaver.Graph/Configuration/ScriptCompilationService.cs +++ b/src/MeshWeaver.Graph/Configuration/ScriptCompilationService.cs @@ -15,6 +15,7 @@ using MeshWeaver.Messaging; using MeshWeaver.NuGet; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Configuration; /// diff --git a/src/MeshWeaver.Graph/MeshDataSource.cs b/src/MeshWeaver.Graph/MeshDataSource.cs index aadd04e4f1..1a0be003f4 100644 --- a/src/MeshWeaver.Graph/MeshDataSource.cs +++ b/src/MeshWeaver.Graph/MeshDataSource.cs @@ -20,6 +20,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph; /// diff --git a/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj b/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj index 4c4d567271..d1c6116861 100644 --- a/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj +++ b/src/MeshWeaver.Graph/MeshWeaver.Graph.csproj @@ -45,9 +45,8 @@ + framework identity) lives in MeshWeaver.Compiler (#1707), namespace MeshWeaver.Compiler; + this project keeps the mesh-actor half (discovery, impersonation, status write-backs). --> diff --git a/src/MeshWeaver.Graph/NodeTypeLayoutAreas.cs b/src/MeshWeaver.Graph/NodeTypeLayoutAreas.cs index 072bfc7365..1bc0b17350 100644 --- a/src/MeshWeaver.Graph/NodeTypeLayoutAreas.cs +++ b/src/MeshWeaver.Graph/NodeTypeLayoutAreas.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph; /// diff --git a/src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs b/src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs deleted file mode 100644 index 27f4ea7f36..0000000000 --- a/src/MeshWeaver.Graph/Properties/CompilerTypeForwards.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Runtime.CompilerServices; - -// The NodeType compile toolchain moved to MeshWeaver.Compiler (#1707). The moved public types -// keep their MeshWeaver.Graph.Configuration namespace and are forwarded here so BINARY consumers -// compiled against earlier releases — installed modules pin their compile-time assembly -// references — keep resolving them through MeshWeaver.Graph. -[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.CodeQueryResolver))] -[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.CodeQueryGroup))] -[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.CompilationException))] -[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.FrameworkBuildIdentity))] -[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.FileSystemAssemblyStore))] -[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.AssemblyStoreExtensions))] -[assembly: TypeForwardedTo(typeof(MeshWeaver.Graph.Configuration.SourceDiscoveryUnavailableException))] diff --git a/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs b/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs index 51d8a1ac49..a3c45ea118 100644 --- a/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs +++ b/src/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs @@ -21,6 +21,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Monolith.TestBase; /// diff --git a/src/MeshWeaver.Hosting/AssemblyCacheRetentionHostedService.cs b/src/MeshWeaver.Hosting/AssemblyCacheRetentionHostedService.cs index 5d512dd8e8..b0344b1c87 100644 --- a/src/MeshWeaver.Hosting/AssemblyCacheRetentionHostedService.cs +++ b/src/MeshWeaver.Hosting/AssemblyCacheRetentionHostedService.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting; /// diff --git a/src/MeshWeaver.Hosting/DynamicTypePreWarmer.cs b/src/MeshWeaver.Hosting/DynamicTypePreWarmer.cs index 189e5d40e3..4a91174ebf 100644 --- a/src/MeshWeaver.Hosting/DynamicTypePreWarmer.cs +++ b/src/MeshWeaver.Hosting/DynamicTypePreWarmer.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting; /// Terminal outcome of pre-warming one dynamic NodeType's hub. @@ -870,7 +871,7 @@ private static bool IsFreshSuccess(DateTimeOffset? succeeded, DateTimeOffset? ba /// that removal is the fix for issue #1391. An empty /// does not mean "configuration-only" — it means "uses the DEFAULT queries" /// (namespace:{path}/Source scope:subtree, see - /// ), which is how very + /// ), which is how very /// nearly every NodeType in a real mesh is authored. Requiring declared queries therefore made /// unreachable for almost the entire population: a type /// whose Source/ subtree had been DELETED was compiled against nothing, its @@ -878,7 +879,7 @@ private static bool IsFreshSuccess(DateTimeOffset? succeeded, DateTimeOffset? ba /// verdict, and a node that no longer exists held portal readiness hostage on every pod boot. /// That is exactly what Edu/Course was doing to memex. /// - /// This restores the three-outcome contract + /// This restores the three-outcome contract /// already documents — an established-but-EMPTY snapshot is a content fact "(the sources were /// deleted, or the type is configuration-only)" and classifies as NoSources — from which /// the extra conjunct had silently drifted. A genuinely configuration-only type is folded in on diff --git a/src/MeshWeaver.Hosting/DynamicTypePreWarmerHostedService.cs b/src/MeshWeaver.Hosting/DynamicTypePreWarmerHostedService.cs index b0483246ce..608d0f3c6a 100644 --- a/src/MeshWeaver.Hosting/DynamicTypePreWarmerHostedService.cs +++ b/src/MeshWeaver.Hosting/DynamicTypePreWarmerHostedService.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting; /// @@ -291,8 +292,8 @@ private void KickWarmup() "DynamicTypePreWarmer: starting background warm-up of dynamic NodeType hubs " + "(framework identity {FrameworkIdentity}, build {Provenance})", Graph.Configuration.PrebuiltAssemblySeeder.LiveFrameworkMvid, - Graph.Configuration.FrameworkBuildIdentity.StampedIdentityOf( - typeof(Graph.Configuration.FrameworkBuildIdentity).Assembly) ?? "(unstamped)"); + MeshWeaver.Compiler.FrameworkBuildIdentity.StampedIdentityOf( + typeof(MeshWeaver.Compiler.FrameworkBuildIdentity).Assembly) ?? "(unstamped)"); // A degraded identity resolution (torn/unusable surface manifest → stamp/MVID fallback) // is safe — conservative key, everything rebakes under it — but it silently costs the // whole CI-bake benefit, so it must be SAID where the identity is announced. diff --git a/src/MeshWeaver.Hosting/Persistence/PersistenceExtensions.cs b/src/MeshWeaver.Hosting/Persistence/PersistenceExtensions.cs index 696fe96fda..7ac3b0c0b7 100644 --- a/src/MeshWeaver.Hosting/Persistence/PersistenceExtensions.cs +++ b/src/MeshWeaver.Hosting/Persistence/PersistenceExtensions.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Persistence; /// @@ -349,10 +350,10 @@ public static IServiceCollection AddInMemoryPersistence(this IServiceCollection private static void RegisterDefaultAssemblyStore(IServiceCollection services) { services.TryAddSingleton(sp => - new MeshWeaver.Graph.Configuration.FileSystemAssemblyStore( + new MeshWeaver.Compiler.FileSystemAssemblyStore( System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"MeshWeaver-AssemblyStore-pid{System.Environment.ProcessId}"), - sp.GetRequiredService>())); + sp.GetRequiredService>())); } /// diff --git a/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs b/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs index c6bdd76602..8adc4803b5 100644 --- a/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs +++ b/src/MeshWeaver.Plugin.Build/FrameworkIdentity.cs @@ -2,6 +2,7 @@ using System.Reflection.PortableExecutable; using MeshWeaver.Graph.Configuration; +using MeshWeaver.Compiler; namespace MeshWeaver.Plugin.Build; /// diff --git a/src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs b/src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs index e1f65cfb6f..cd5859a1de 100644 --- a/src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs +++ b/src/MeshWeaver.Plugin.Build/SkeletonGenerator.cs @@ -3,6 +3,7 @@ using MeshWeaver.Graph.Configuration; using MeshWeaver.Mesh; +using MeshWeaver.Compiler; namespace MeshWeaver.Plugin.Build; /// diff --git a/test/MeshWeaver.AI.Test/ThreadAgentIntegrationTest.cs b/test/MeshWeaver.AI.Test/ThreadAgentIntegrationTest.cs index 6e0bee7987..12bf217dcf 100644 --- a/test/MeshWeaver.AI.Test/ThreadAgentIntegrationTest.cs +++ b/test/MeshWeaver.AI.Test/ThreadAgentIntegrationTest.cs @@ -26,6 +26,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.AI.Test; /// diff --git a/test/MeshWeaver.Acme.Test/NodeTypeProgressAreaTest.cs b/test/MeshWeaver.Acme.Test/NodeTypeProgressAreaTest.cs index a3fe256f79..874e5c6bc0 100644 --- a/test/MeshWeaver.Acme.Test/NodeTypeProgressAreaTest.cs +++ b/test/MeshWeaver.Acme.Test/NodeTypeProgressAreaTest.cs @@ -24,6 +24,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Acme.Test; /// diff --git a/test/MeshWeaver.Graph.Test/CodeQueryResolverTest.cs b/test/MeshWeaver.Graph.Test/CodeQueryResolverTest.cs index 197454ec05..27986ebec7 100644 --- a/test/MeshWeaver.Graph.Test/CodeQueryResolverTest.cs +++ b/test/MeshWeaver.Graph.Test/CodeQueryResolverTest.cs @@ -3,6 +3,7 @@ using MeshWeaver.Graph.Configuration; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// diff --git a/test/MeshWeaver.Graph.Test/DynamicMeshNodeAttributeGeneratorTest.cs b/test/MeshWeaver.Graph.Test/DynamicMeshNodeAttributeGeneratorTest.cs index b55ac73336..1bf6ae546d 100644 --- a/test/MeshWeaver.Graph.Test/DynamicMeshNodeAttributeGeneratorTest.cs +++ b/test/MeshWeaver.Graph.Test/DynamicMeshNodeAttributeGeneratorTest.cs @@ -5,6 +5,7 @@ using MeshWeaver.Mesh; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// diff --git a/test/MeshWeaver.Graph.Test/FileSystemAssemblyStoreTest.cs b/test/MeshWeaver.Graph.Test/FileSystemAssemblyStoreTest.cs index 18fe563980..9068a66002 100644 --- a/test/MeshWeaver.Graph.Test/FileSystemAssemblyStoreTest.cs +++ b/test/MeshWeaver.Graph.Test/FileSystemAssemblyStoreTest.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// diff --git a/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs b/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs index fd892524de..427c6ef65f 100644 --- a/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs +++ b/test/MeshWeaver.Graph.Test/FrameworkBuildIdentityTest.cs @@ -1,10 +1,12 @@ #pragma warning disable CS1591 +using System.Reflection.Metadata; using System.Text.RegularExpressions; using MeshWeaver.Graph.Configuration; using MeshWeaver.Plugin.Build; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// @@ -94,16 +96,46 @@ public void SurfaceIdentity_UsesTheFullImplMvidForGeneratorAssemblies() } [Fact] - public void FullMvidMembership_IsTheToolchainAndTheDirectiveResolver() + public void FullMvidMembership_IsTheToolchainClosureIncludingItsDirectDependencies() { - // The membership IS the design (#1707): the toolchain assembly (everything that shapes - // generated compile input) plus MeshWeaver.NuGet (the #r directive parser/resolver — - // shapes what Roslyn is fed and which assemblies a directive adds; was a HOLE before - // #1707: surface-hashed only). Anything else here means either the extraction leaked - // input-shaping code back out, or an assembly is being over-pinned. - FrameworkBuildIdentity.FullMvidAssemblies - .OrderBy(n => n, StringComparer.Ordinal) - .Should().Equal("MeshWeaver.Compiler", "MeshWeaver.NuGet"); + // The membership IS the design (#1707, maintainer 2026-08-17: "must track dependencies of + // the compiler itself — if any have changed, need to recompile"): the toolchain roots + // (MeshWeaver.Compiler — everything that shapes generated compile input — and + // MeshWeaver.NuGet, the #r directive parser/resolver) PLUS their MeshWeaver dependency + // closure, because the toolchain CALLS into what it links, so a body-only change in a + // closure member can change what it emits with no API change. + var members = FrameworkBuildIdentity.FullMvidAssemblies; + + members.Should().Contain("MeshWeaver.Compiler").And.Contain("MeshWeaver.NuGet", + "the roots are always members"); + members.Should().OnlyContain(n => n.StartsWith("MeshWeaver.", StringComparison.Ordinal), + "non-MeshWeaver dependencies roll with the image/TFM and stay outside the identity"); + // Known DIRECT dependencies of the toolchain — a regression here means the closure walk + // silently stopped resolving references. + members.Should().Contain("MeshWeaver.Mesh.Contract").And.Contain("MeshWeaver.ContentCollections"); + members.Should().Equal(members.OrderBy(n => n, StringComparer.Ordinal), + "the closure is sorted so the hash text is deterministic"); + } + + [Fact] + public void ToolchainClosure_WalksTransitivesAndFiltersNonMeshWeaver() + { + // The pure closure rule over a staged graph: transitive MeshWeaver refs join, diamonds + // dedupe, non-MeshWeaver refs are dropped, and a name with no resolvable refs still + // joins (its MVID then resolves 'absent', which is itself identity-relevant). + var graph = new Dictionary(StringComparer.Ordinal) + { + ["MeshWeaver.Compiler"] = ["MeshWeaver.A", "System.Runtime", "MeshWeaver.B"], + ["MeshWeaver.NuGet"] = ["MeshWeaver.B", "NuGet.Protocol"], + ["MeshWeaver.A"] = ["MeshWeaver.C"], + ["MeshWeaver.B"] = [], + }; + var closure = FrameworkBuildIdentity.ComputeToolchainClosure( + ["MeshWeaver.Compiler", "MeshWeaver.NuGet"], + name => graph.TryGetValue(name, out var refs) ? refs : []); + closure.Should().Equal( + "MeshWeaver.A", "MeshWeaver.B", "MeshWeaver.C", + "MeshWeaver.Compiler", "MeshWeaver.NuGet"); } [Fact] @@ -181,18 +213,12 @@ public void ProcessIdentity_UsesTheManifestWhenPresent() var identity = FrameworkBuildIdentity.ResolveProcessIdentity(dir, AnchorAssembly); identity.Should().StartWith("s"); - // The full-MVID exceptions (the toolchain anchor + MeshWeaver.NuGet) are loaded in - // this test host, so their live MVIDs join the hash — the rest resolve their - // manifest stubs. - var nuget = typeof(MeshWeaver.NuGet.NuGetDirectiveParser).Assembly; + // The full-MVID exceptions (the toolchain closure) resolve their LIVE MVIDs in this + // process — mirrored here with the same loaded-else-PE resolution the identity uses — + // while everything else resolves its manifest stub. var expected = FrameworkBuildIdentity.ComputeSurfaceIdentity( FrameworkBuildIdentity.ContentSurfaceAssemblies.ToDictionary(n => n, _ => "stub"), - n => n switch - { - "MeshWeaver.Compiler" => AnchorAssembly.ManifestModule.ModuleVersionId.ToString("N"), - "MeshWeaver.NuGet" => nuget.ManifestModule.ModuleVersionId.ToString("N"), - _ => null, - }); + TestImplMvidOf); identity.Should().Be(expected); } finally @@ -330,6 +356,21 @@ public void StoreTag_IsAlwaysAttributableByTheRetentionSweep() && string.Equals(a.GetName().Name, simpleName, StringComparison.Ordinal)) ?.ManifestModule.ModuleVersionId.ToString("N"); + /// The identity's ImplMvidOf resolution, mirrored independently: loaded assembly, + /// else a metadata-only read of the DLL beside the test host, else null. + private static string? TestImplMvidOf(string simpleName) + { + if (LoadedMvidOf(simpleName) is { } loaded) + return loaded; + var candidate = Path.Combine(AppContext.BaseDirectory, simpleName + ".dll"); + if (!File.Exists(candidate)) + return null; + using var stream = File.OpenRead(candidate); + using var pe = new System.Reflection.PortableExecutable.PEReader(stream); + var md = pe.GetMetadataReader(); + return md.GetGuid(md.GetModuleDefinition().Mvid).ToString("N"); + } + private static string? FindRepositoryRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/test/MeshWeaver.Graph.Test/NodeTypeCompileStampTest.cs b/test/MeshWeaver.Graph.Test/NodeTypeCompileStampTest.cs index 276f7633bf..c078d47cd4 100644 --- a/test/MeshWeaver.Graph.Test/NodeTypeCompileStampTest.cs +++ b/test/MeshWeaver.Graph.Test/NodeTypeCompileStampTest.cs @@ -7,6 +7,7 @@ using Xunit; using Lsp = MeshWeaver.Mesh.Services.LanguageServer; +using MeshWeaver.Compiler; namespace MeshWeaver.Graph.Test; /// diff --git a/test/MeshWeaver.Hosting.Monolith.Test/FrameworkStaleProactiveRebuildTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/FrameworkStaleProactiveRebuildTest.cs index 83db452e47..586b8441ff 100644 --- a/test/MeshWeaver.Hosting.Monolith.Test/FrameworkStaleProactiveRebuildTest.cs +++ b/test/MeshWeaver.Hosting.Monolith.Test/FrameworkStaleProactiveRebuildTest.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Monolith.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansCompileActivityAccessTest.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansCompileActivityAccessTest.cs index 10eb2373f4..9aa7433b55 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansCompileActivityAccessTest.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansCompileActivityAccessTest.cs @@ -24,6 +24,7 @@ using Orleans.TestingHost; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansDynamicCompilationTest.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansDynamicCompilationTest.cs index e67ca9fffb..44ed97fcaa 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansDynamicCompilationTest.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansDynamicCompilationTest.cs @@ -19,6 +19,7 @@ using Orleans.TestingHost; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresFanOutTest.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresFanOutTest.cs index 008c5c9064..20b0655e77 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresFanOutTest.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresFanOutTest.cs @@ -25,6 +25,7 @@ using Orleans.TestingHost; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresLifecycleTest.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresLifecycleTest.cs index 90988c2360..f2e16e4b04 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresLifecycleTest.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresLifecycleTest.cs @@ -18,6 +18,7 @@ using Orleans.TestingHost; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresPathResolutionTest.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresPathResolutionTest.cs index b52ff55c60..edb984a360 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresPathResolutionTest.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansPostgresPathResolutionTest.cs @@ -19,6 +19,7 @@ using Orleans.TestingHost; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansSlideNavigationPostgresTest.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansSlideNavigationPostgresTest.cs index f19e75e0a8..7cdac8c335 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansSlideNavigationPostgresTest.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansSlideNavigationPostgresTest.cs @@ -26,6 +26,7 @@ using Orleans.TestingHost; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansTestBase.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansTestBase.cs index 93a21a0e3d..9e4f9f7214 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansTestBase.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansTestBase.cs @@ -23,6 +23,7 @@ using Orleans.TestingHost; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.Orleans.Test/OrleansThreadColdLoadHangPostgresTest.cs b/test/MeshWeaver.Hosting.Orleans.Test/OrleansThreadColdLoadHangPostgresTest.cs index 10c1ccc2c9..5ab33ce9b4 100644 --- a/test/MeshWeaver.Hosting.Orleans.Test/OrleansThreadColdLoadHangPostgresTest.cs +++ b/test/MeshWeaver.Hosting.Orleans.Test/OrleansThreadColdLoadHangPostgresTest.cs @@ -26,6 +26,7 @@ using Xunit; using MeshThread = MeshWeaver.AI.Thread; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Orleans.Test; /// diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs index 4847546832..4f39d21f08 100644 --- a/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/BatchBakeSourceDiscoveryScaleTests.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Logging; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.PostgreSql.Test; /// diff --git a/test/MeshWeaver.Hosting.Sqlite.Test/MemexInstanceBootstrapTest.cs b/test/MeshWeaver.Hosting.Sqlite.Test/MemexInstanceBootstrapTest.cs index 29c043eef6..22b595b59c 100644 --- a/test/MeshWeaver.Hosting.Sqlite.Test/MemexInstanceBootstrapTest.cs +++ b/test/MeshWeaver.Hosting.Sqlite.Test/MemexInstanceBootstrapTest.cs @@ -10,6 +10,7 @@ using MeshWeaver.Mesh; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Sqlite.Test; /// diff --git a/test/MeshWeaver.Hosting.Sqlite.Test/SqliteMonolithMeshTest.cs b/test/MeshWeaver.Hosting.Sqlite.Test/SqliteMonolithMeshTest.cs index 0365eb95f9..6a3bc63eec 100644 --- a/test/MeshWeaver.Hosting.Sqlite.Test/SqliteMonolithMeshTest.cs +++ b/test/MeshWeaver.Hosting.Sqlite.Test/SqliteMonolithMeshTest.cs @@ -10,6 +10,7 @@ using MeshWeaver.Mesh.Services; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Sqlite.Test; /// diff --git a/test/MeshWeaver.Hosting.Sqlite.Test/SqliteRawBootstrapTest.cs b/test/MeshWeaver.Hosting.Sqlite.Test/SqliteRawBootstrapTest.cs index 6552958c4b..6f48b760fd 100644 --- a/test/MeshWeaver.Hosting.Sqlite.Test/SqliteRawBootstrapTest.cs +++ b/test/MeshWeaver.Hosting.Sqlite.Test/SqliteRawBootstrapTest.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Sqlite.Test; /// diff --git a/test/MeshWeaver.Hosting.Test/FileSystemAssemblyStoreAtomicityTest.cs b/test/MeshWeaver.Hosting.Test/FileSystemAssemblyStoreAtomicityTest.cs index cd06c891c5..46c26b3f8a 100644 --- a/test/MeshWeaver.Hosting.Test/FileSystemAssemblyStoreAtomicityTest.cs +++ b/test/MeshWeaver.Hosting.Test/FileSystemAssemblyStoreAtomicityTest.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using MeshWeaver.Compiler; namespace MeshWeaver.Hosting.Test; /// diff --git a/tools/MeshWeaver.PluginTester/PluginGateRunner.cs b/tools/MeshWeaver.PluginTester/PluginGateRunner.cs index ef8880883a..bce4899f28 100644 --- a/tools/MeshWeaver.PluginTester/PluginGateRunner.cs +++ b/tools/MeshWeaver.PluginTester/PluginGateRunner.cs @@ -24,6 +24,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using MeshWeaver.Compiler; namespace MeshWeaver.PluginTester; /// Configuration for one gate run. diff --git a/tools/MeshWeaver.PluginTester/Program.cs b/tools/MeshWeaver.PluginTester/Program.cs index adb7739910..8e4caadd8e 100644 --- a/tools/MeshWeaver.PluginTester/Program.cs +++ b/tools/MeshWeaver.PluginTester/Program.cs @@ -5,6 +5,7 @@ using MeshWeaver.PluginCatalog; using MeshWeaver.PluginTester; +using MeshWeaver.Compiler; // mw-plugin-test [--compile-timeout ] [--render-timeout ] // [--allow ] [--report ] // [--bake-output ] [--source-sha ] @@ -70,8 +71,8 @@ // is what the surface-identity proof script drives. case "--print-framework-identity": { - var provenance = MeshWeaver.Graph.Configuration.FrameworkBuildIdentity - .StampedIdentityOf(typeof(MeshWeaver.Graph.Configuration.FrameworkBuildIdentity).Assembly); + var provenance = MeshWeaver.Compiler.FrameworkBuildIdentity + .StampedIdentityOf(typeof(MeshWeaver.Compiler.FrameworkBuildIdentity).Assembly); Console.WriteLine( $"identity={MeshWeaver.Graph.Configuration.PrebuiltAssemblySeeder.LiveFrameworkMvid} " + $"provenance={provenance ?? "(unstamped)"}");