From d42f4f9417fa1766e8bce99256faf8983773e54b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Fri, 21 Aug 2026 16:46:18 +0200 Subject: [PATCH 1/5] compile: a CONTENT KEY over the fully generated compile input (#1707 slice 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every invalidation signal for a compiled NodeType has so far been a PROXY. `FrameworkBuildIdentity.FullMvidAssemblies` folds the toolchain's whole implementation MVID into the framework identity precisely because a generator body change reshapes what Roslyn is fed with no API change — surface hashing cannot see it. Correct, and unbelievably coarse: a body-only commit to any member of that closure (16 assemblies, 383 commits/30d — #1976) mints a new global identity, empties the assembly share's key-space and rebakes every NodeType on every deployment, including the ones whose generated input is byte-for-byte what it was. This is the direct observation the proxy stands in for. `GeneratedInputIdentity` hashes the FULLY GENERATED compilation input — the exact text handed to Roslyn after skeleton generation, source aggregation, `@@`-include expansion and the `#r` strip — together with everything else that decides the bytes given that text: the assembly name, the reflected parse/compilation/emit option set, the Roslyn build, the source generators that run, and the pruned reference-surface set. Two compiles whose keys are equal produce interchangeable bytes, whoever built them and whenever. Two stages, because the halves are known at different moments: `OfGeneratedInput` runs before Roslyn where the text exists; `Combine` folds in the reference surfaces read off the EMITTED assembly afterwards. Hashing the pruned set rather than the candidate set is what keeps the key per-type. The result lands in the per-type dependency record under the reserved `!input` key, folded over the record's ASSEMBLY entries only — folding the toolchain id in would reproduce the proxy it replaces. Determinism is the whole product, so exactly three normalisations, each a deliberate trade documented on the type: a leading BOM is dropped; line endings fold to `\n` (the skeleton is built with `AppendLine`, so the same node generates CRLF on Windows and LF in a pod — without the fold nothing could ever share a build across hosts); and the generator's `// Generated at: ` header becomes a fixed marker (a wall clock in the generated text means the input is never twice the same, and no key over it could ever hit — it is also why the store's digest-of-the-emitted-bytes can never dedupe two compiles of identical content). Nothing else is touched. The option set is REFLECTED rather than hand-listed: a hand-list is how `NuGetDirectiveParser` sat outside the identity boundary while shaping compile input, and an option the toolchain starts setting must not be able to sit silently outside the key. `FindMismatch` gains an optional live content key. A caller that REGENERATED the input gets an exact verdict; a metadata-only caller skips the entry, and the toolchain entry still governs — see the next commit's note and the pinned `DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchain`. Tests pin all four discriminations plus golden vectors that a different process on a different architecture has to reproduce. Each was verified non-vacuous by breaking the property and watching it go red. Internal only: no user-visible effect, so no What's New entry. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../CompiledDependencies.cs | 74 +++- src/MeshWeaver.Compiler/EmitPipeline.cs | 15 + .../GeneratedInputIdentity.cs | 356 +++++++++++++++++ src/MeshWeaver.Compiler/GeneratorPipeline.cs | 22 +- .../GeneratedInputIdentityTest.cs | 372 ++++++++++++++++++ 5 files changed, 829 insertions(+), 10 deletions(-) create mode 100644 src/MeshWeaver.Compiler/GeneratedInputIdentity.cs create mode 100644 test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs diff --git a/src/MeshWeaver.Compiler/CompiledDependencies.cs b/src/MeshWeaver.Compiler/CompiledDependencies.cs index 4f9f1a7ac6..c2057e0152 100644 --- a/src/MeshWeaver.Compiler/CompiledDependencies.cs +++ b/src/MeshWeaver.Compiler/CompiledDependencies.cs @@ -53,6 +53,31 @@ public static class CompiledDependencies /// public const string ToolchainKey = "!toolchain"; + /// + /// The reserved record key carrying the build's CONTENT KEY (#1707 slice 4) — the hash of the + /// FULLY GENERATED compilation input folded with the pruned reference surfaces + /// (). Same '!' prefix convention as + /// . + /// + /// What it is FOR, and what it is not. is a PROXY — + /// "the toolchain moved, so the bytes MIGHT be stale". This is the direct observation: "the + /// input that produced these bytes hashed to X". A consumer that has REGENERATED the input can + /// therefore answer exactly, and a toolchain change that did not move a type's generated input + /// is provably not a reason to rebuild it. + /// + /// 🚨 It is consequently validated ONLY against a live value the caller computed by + /// regenerating ('s liveContentKey). There is no cheap live + /// counterpart: the key is a function of the toolchain AND the source text, and no metadata-only + /// check can evaluate it. That is precisely why the toolchain entry cannot simply be deleted in + /// favour of this one — see the note on . + /// + /// OPTIONAL: a record produced where the generated input was not available (an adopted + /// prebuilt, the disk-cache hit path, the hydration shortcut) carries no entry, and + /// then simply has nothing to compare — the toolchain entry still + /// governs, exactly as before this key existed. + /// + public const string ContentKey = "!input"; + /// /// The toolchain id for : SHA-256 over the /// closure members' implementation @@ -82,10 +107,17 @@ public static string ComputeToolchainId(Func implMvidOf) /// null = out of scope (System/TPA), excluded from the record. /// The producing process's toolchain id /// (). + /// The stage-1 generated-input digest + /// () of the compile that produced these + /// bytes, or null when the caller did not compile (an adopted prebuilt, a cache hit, the + /// hydration shortcut). Non-null folds the pruned reference surfaces in and writes the + /// entry — the record's own assembly entries ARE that set, so the two + /// cannot drift apart. public static ImmutableSortedDictionary Compute( IEnumerable referencedSimpleNames, Func idOf, - string toolchainId) + string toolchainId, + string? generatedInputDigest = null) { var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); builder[ToolchainKey] = toolchainId; @@ -96,6 +128,13 @@ public static ImmutableSortedDictionary Compute( if (idOf(name) is { } id) builder[name] = id; } + if (!string.IsNullOrEmpty(generatedInputDigest)) + // The reference surfaces are the record's ASSEMBLY entries — the reserved '!' entries + // are deliberately excluded. Folding the toolchain MVID into the content key would + // make the key move on every toolchain commit, which is the exact proxy this key + // exists to replace. + builder[ContentKey] = GeneratedInputIdentity.Combine( + generatedInputDigest, builder.Where(pair => !IsReservedKey(pair.Key))); return builder.ToImmutable(); } @@ -106,10 +145,17 @@ public static ImmutableSortedDictionary Compute( /// mismatch — a build that binds something this environment cannot even classify is not /// provably compatible. /// + /// The stamped record. + /// The live surface-id resolver (). + /// The live toolchain id (). + /// The content key the caller computed by REGENERATING this + /// type's compile input (), or null when it did not — see + /// . public static string? FindMismatch( IReadOnlyDictionary record, Func liveIdOf, - string liveToolchainId) + string liveToolchainId, + string? liveContentKey = null) { // 🚨 A record WITHOUT the reserved toolchain entry is never trusted: FindMismatch only // compares entries that are PRESENT, so such a record would validate forever across @@ -122,15 +168,33 @@ public static ImmutableSortedDictionary Compute( foreach (var (name, stamped) in record) { - var live = string.Equals(name, ToolchainKey, StringComparison.Ordinal) - ? liveToolchainId - : liveIdOf(name) ?? AbsentId; + string live; + if (string.Equals(name, ToolchainKey, StringComparison.Ordinal)) + live = liveToolchainId; + else if (string.Equals(name, ContentKey, StringComparison.Ordinal)) + { + // The content key has NO cheap live counterpart — evaluating it means regenerating + // the compile input. A caller that did so passes it and gets an EXACT verdict; a + // caller that did not skips the entry rather than inventing a live value, and the + // toolchain entry above still governs. Skipping is not fail-open here for exactly + // that reason: the record is refused outright if it carries no toolchain entry. + if (string.IsNullOrEmpty(liveContentKey)) + continue; + live = liveContentKey; + } + else + live = liveIdOf(name) ?? AbsentId; if (!string.Equals(stamped, live, StringComparison.Ordinal)) return $"'{name}' built against {stamped}, live is {live}"; } return null; } + /// True for the reserved '!'-prefixed record keys, which name compile FACTS rather + /// than referenced assemblies. + public static bool IsReservedKey(string key) => + key.StartsWith('!'); + /// /// The surface-id resolver over one environment: installed modules FIRST (exact-build MVID — /// a module name wins even when it starts with MeshWeaver., because modules pin by diff --git a/src/MeshWeaver.Compiler/EmitPipeline.cs b/src/MeshWeaver.Compiler/EmitPipeline.cs index 335f96b042..05b2065c0a 100644 --- a/src/MeshWeaver.Compiler/EmitPipeline.cs +++ b/src/MeshWeaver.Compiler/EmitPipeline.cs @@ -26,6 +26,21 @@ internal static CSharpCompilationOptions CreateCompilationOptions() .WithOptimizationLevel(OptimizationLevel.Debug) .WithPlatform(Platform.AnyCpu); + /// + /// The canonical option set rendered for the CONTENT KEY (#1707 slice 4) — see + /// . It lives here, beside the three + /// factories it renders, so an option added to one of them cannot be forgotten by the key: + /// the rendering is REFLECTED, so a new option joins automatically. + /// + /// Process-constant (a of an immutable string): the options are + /// literals, so this is a constant lookup rather than cached state. + /// + internal static string OptionsFingerprint => _optionsFingerprint.Value; + + private static readonly Lazy _optionsFingerprint = new(() => + GeneratedInputIdentity.OptionsFingerprint( + CreateParseOptions(), CreateCompilationOptions(), DebugInformationFormat.PortablePdb)); + /// /// 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 diff --git a/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs b/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs new file mode 100644 index 0000000000..8a2f21e01c --- /dev/null +++ b/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs @@ -0,0 +1,356 @@ +using System.Collections; +using System.Collections.Immutable; +using System.Globalization; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace MeshWeaver.Compiler; + +/// +/// 🚨 THE CONTENT KEY of a compiled NodeType (#1707 slice 4): a hash of the fully generated +/// compilation input — the exact text handed to Roslyn after skeleton generation, source +/// aggregation, @@-include expansion and the #r strip — together with everything +/// else that decides the emitted bytes given that text: the assembly name, the parse/compilation +/// option set, the Roslyn version, the source generators that run, and the reference SURFACE set. +/// +/// Why it exists. Every invalidation signal that preceded it was a PROXY. +/// folds the toolchain's full +/// implementation MVID into the framework identity precisely because a generator body change +/// reshapes what Roslyn is fed with no API change — so surface hashing cannot see it. That proxy +/// is correct and unbelievably coarse: a body-only commit to any member of the toolchain closure +/// (16 assemblies, 383 commits/30d — issue #1976) mints a new global identity, empties the +/// assembly share's key-space, and rebakes every NodeType on every deployment, including the ones +/// whose generated input is byte-for-byte what it was. This is the DIRECT observation the proxy +/// stands in for: two compiles whose generated input hashes equal produce interchangeable bytes, +/// whoever built them and whenever. +/// +/// Two stages, because the two halves are known at different moments. +/// runs BEFORE Roslyn, where the generated text exists; +/// folds in the PRUNED reference surfaces read off the emitted assembly +/// afterwards (Roslyn emits an AssemblyRef row only for what the produced code actually uses — +/// see ). Hashing the pruned set rather than the candidate set +/// is what keeps the key per-type: a platform surface change invalidates the types that bind it, +/// not the world. +/// +/// Determinism is the whole product. An unstable key invalidates everything on every +/// build, which is the current complaint amplified. Everything enumerable is ordinal-sorted, +/// every value is rendered invariant-culture, and the source text is normalised by +/// . Exactly three normalisations are applied, and each is +/// a deliberate trade: +/// +/// A leading UTF-8 BOM is dropped. Whether the text carries one is a +/// property of how it was read, not of what it means. +/// Line endings are folded to \n. The skeleton is built with +/// StringBuilder.AppendLine, i.e. , so the SAME node +/// generates CRLF text on a Windows dev box and LF in a Linux pod. Without this fold the key would +/// differ per host for identical content and nothing would ever share a build. The cost is +/// explicit and small: two inputs that differ ONLY in the line endings inside a verbatim/raw +/// string literal hash equal. Their emitted bytes do differ, so this is the one place the key is +/// deliberately coarser than the bytes — and it is coarser in the direction of a Windows-built +/// artifact matching its Linux twin, which is the shape a mixed-host mesh needs. +/// The skeleton's // Generated at: <UtcNow> header line is +/// replaced by a fixed marker (). +/// DynamicMeshNodeAttributeGenerator.GenerateAttributeSource stamps a WALL CLOCK into the +/// generated text, so the input is never twice the same and no content key over it could ever +/// hit. It is a comment: it contributes no IL. (It does move the PDB's document hash, which is +/// why the assembly-store's digest-of-the-emitted-bytes can never dedupe two compiles of +/// identical content either — the observation that motivates this type.) +/// +/// Nothing else is touched: no trimming, no whitespace collapsing, no comment stripping. Every +/// other byte of the generated text is part of the key. +/// +public static class GeneratedInputIdentity +{ + /// Prefix of a stage-1 GENERATED-INPUT digest () — the + /// text-and-toolchain half, before the reference surfaces are known. + public const string GeneratedInputPrefix = "g"; + + /// Prefix of the complete CONTENT KEY (). + public const string ContentKeyPrefix = "i"; + + /// Recorded for an input whose identity cannot be resolved (a generator assembly that + /// is not on disk, a reference with no id). Absence is part of the key, never silently + /// skipped — two environments with different presence sets must not hash equal. + public const string AbsentId = "absent"; + + /// The fixed text every // Generated at: … line normalises to. + internal const string NormalizedGeneratedAtLine = "// Generated at: (normalized)"; + + /// + /// Matches the skeleton's wall-clock header line. Anchored to a whole line (Multiline) and + /// deliberately narrow — it must not touch a line of USER code that happens to mention the + /// phrase inside a string literal, which is why it requires the line to START with the comment + /// marker rather than merely contain it. + /// + private static readonly Regex GeneratedAtLinePattern = new( + @"^//[ \t]*Generated at:.*$", RegexOptions.Multiline | RegexOptions.Compiled); + + /// + /// The identity of the Roslyn that will do the compiling. A compiler upgrade can change the + /// emitted bytes for identical input, and it is NOT covered by anything else here: the + /// framework identity walks MeshWeaver.* references only + /// (FrameworkBuildIdentity.ComputeToolchainClosure), so Microsoft.CodeAnalysis.* + /// has never been in it directly — it rode in on MeshWeaver.Compiler's MVID, which a + /// package bump moves. A content key that replaces that MVID must carry the compiler + /// explicitly or it under-invalidates on exactly the change that rewrites every assembly. + /// + public static string CompilerIdentity { get; } = ResolveCompilerIdentity(); + + private static string ResolveCompilerIdentity() + { + var assembly = typeof(CSharpCompilation).Assembly; + var name = assembly.GetName(); + var informational = assembly + .GetCustomAttribute()?.InformationalVersion; + return string.Create(CultureInfo.InvariantCulture, + $"{name.Name}/{name.Version}/{informational ?? AbsentId}"); + } + + /// + /// Normalises the fully generated compilation input for hashing — see the three normalisations + /// documented on the type. Pure; a null/empty input normalises to the empty string. + /// + public static string NormalizeGeneratedSource(string? source) + { + if (string.IsNullOrEmpty(source)) + return string.Empty; + var text = source[0] == '\uFEFF' ? source[1..] : source; + text = text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + return GeneratedAtLinePattern.Replace(text, NormalizedGeneratedAtLine); + } + + /// + /// STAGE 1 — the digest of everything that is known BEFORE Roslyn runs: the generated text, + /// the emitted assembly's name, the option set, the compiler, and the source generators that + /// will run over the compilation. + /// + /// The generators are in here rather than in the text because they ADD source Roslyn + /// never shows the caller: a generator body change alters the emitted bytes while the text + /// handed in is unchanged. Their identities are MVIDs + /// () — a generator is a build tool, and the only honest + /// question about one is "is it the same build". + /// + /// The emitted assembly's name — it is written into the metadata, + /// so it is part of the bytes. + /// The full C# text handed to Roslyn (post skeleton, aggregation, + /// @@-include expansion and #r strip). Normalised here. + /// The canonical rendering of the parse/compilation/emit + /// options — see . + /// The Roslyn identity — in + /// production, injectable so the rule is testable without rebuilding the compiler. + /// Source-generator assembly file name → identity. + public static string OfGeneratedInput( + string assemblyName, + string? generatedSource, + string optionsFingerprint, + string compilerIdentity, + IEnumerable> generatorIdentities) + { + ArgumentNullException.ThrowIfNull(assemblyName); + ArgumentNullException.ThrowIfNull(optionsFingerprint); + ArgumentNullException.ThrowIfNull(compilerIdentity); + ArgumentNullException.ThrowIfNull(generatorIdentities); + + var document = new StringBuilder(); + document.Append("generated-input/v1\n"); + document.Append("assembly=").Append(assemblyName).Append('\n'); + document.Append("compiler=").Append(compilerIdentity).Append('\n'); + document.Append("options=").Append(Sha256Hex(optionsFingerprint)).Append('\n'); + AppendPairs(document, "generator", generatorIdentities); + document.Append("source=") + .Append(Sha256Hex(NormalizeGeneratedSource(generatedSource))).Append('\n'); + return GeneratedInputPrefix + Sha256Hex(document.ToString())[..32]; + } + + /// + /// STAGE 2 — the complete CONTENT KEY: the stage-1 digest folded with the PRUNED reference + /// surfaces the emitted assembly actually binds ('s + /// assembly entries — platform assemblies by reference-assembly hash, modules and NuGet + /// packages by MVID). + /// + /// Reference SURFACES, not reference builds: two hosts whose platform assemblies present + /// the same API surface resolve the same ids (#1696's construction), so the key is equal + /// across them — which is the entire point of a content key that a bake and a portal can both + /// compute. + /// + /// The stage-1 value from . + /// Referenced assembly simple name → surface id. + public static string Combine( + string generatedInputDigest, + IEnumerable> referenceSurfaces) + { + ArgumentNullException.ThrowIfNull(generatedInputDigest); + ArgumentNullException.ThrowIfNull(referenceSurfaces); + + var document = new StringBuilder(); + document.Append("content-key/v1\n"); + document.Append("input=").Append(generatedInputDigest).Append('\n'); + AppendPairs(document, "reference", referenceSurfaces); + return ContentKeyPrefix + Sha256Hex(document.ToString())[..32]; + } + + /// + /// Assembly file name → implementation MVID ("N" format), read METADATA-ONLY so nothing is + /// loaded into the process. Keyed by FILE NAME rather than by full path: a path is a property + /// of the host's layout, and a key that carried one could never match between a bake container + /// and a portal pod. A missing or unreadable file resolves — absence is + /// part of the key. + /// + public static ImmutableSortedDictionary AssemblyFileIdentities( + IEnumerable assemblyPaths) + { + ArgumentNullException.ThrowIfNull(assemblyPaths); + var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var path in assemblyPaths) + { + if (string.IsNullOrEmpty(path)) + continue; + builder[Path.GetFileName(path)] = ReadMvid(path) ?? AbsentId; + } + return builder.ToImmutable(); + } + + private static string? ReadMvid(string path) + { + if (!File.Exists(path)) + return null; + try + { + using var stream = File.OpenRead(path); + using var pe = new PEReader(stream); + var metadata = pe.GetMetadataReader(); + return metadata.GetGuid(metadata.GetModuleDefinition().Mvid).ToString("N"); + } + catch (Exception ex) when (ex is IOException or BadImageFormatException + or UnauthorizedAccessException) + { + // Unreadable is recorded as ABSENT, not swallowed: the caller's key then differs from + // one computed where the file was readable, which is the conservative direction + // (a rebuild), and the identity resolution must never fault a compile. + return null; + } + } + + /// + /// The canonical rendering of everything the toolchain tells Roslyn ABOUT the compilation, + /// as opposed to the text of it. A body-only change to + /// EmitPipeline.CreateCompilationOptions — flipping the optimization level, say — + /// rewrites every emitted assembly while leaving the generated text identical, so the option + /// set has to be in the key or the key under-invalidates on it. + /// + /// 🚨 REFLECTED, never hand-listed. A hand-list is how NuGetDirectiveParser sat + /// outside the identity boundary while shaping compile input (#1707), and the same trap is + /// waiting here: an option the toolchain starts setting would be silently outside the key, and + /// the failure mode of that is stale bytes with no diagnostic. Reflecting over the option + /// objects' public properties also makes the key sensitive to a Roslyn upgrade that ADDS an + /// option, which is correct — the new default may change what is emitted. + /// + /// The PDB file path is deliberately NOT here: the emit sets it to a host-local + /// directory, and a key carrying it could never match across hosts. It lands in the DLL's + /// debug directory, so the emitted BYTES do differ by host — which is one more reason a digest + /// of the emitted bytes cannot be a content key, and no reason to poison this one. + /// + public static string OptionsFingerprint( + CSharpParseOptions parseOptions, + CSharpCompilationOptions compilationOptions, + DebugInformationFormat debugInformationFormat) + { + ArgumentNullException.ThrowIfNull(parseOptions); + ArgumentNullException.ThrowIfNull(compilationOptions); + var text = new StringBuilder(); + text.Append("options/v1\n"); + text.Append(RenderOptions(parseOptions)); + text.Append(RenderOptions(compilationOptions)); + text.Append("emit.DebugInformationFormat=") + .Append(debugInformationFormat.ToString()).Append('\n'); + return text.ToString(); + } + + /// + /// One option object rendered as sorted Type.Property=value lines. Values are rendered + /// invariant-culture; a collection is rendered as its ordinal-sorted elements so declaration + /// order can never move the key; anything that is not a scalar, a string, an enum or a + /// collection is rendered as its runtime TYPE NAME — never ToString() of an arbitrary + /// object (which may embed a hash code) and never a reference identity. + /// + internal static string RenderOptions(object options) + { + ArgumentNullException.ThrowIfNull(options); + var type = options.GetType(); + var text = new StringBuilder(); + var properties = type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .OrderBy(p => p.Name, StringComparer.Ordinal); + foreach (var property in properties) + text.Append(type.Name).Append('.').Append(property.Name).Append('=') + .Append(ReadAndRender(property, options)).Append('\n'); + return text.ToString(); + } + + private static string ReadAndRender(PropertyInfo property, object instance) + { + try + { + return RenderValue(property.GetValue(instance)); + } + catch (Exception ex) + { + // A getter that throws is rendered as a STABLE marker naming the fault type, so the + // fingerprint stays deterministic instead of faulting the compile that needs it. + // It is not a swallow: the marker differs from every real value, so a property that + // starts throwing invalidates — the safe direction. + return ""; + } + } + + private static string RenderValue(object? value) + { + switch (value) + { + case null: + return "null"; + case string s: + return s; + case bool b: + return b ? "true" : "false"; + case Enum e: + return e.ToString(); + case IFormattable formattable when value.GetType().IsPrimitive + || value is decimal or Guid: + return formattable.ToString(null, CultureInfo.InvariantCulture); + case IDictionary dictionary: + return Join(dictionary.Cast() + .Select(entry => RenderValue(entry.Key) + "=" + RenderValue(entry.Value))); + case IEnumerable enumerable: + return Join(enumerable.Cast().Select(RenderValue)); + } + + var type = value.GetType(); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) + return RenderValue(type.GetProperty("Key")?.GetValue(value)) + + "=" + RenderValue(type.GetProperty("Value")?.GetValue(value)); + return "<" + (type.FullName ?? type.Name) + ">"; + } + + private static string Join(IEnumerable rendered) => + "[" + string.Join(",", rendered.OrderBy(s => s, StringComparer.Ordinal)) + "]"; + + private static void AppendPairs( + StringBuilder document, string label, IEnumerable> pairs) + { + foreach (var pair in pairs.OrderBy(p => p.Key, StringComparer.Ordinal)) + document.Append(label).Append('=').Append(pair.Key).Append('=') + .Append(pair.Value ?? AbsentId).Append('\n'); + } + + private static string Sha256Hex(string text) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); +} diff --git a/src/MeshWeaver.Compiler/GeneratorPipeline.cs b/src/MeshWeaver.Compiler/GeneratorPipeline.cs index 6cc8b243bc..a643d941c1 100644 --- a/src/MeshWeaver.Compiler/GeneratorPipeline.cs +++ b/src/MeshWeaver.Compiler/GeneratorPipeline.cs @@ -61,6 +61,22 @@ internal static void StripBuiltInScopeGeneratorRef(List r r.Id, BuiltInScopeGeneratorId, StringComparison.OrdinalIgnoreCase)); } + /// + /// The generator-candidate assemblies a compile actually loads: the built-in generator (when a + /// copy is present beside the app) plus everything the node's #r directives resolved to, + /// minus a duplicate of the built-in. Extracted so the CONTENT KEY + /// () hashes the SAME set + /// runs — a key computed over a different set would be a key + /// about a compile that never happened. + /// + internal static IReadOnlyList EffectiveGeneratorPaths( + IReadOnlyList generatorAssemblyPaths) + => BuiltInGeneratorPaths.Count == 0 + ? generatorAssemblyPaths + : [.. BuiltInGeneratorPaths, + .. generatorAssemblyPaths.Where(p => !string.Equals( + Path.GetFileName(p), BuiltInScopeGeneratorId + ".dll", StringComparison.OrdinalIgnoreCase))]; + /// /// Runs the built-in scope generator plus any OTHER generator a node #r'd over /// . A node's own @@ -73,11 +89,7 @@ internal static void StripBuiltInScopeGeneratorRef(List r 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))]; + var allPaths = EffectiveGeneratorPaths(generatorAssemblyPaths); if (allPaths.Count == 0) return compilation; var generators = SourceGeneratorLoader.Discover(allPaths, logger); diff --git a/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs b/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs new file mode 100644 index 0000000000..d60c08d3d1 --- /dev/null +++ b/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs @@ -0,0 +1,372 @@ +#pragma warning disable CS1591 + +using System.Collections.Immutable; +using MeshWeaver.Compiler; +using Xunit; + +namespace MeshWeaver.Graph.Test; + +/// +/// Pins the CONTENT KEY (#1707 slice 4) — the hash of the FULLY GENERATED compilation input. +/// +/// The key exists to replace a PROXY. FrameworkBuildIdentity.FullMvidAssemblies folds +/// the toolchain's whole implementation MVID into the framework identity because a generator body +/// change reshapes what Roslyn is fed with no API change — correct, and so coarse that a body-only +/// commit anywhere in a 16-assembly closure rebakes every NodeType on every deployment (#1976). So +/// the key has to DISCRIMINATE, and these are the four claims that make it usable: +/// +/// +/// identical generated input ⇒ identical key, in one process and across +/// processes/hosts/architectures (the golden vector); +/// a GENERATOR BODY change that alters the generated input ⇒ the key MOVES +/// (this is the case the full-MVID rule exists for — the load-bearing one); +/// a body-only change that does NOT alter the generated input ⇒ the key does +/// NOT move (this is the win); +/// a REFERENCE-SURFACE change ⇒ the key MOVES. +/// +/// +/// An unstable key is worse than the proxy it replaces: it would invalidate everything on +/// every build and nobody would know why. Hence the golden vectors — a literal a DIFFERENT process +/// on a DIFFERENT architecture has to reproduce, which is exactly what CI does on every run. +/// +public class GeneratedInputIdentityTest +{ + // Fixed, injected environment: the algorithm is pinned, not this machine's Roslyn build. + private const string Compiler = "Microsoft.CodeAnalysis.CSharp/4.14.0.0/4.14.0-test"; + private const string Options = "options/v1\nCSharpParseOptions.LanguageVersion=Preview\n"; + + private const string GeneratedSource = + "// Auto-generated from MeshNode: Demo/Thing\n" + + "// Generated at: 2026-08-21T09:14:02.1234567+00:00\n" + + "using System;\n" + + "[assembly: MeshWeaver.Graph.Generated.DemoThingMeshNodeProvider]\n" + + "public record ThingContent(string Name);\n"; + + private static ImmutableSortedDictionary Pairs( + params (string Key, string Value)[] pairs) + { + var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var (key, value) in pairs) + builder[key] = value; + return builder.ToImmutable(); + } + + private static string Digest( + string source, string options = Options, params (string Key, string Value)[] generators) => + GeneratedInputIdentity.OfGeneratedInput( + "DynamicNode_Demo_Thing", source, options, Compiler, Pairs(generators)); + + // ── 1. identical generated input ⇒ identical key ───────────────────────────────────────── + + [Fact] + public void IdenticalGeneratedInput_HashesIdentically_WithinOneProcess() + { + Digest(GeneratedSource).Should().Be(Digest(GeneratedSource)); + GeneratedInputIdentity.Combine(Digest(GeneratedSource), Pairs(("MeshWeaver.Layout", "ref:a"))) + .Should().Be(GeneratedInputIdentity.Combine( + Digest(GeneratedSource), Pairs(("MeshWeaver.Layout", "ref:a")))); + } + + /// + /// 🚨 THE CROSS-PROCESS / CROSS-HOST / CROSS-ARCHITECTURE claim, expressed the only way a test + /// can make it: a LITERAL. Every CI run on every runner recomputes these from the same inputs + /// in a different process — an implementation that picked up ANY ambient state (culture, line + /// endings, enumeration order, a clock, a hash seed) fails here rather than silently + /// invalidating every build in production. + /// + /// A change to these literals is a change to the KEY SPACE: every stamped record stops + /// matching and every NodeType rebuilds once. That is legitimate when the algorithm genuinely + /// changes — bump the generated-input/v1 / content-key/v1 document versions with + /// it and say so in the PR. It is never legitimate as a way to make a failing test pass. + /// + [Fact] + public void TheKey_IsAPinnedFunctionOfItsInputs_AcrossProcessesAndHosts() + { + var digest = Digest(GeneratedSource); + digest.Should().Be("gb7fb3927697f90ef616dc94be3d91869"); + + GeneratedInputIdentity.Combine(digest, Pairs( + ("MeshWeaver.Layout", "ref:aaa"), ("MeshWeaver.Mesh.Contract", "ref:bbb"))) + .Should().Be("i01e1f2f47df6303c72c00a1f6857eae7"); + } + + [Fact] + public void LineEndings_AreNormalized_SoAWindowsBuildAndALinuxBuildAgree() + { + // The skeleton is built with StringBuilder.AppendLine — Environment.NewLine — so the SAME + // node generates CRLF on a Windows dev box and LF in a pod. Without the fold nothing would + // ever share a build across hosts. + Digest(GeneratedSource.Replace("\n", "\r\n", StringComparison.Ordinal)) + .Should().Be(Digest(GeneratedSource)); + Digest("\uFEFF" + GeneratedSource).Should().Be(Digest(GeneratedSource)); + } + + [Fact] + public void TheGeneratorsWallClockHeader_IsNormalizedOut() + { + // DynamicMeshNodeAttributeGenerator stamps DateTimeOffset.UtcNow into the generated text. + // Left in, the input is never twice the same and NO content key over it could ever hit. + var later = GeneratedSource.Replace( + "2026-08-21T09:14:02.1234567+00:00", "2027-01-02T23:59:59.9999999+00:00", + StringComparison.Ordinal); + later.Should().NotBe(GeneratedSource); + Digest(later).Should().Be(Digest(GeneratedSource)); + + // …and ONLY that line: the same timestamp text inside user code is content, and moves it. + var inUserCode = GeneratedSource.Replace( + "public record ThingContent(string Name);", + "public const string Stamp = \"// Generated at: 2026-08-21T09:14:02.1234567+00:00\";", + StringComparison.Ordinal); + Digest(inUserCode).Should().NotBe(Digest(GeneratedSource)); + } + + // ── 2. a generator body change that alters the generated input ⇒ the key MOVES ─────────── + + /// + /// The load-bearing case. A body-only edit to the skeleton generator — the #1802 shape, where + /// GenerateGlobalUsingsSource/GenerateAttributeSource started emitting a + /// different import scope — changes the emitted bytes with NO API change anywhere. This is + /// exactly what surface hashing cannot see and what FullMvidAssemblies exists for. + /// + [Fact] + public void AGeneratorBodyChange_ThatAltersTheGeneratedText_MovesTheKey() + { + var afterGeneratorChange = GeneratedSource.Replace( + "using System;\n", "using System;\nusing System.Linq;\n", StringComparison.Ordinal); + + Digest(afterGeneratorChange).Should().NotBe(Digest(GeneratedSource)); + } + + /// + /// The other half of the same case: a SOURCE GENERATOR whose body changed emits different + /// source into the compilation while the text handed to Roslyn is byte-identical. Nothing in + /// the generated text can see that, so the generators' own identities are part of the key. + /// + [Fact] + public void AGeneratorAssemblyBodyChange_MovesTheKey_EvenWhenTheTextIsIdentical() + { + var before = Digest(GeneratedSource, Options, ("Scope.Generator.dll", "mvid:1111")); + var after = Digest(GeneratedSource, Options, ("Scope.Generator.dll", "mvid:2222")); + + after.Should().NotBe(before); + // …and adding a generator is a change too — an empty set must not hash like a populated one. + Digest(GeneratedSource).Should().NotBe(before); + } + + /// + /// A body-only change to EmitPipeline.CreateCompilationOptions (flipping the + /// optimization level, say) rewrites every emitted assembly while the generated text is + /// unchanged — so the option set is in the key, and it is REFLECTED rather than hand-listed so + /// a newly-set option cannot sit silently outside it. + /// + [Fact] + public void AnOptionChange_MovesTheKey() + { + Digest(GeneratedSource, Options + "CSharpCompilationOptions.OptimizationLevel=Release\n") + .Should().NotBe(Digest(GeneratedSource)); + } + + [Fact] + public void RenderOptions_IsSortedOrdinalAndInvariant_AndNamesEveryPublicProperty() + { + var rendered = GeneratedInputIdentity.RenderOptions( + Microsoft.CodeAnalysis.CSharp.CSharpParseOptions.Default); + var lines = rendered.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + lines.Should().NotBeEmpty(); + lines.Should().Equal(lines.OrderBy(l => l, StringComparer.Ordinal)); + lines.Should().Contain(l => l.StartsWith("CSharpParseOptions.LanguageVersion=", StringComparison.Ordinal)); + // Reflected, so a Roslyn upgrade that ADDS an option joins the key automatically. + lines.Should().HaveCountGreaterThan(5); + } + + // ── 3. a body-only change that does NOT alter the generated input ⇒ the key does NOT move ── + + /// + /// 🚨 THE WIN, and the claim the whole slice is for. Two compiles of the SAME content by two + /// toolchains whose implementation MVIDs differ — the situation after any body-only commit to + /// the 16-assembly toolchain closure — produce the SAME content key. The !toolchain + /// proxy moves and the record records that it moved; the CONTENT key does not, because the + /// generated input did not. + /// + [Fact] + public void AToolchainBodyChange_ThatDoesNotAlterTheGeneratedInput_LeavesTheKeyPut() + { + Func ids = name => name == "MeshWeaver.Layout" ? "ref:aaa" : null; + var digest = Digest(GeneratedSource); + + var before = CompiledDependencies.Compute( + ["MeshWeaver.Layout"], ids, "mvid:toolchain-BEFORE", digest); + var after = CompiledDependencies.Compute( + ["MeshWeaver.Layout"], ids, "mvid:toolchain-AFTER", digest); + + before[CompiledDependencies.ToolchainKey] + .Should().NotBe(after[CompiledDependencies.ToolchainKey]); + after[CompiledDependencies.ContentKey] + .Should().Be(before[CompiledDependencies.ContentKey]); + } + + /// + /// The record's content key is folded over the ASSEMBLY entries only — never over the reserved + /// ! entries. Folding the toolchain id in would make the key move on every toolchain + /// commit, i.e. reproduce the proxy it replaces. + /// + [Fact] + public void TheRecordsContentKey_IsFoldedOverTheAssemblyEntriesOnly() + { + Func ids = name => name == "MeshWeaver.Layout" ? "ref:aaa" : null; + var digest = Digest(GeneratedSource); + var record = CompiledDependencies.Compute( + ["MeshWeaver.Layout"], ids, "mvid:toolchain-1", digest); + + record[CompiledDependencies.ContentKey].Should().Be( + GeneratedInputIdentity.Combine(digest, Pairs(("MeshWeaver.Layout", "ref:aaa")))); + } + + [Fact] + public void NoDigest_MeansNoContentKey_AndValidationIsUnchanged() + { + Func ids = _ => "ref:aaa"; + var record = CompiledDependencies.Compute(["MeshWeaver.Layout"], ids, "mvid:t"); + + record.Should().NotContainKey(CompiledDependencies.ContentKey); + CompiledDependencies.FindMismatch(record, ids, "mvid:t").Should().BeNull(); + } + + [Fact] + public void AStampedContentKey_IsSkippedWithoutALiveOne_AndDecisiveWithOne() + { + Func ids = _ => "ref:aaa"; + var record = CompiledDependencies.Compute( + ["MeshWeaver.Layout"], ids, "mvid:t", Digest(GeneratedSource)); + var stamped = record[CompiledDependencies.ContentKey]; + + // No live key (every metadata-only caller): skipped — the toolchain entry still governs. + CompiledDependencies.FindMismatch(record, ids, "mvid:t").Should().BeNull(); + // A caller that REGENERATED the input gets an exact verdict, both ways. + CompiledDependencies.FindMismatch(record, ids, "mvid:t", stamped).Should().BeNull(); + CompiledDependencies.FindMismatch(record, ids, "mvid:t", "i-something-else") + .Should().Contain(CompiledDependencies.ContentKey); + } + + // ── 4. a reference-surface change ⇒ the key MOVES ──────────────────────────────────────── + + [Fact] + public void AReferenceSurfaceChange_MovesTheKey() + { + var digest = Digest(GeneratedSource); + + var before = GeneratedInputIdentity.Combine(digest, Pairs( + ("MeshWeaver.Layout", "ref:aaa"), ("MeshWeaver.Mesh.Contract", "ref:bbb"))); + var afterSurfaceChange = GeneratedInputIdentity.Combine(digest, Pairs( + ("MeshWeaver.Layout", "ref:aaa"), ("MeshWeaver.Mesh.Contract", "ref:CHANGED"))); + var afterNewReference = GeneratedInputIdentity.Combine(digest, Pairs( + ("MeshWeaver.Layout", "ref:aaa"), ("MeshWeaver.Mesh.Contract", "ref:bbb"), + ("Some.Module", "mvid:ccc"))); + + afterSurfaceChange.Should().NotBe(before); + afterNewReference.Should().NotBe(before); + } + + [Fact] + public void ReferenceOrder_DoesNotMoveTheKey() + { + var digest = Digest(GeneratedSource); + var ascending = new[] { ("A.Ref", "ref:1"), ("B.Ref", "ref:2") }; + var descending = new[] { ("B.Ref", "ref:2"), ("A.Ref", "ref:1") }; + + GeneratedInputIdentity.Combine(digest, Pairs(descending)) + .Should().Be(GeneratedInputIdentity.Combine(digest, Pairs(ascending))); + } + + // ── the stage-1/stage-2 split ──────────────────────────────────────────────────────────── + + [Fact] + public void TheTwoStages_AreDistinguishableByPrefix() + { + var digest = Digest(GeneratedSource); + digest.Should().StartWith(GeneratedInputIdentity.GeneratedInputPrefix); + GeneratedInputIdentity.Combine(digest, Pairs()) + .Should().StartWith(GeneratedInputIdentity.ContentKeyPrefix); + } + + [Fact] + public void AnUnreadableGeneratorAssembly_IsRecordedAsAbsent_NotSkipped() + { + var identities = GeneratedInputIdentity.AssemblyFileIdentities( + [Path.Combine(Path.GetTempPath(), "definitely-not-here-4d1f.dll")]); + + identities.Should().ContainKey("definitely-not-here-4d1f.dll") + .WhoseValue.Should().Be(GeneratedInputIdentity.AbsentId); + } + + // ── why the full-MVID rule could NOT be deleted with this slice ───────────────────────── + + /// + /// 🚨 THE BLOCKER, pinned so it is checkable rather than an assertion in a PR body. + /// + /// #1707 slice 4 says the content key lets the full-MVID rule "be deleted entirely". It + /// does not — not on its own — and this test is why. The content key answers "were these bytes + /// produced from THIS input" exactly; what it cannot do is answer it CHEAPLY, because + /// evaluating it means regenerating the compile input (source discovery, includes, skeleton). + /// Every consumer that decides "rebuild or not" today is metadata-only, so it has no live value + /// to compare against and correctly skips the + /// entry. + /// + /// So with FullMvidAssemblies emptied, a body-only toolchain commit that DOES + /// change generated input — the #1802 global-usings fix, the AnchorIncludePath fix, + /// both real — moves NOTHING that any check looks at: the surface identity is unchanged + /// (no API moved), the toolchain entry is a constant, and the content key is skipped. The type + /// keeps bytes compiled from input that no longer exists, and the failure mode is a + /// TypeLoadException inside an ALC at activation with no diagnostic. + /// + /// What unblocks it is a re-evaluation lane, not a better key: something that + /// REGENERATES the input on a toolchain change and compares. The toolchain MVID then stops + /// being the invalidation unit and becomes the trigger for that comparison — which is the + /// demotion #1976 actually wants, and which this key is the precondition for. + /// + [Fact] + public void DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchain() + { + // Identical API surfaces; only the toolchain's IMPLEMENTATION moved — a body-only commit. + var surfaces = new Dictionary(StringComparer.Ordinal) + { + ["MeshWeaver.Compiler"] = "surface-unchanged", + }; + + // WITH the rule, the global identity moves and every stale-build check fires. + FrameworkBuildIdentity.ComputeSurfaceIdentity(surfaces, _ => "mvidBEFORE", ["MeshWeaver.Compiler"]) + .Should().NotBe(FrameworkBuildIdentity.ComputeSurfaceIdentity( + surfaces, _ => "mvidAFTER", ["MeshWeaver.Compiler"])); + + // WITHOUT it, the identity is the same value — nothing re-drives anything. + FrameworkBuildIdentity.ComputeSurfaceIdentity(surfaces, _ => "mvidBEFORE", []) + .Should().Be(FrameworkBuildIdentity.ComputeSurfaceIdentity(surfaces, _ => "mvidAFTER", [])); + + // …and the per-type record cannot cover for it: the toolchain entry has degenerated to a + // constant, and the content key is skipped because no metadata-only caller can compute a + // live one. A stamped record therefore validates forever across a toolchain change. + Func ids = _ => "ref:unchanged"; + const string ToolchainAfterDeletion = "mvid:constant"; + var record = CompiledDependencies.Compute( + ["MeshWeaver.Layout"], ids, ToolchainAfterDeletion, Digest(GeneratedSource)); + + CompiledDependencies.FindMismatch(record, ids, ToolchainAfterDeletion).Should().BeNull(); + // The SAME record, judged by a caller that regenerated, is decisive — which is the lane + // that has to exist before the rule can go. + var afterAToolchainChangeThatDidAlterTheInput = GeneratedInputIdentity.Combine( + Digest(GeneratedSource + "using System.Text;\n"), + Pairs(("MeshWeaver.Layout", "ref:unchanged"))); + CompiledDependencies.FindMismatch( + record, ids, ToolchainAfterDeletion, afterAToolchainChangeThatDidAlterTheInput) + .Should().Contain(CompiledDependencies.ContentKey); + } + + [Fact] + public void AssemblyFileIdentities_AreKeyedByFileName_NotByHostPath() + { + // A key carrying a host path could never match between a bake container and a portal pod. + GeneratedInputIdentity.AssemblyFileIdentities(["/a/b/Gen.dll"]) + .Keys.Should().Equal("Gen.dll"); + } +} From 75c478bf4943ce72960d1d199e0771b429d539bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Fri, 21 Aug 2026 16:46:55 +0200 Subject: [PATCH 2/5] compile: stamp the content key from both compile paths (#1707 slice 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key from the previous commit is only worth having if PRODUCERS record it. Both paths that emit a dynamic NodeType now compute it at the one moment the fully generated input exists as text — after the `#r` strip, before a single Roslyn call — and complete it at write-back with the pruned reference surfaces Roslyn proved the bytes actually bind: - `NodeSetCompiler.Compile` (the build-process bake, #1763) — it already had `inputs.GeneratedSource` in hand; - `MeshNodeCompilationService.CompileAsyncCore` (the runtime) — the digest rides out on a new `CompileEmit` through `CompileAttempt` to `CompileResultFromAssembly`, beside the assembly it describes. The two paths differ only in where the nodes came from, so their keys must agree for identical content — that is what makes a CI bake's bytes adoptable by a portal, and `GeneratorPipeline.EffectiveGeneratorPaths` is shared by the hash and the generator run so a key can never describe a compile that never happened. Paths where no compile ran — a disk-cache hit, the assembly-hydration shortcut — pass null and the record simply carries no content key, exactly as before it existed. Nothing yet CONSUMES the key: no metadata-only caller can compute a live one, which is the whole reason `FullMvidAssemblies` is still here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- src/MeshWeaver.Compiler/NodeSetCompiler.cs | 24 +++++- .../MeshNodeCompilationService.cs | 83 ++++++++++++++----- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/MeshWeaver.Compiler/NodeSetCompiler.cs b/src/MeshWeaver.Compiler/NodeSetCompiler.cs index f075e40c83..e8d3dc51ae 100644 --- a/src/MeshWeaver.Compiler/NodeSetCompiler.cs +++ b/src/MeshWeaver.Compiler/NodeSetCompiler.cs @@ -247,9 +247,31 @@ .. generatorPaths.Select(p => (MetadataReference)MetadataReference.CreateFromFil File.Exists(pdbPath) ? pdbPath : null, inputs, CompiledDependencies.Compute( - ReferencedAssemblyNames(artifact.DllPath), dependencyIdOf, toolchainId)); + ReferencedAssemblyNames(artifact.DllPath), dependencyIdOf, toolchainId, + GeneratedInputDigestOf(inputs, generatorPaths))); } + /// + /// The stage-1 CONTENT KEY digest of a resolved compile (#1707 slice 4): the generated text + /// plus everything else that decides the bytes given that text. Shared by this bake and the + /// runtime's MeshNodeCompilationService — the two paths differ only in where the nodes + /// came from, so their keys must agree for identical content, which is what makes a CI bake's + /// bytes adoptable by a portal. + /// + /// The NuGet-resolved assemblies are folded in as generator CANDIDATES: they are what + /// discovers over, and a change in what a + /// #r "nuget:" directive resolves to changes the emitted bytes. + /// + internal static string GeneratedInputDigestOf( + CompileInputs inputs, IReadOnlyList nugetAssemblyPaths) => + GeneratedInputIdentity.OfGeneratedInput( + inputs.AssemblyName, + inputs.GeneratedSource, + EmitPipeline.OptionsFingerprint, + GeneratedInputIdentity.CompilerIdentity, + GeneratedInputIdentity.AssemblyFileIdentities( + GeneratorPipeline.EffectiveGeneratorPaths(nugetAssemblyPaths))); + /// /// The include reader the tree bake hands : /// the ANCHORED path first, the authored path only as a fallback — the same two-step the mesh diff --git a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs index 5a747c5f4d..e48aca18d0 100644 --- a/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs +++ b/src/MeshWeaver.Graph/Configuration/MeshNodeCompilationService.cs @@ -167,7 +167,7 @@ private static IObservable BoundLeg( // path even though caller 1 had just finished). Entries clear once the // task settles so a future re-compile (e.g. after source edit) starts // fresh. - private readonly ConcurrentDictionary>> _inflightCompiles = + private readonly ConcurrentDictionary>> _inflightCompiles = new(StringComparer.Ordinal); // Query expansion lives in CodeQueryResolver (MeshWeaver.Compiler) so the NodeType @@ -274,15 +274,23 @@ private IObservable ResolveCodeIncludes( => GetAssemblyLocationWithLog(node).Select(t => t.Path); /// - /// One compile attempt's outcome: the assembly path (null on failure), the - /// , and — the point of carrying it — the ONE source snapshot - /// the attempt was taken against. Every downstream stage - /// (, ) - /// reuses instead of re-discovering; see + /// What one Roslyn emit produced: where the bytes landed, and the stage-1 CONTENT KEY digest + /// of the fully generated input that produced them (#1707 slice 4 — see + /// ). + /// + private readonly record struct CompileEmit(string? Path, string? InputDigest); + + /// + /// One compile attempt's outcome: the assembly path (null on failure), the CONTENT KEY digest + /// of the input that produced it (null when no compile ran — a disk-cache hit; the dependency + /// record then simply carries no content key), the , and — the point + /// of carrying it — the ONE source snapshot the attempt was taken against. Every downstream + /// stage (, ) + /// reuses Sources instead of re-discovering; see /// . /// private readonly record struct CompileAttempt( - string? Path, ActivityLog Log, IReadOnlyList Sources); + string? Path, string? InputDigest, ActivityLog Log, IReadOnlyList Sources); /// /// Companion to that also surfaces the @@ -325,7 +333,7 @@ private IObservable GetAssemblyLocationWithLog( if (string.IsNullOrEmpty(node.NodeType)) { logger.LogDebug("Node {NodePath} has no NodeType, skipping assembly compilation", node.Path); - return Observable.Return(new CompileAttempt(null, + return Observable.Return(new CompileAttempt(null, null, AppendInfo(log, $"Skipped — node '{node.Path}' has no NodeType.") .Finish((int)hub.Version, ActivityStatus.Succeeded), Array.Empty())); @@ -405,6 +413,8 @@ private IObservable GetAssemblyLocationWithLog( node.Path, cachedDllPath, effectiveLastModified); return Observable.Return(new CompileAttempt( cachedDllPath, + // No compile ran, so there is no generated input to key on. + null, AppendInfo(log, $"Cache hit — returning {cachedDllPath} (effective LastModified={effectiveLastModified:O}).") .Finish((int)hub.Version, ActivityStatus.Succeeded), @@ -415,7 +425,7 @@ private IObservable GetAssemblyLocationWithLog( // Hand the snapshot down as the override — CompileCore then short-circuits // its own SnapshotSources to this authoritative point-in-time set. return CompileCore(node, ntDef, selfPath, log, sources) - .Select(t => new CompileAttempt(t.Path, t.Log, sources)); + .Select(t => new CompileAttempt(t.Path, t.InputDigest, t.Log, sources)); }); }); } @@ -802,7 +812,7 @@ private IObservable DirectSourceProbe( /// no await on hub round-trips — both are the canonical deadlock /// patterns documented in Doc/Architecture/AsynchronousCalls.md. /// - private IObservable<(string? Path, ActivityLog Log)> CompileCore( + private IObservable<(string? Path, string? InputDigest, ActivityLog Log)> CompileCore( MeshNode node, NodeTypeDefinition? ntDef, string selfPath, ActivityLog log, IReadOnlyList? sourcesOverride = null) { @@ -928,8 +938,9 @@ private IObservable DirectSourceProbe( ct => OnThreadPool(() => CompileAsync(codeFile, configuration, contentCollections, node, ct)), _cacheOptions.RoslynCompileTimeout, "roslyn-compile", node.Path) - .Select(actualPath => + .Select(emit => { + var actualPath = emit.Path; ActivityLog finalLog; string? finalPath; if (cacheService.IsDiskCacheEnabled) @@ -959,7 +970,8 @@ private IObservable DirectSourceProbe( finalLog = AppendInfo(discoveryLog, $"Compiled assembly loaded in-memory ({actualPath})."); } - return (finalPath, finalLog.Finish((int)hub.Version, ActivityStatus.Succeeded)); + return (finalPath, emit.InputDigest, + finalLog.Finish((int)hub.Version, ActivityStatus.Succeeded)); }) // 🚨 THE SINGLE REPORTER of a compile failure. Every CompilationException — // Roslyn diagnostics from the disk emit (EmitCompilationToDirectory) or the @@ -970,7 +982,7 @@ private IObservable DirectSourceProbe( // same diagnostics too, which double-counted every failure in prod and put a // context-free record first — do not re-add a log next to a `throw new // CompilationException`. - .Catch<(string?, ActivityLog), CompilationException>(ex => + .Catch<(string?, string?, ActivityLog), CompilationException>(ex => { var diag = CompileDiagnostics.BuildSourceDiscoveryReport(executedQueries, matchedCodePaths); // 🚨 ORDER BY ACTIONABILITY — issue #1840. This report leads with the @@ -997,7 +1009,8 @@ private IObservable DirectSourceProbe( var failedLog = AppendError(discoveryLog, $"Compilation failed: {ex.Message}\n--- Source discovery ---\n{diag}") .Finish((int)hub.Version, ActivityStatus.Failed); - return Observable.Return<(string?, ActivityLog)>((null, failedLog)); + return Observable.Return<(string?, string?, ActivityLog)>( + (null, null, failedLog)); }); }); } @@ -1107,7 +1120,7 @@ private IObservable ValidateCellSurfaceSingleHome( IReadOnlyList? sourcesOverride = null) => GetAssemblyLocationWithLog(node, sourcesOverride).SelectMany(attempt => { - var (assemblyLocation, log, sources) = attempt; + var (assemblyLocation, _, log, sources) = attempt; if (string.IsNullOrEmpty(assemblyLocation)) // Failed compile: capture the per-source-file Roslyn diagnostics (one // LSP-style per-file-tree compilation of all this NodeType's src+test) so @@ -1144,7 +1157,8 @@ private IObservable ValidateCellSurfaceSingleHome( // the abandoned load thread is the honest cost. .SelectMany(snapshot => BoundLeg( _ => OnThreadPool(() => - CompileResultFromAssembly(node, assemblyLocation, log, snapshot)), + CompileResultFromAssembly( + node, assemblyLocation, log, snapshot, attempt.InputDigest)), _cacheOptions.AssemblyLoadTimeout, "assembly-load", node.Path)) // Re-Finish the log after CompileResultFromAssembly. CompileCore already // Finished it Succeeded, but CompileResultFromAssembly's downstream steps @@ -1435,9 +1449,14 @@ private IObservable ValidateCellSurfaceSingleHome( }); } + /// The stage-1 CONTENT KEY digest of the compile that + /// produced these bytes (#1707 slice 4), or null when no compile ran in this process — a + /// disk-cache hit or the assembly-hydration shortcut. Null simply leaves the record without a + /// content key; the toolchain entry still governs. private NodeCompilationResult? CompileResultFromAssembly( MeshNode node, string assemblyLocation, ActivityLog log, - ImmutableDictionary compiledSources) + ImmutableDictionary compiledSources, + string? generatedInputDigest = null) { var nodeName = cacheService.SanitizeNodeName(node.Path); @@ -1538,7 +1557,11 @@ private IObservable ValidateCellSurfaceSingleHome( var dependencies = Compiler.CompiledDependencies.Compute( assembly.GetReferencedAssemblies().Select(n => n.Name), NodeTypeCompilationHelpers.DependencyIdResolverOf(hub), - NodeTypeCompilationHelpers.ProcessToolchainId); + NodeTypeCompilationHelpers.ProcessToolchainId, + // The CONTENT KEY (#1707 slice 4) is completed here: stage 1 came from the + // emit, and the PRUNED reference surfaces are the record's own entries — the + // set Roslyn proved the bytes actually bind. + generatedInputDigest); return new NodeCompilationResult( assemblyLocation, configurations, log, compiledSources, @@ -1568,7 +1591,7 @@ private IObservable ValidateCellSurfaceSingleHome( /// Compiles CodeConfiguration into an assembly using Roslyn. /// Supports both disk-based and in-memory compilation. /// - private Task CompileAsync( + private Task CompileAsync( CodeConfiguration? codeFile, string? hubConfiguration, IReadOnlyList? contentCollections, @@ -1582,12 +1605,12 @@ private IObservable ValidateCellSurfaceSingleHome( // once the task settles so a future invalidation triggers a fresh // compile instead of returning the stale completed task. var lazy = _inflightCompiles.GetOrAdd(nodeName, n => - new Lazy>(() => RunCompileAndEvict( + new Lazy>(() => RunCompileAndEvict( codeFile, hubConfiguration, contentCollections, node, n, ct))); return lazy.Value; } - private async Task RunCompileAndEvict( + private async Task RunCompileAndEvict( CodeConfiguration? codeFile, string? hubConfiguration, IReadOnlyList? contentCollections, @@ -1605,7 +1628,7 @@ private IObservable ValidateCellSurfaceSingleHome( } } - private async Task CompileAsyncCore( + private async Task CompileAsyncCore( CodeConfiguration? codeFile, string? hubConfiguration, IReadOnlyList? contentCollections, @@ -1661,6 +1684,20 @@ private IObservable ValidateCellSurfaceSingleHome( // 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}"; + + // 🚨 THE CONTENT KEY's first stage (#1707 slice 4), taken HERE — the last point at which + // the fully generated input exists as text, and before a single Roslyn call. It keys the + // exact thing the toolchain's full-MVID proxy stands in for: what Roslyn is actually fed. + // The NuGet-resolved assemblies ride in as generator candidates, so a change in what a + // `#r "nuget:"` resolves to moves the key (GeneratorPipeline.EffectiveGeneratorPaths is + // the same set RunSourceGenerators loads — one resolution, no drift). + var generatedInputDigest = GeneratedInputIdentity.OfGeneratedInput( + assemblyName, + source, + EmitPipeline.OptionsFingerprint, + GeneratedInputIdentity.CompilerIdentity, + GeneratedInputIdentity.AssemblyFileIdentities( + GeneratorPipeline.EffectiveGeneratorPaths(nugetAssemblyPaths))); var compilation = GeneratorPipeline.RunSourceGenerators( EmitPipeline.CreateEmitCompilation( source, @@ -1685,6 +1722,6 @@ private IObservable ValidateCellSurfaceSingleHome( } logger.LogInformation("Successfully compiled assembly for {NodePath} to {ActualPath}", node.Path, actualPath); - return actualPath; + return new CompileEmit(actualPath, generatedInputDigest); } } From c9eccc76a369dbd5138530aa33052d2fa743c2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Fri, 21 Aug 2026 18:33:05 +0200 Subject: [PATCH 3/5] compile: the source join order is the SET's, not the delivery's (#1707 slice 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BakeEquivalenceTest` went red on the content key and the divergence was real: the compiler-driven bake produced the same key on three consecutive runs while the mesh-driven bake produced a different one every time. run 1 mesh ic920384a… tree i3494e62e… run 2 mesh iead1b585… tree i3494e62e… run 3 mesh i70db762d… tree i3494e62e… `CollectCompileSources` kept "the order the snapshot delivered them", and on the mesh path that delivery is `map.Values.ToList()` over an `ImmutableDictionary` (MeshNodeCompilationService's source probe) — hash-bucket order over string hashes that .NET randomises PER PROCESS. The join order is part of the emitted bytes, so the mesh compiled identical content into a different assembly on every process: not reproducible against the compiler-driven bake, and not reproducible against ITSELF. That is why the assembly store's digest of the emitted bytes could never dedupe, and it would have made the content key move on every compile — an identity that invalidates everything on every build, which is strictly worse than the proxy it replaces. So the order becomes a function of the SOURCE SET: ordinal by path, inside the toolchain function that already owns the join order, so both producers agree by construction rather than by convention. C# does not care — top-level declarations in a concatenated unit are order-independent, which is exactly what BakeEquivalenceTest's type-and-member SURFACE comparison proves, and it is what made the old non-determinism invisible. One-time effect: every NodeType's bytes change once, as they would on any identity move. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- src/MeshWeaver.Compiler/NodeCompileShaping.cs | 21 ++++- .../CompileSourceOrderTest.cs | 76 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 test/MeshWeaver.Graph.Test/CompileSourceOrderTest.cs diff --git a/src/MeshWeaver.Compiler/NodeCompileShaping.cs b/src/MeshWeaver.Compiler/NodeCompileShaping.cs index a8bacbb272..09ab420f3d 100644 --- a/src/MeshWeaver.Compiler/NodeCompileShaping.cs +++ b/src/MeshWeaver.Compiler/NodeCompileShaping.cs @@ -210,12 +210,29 @@ internal static IObservable RaceSourceSnapshot( /// /// 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. + /// non-executable Code nodes with non-blank code, ORDERED BY PATH (ordinal). /// 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). + /// + /// 🚨 The order is a function of the SOURCE SET, never of how it was delivered + /// (#1707 slice 4). It used to be "the order the snapshot delivered them", and on the mesh path + /// that delivery is ImmutableDictionary.Values — hash-bucket order over string hashes + /// that .NET RANDOMISES PER PROCESS. The join order is part of the emitted bytes, so the mesh + /// compile emitted a different assembly for identical content on every process: not + /// reproducible against another producer, and not reproducible against ITSELF. That is why the + /// assembly store's digest-of-the-emitted-bytes could never dedupe, and it made the + /// generated-input CONTENT KEY move on every compile — an identity that invalidates everything + /// on every build, which is strictly worse than the proxy it replaces. + /// + /// Measured on BakeEquivalenceTest before the sort: the compiler-driven bake + /// produced i3494e62e… on three consecutive runs while the mesh-driven bake produced + /// ic920384a…, iead1b585…, i70db762d…. Ordinal path order is what the + /// tree bake already effectively had, it is stable across processes, hosts and architectures, + /// and C# does not care: top-level declarations in a concatenated unit are order-independent, + /// which is exactly what that test's type-and-member SURFACE comparison proves. /// internal static (List Sources, List MatchedPaths) CollectCompileSources(IEnumerable matches, string nodePath, ILogger logger) @@ -223,7 +240,7 @@ internal static (List Sources, List MatchedPaths) var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var acc = new List(); var matchedPaths = new List(); - foreach (var n in matches) + foreach (var n in matches.OrderBy(n => n.Path, StringComparer.Ordinal)) { if (string.IsNullOrEmpty(n.Path) || !seen.Add(n.Path)) continue; diff --git a/test/MeshWeaver.Graph.Test/CompileSourceOrderTest.cs b/test/MeshWeaver.Graph.Test/CompileSourceOrderTest.cs new file mode 100644 index 0000000000..d37811397b --- /dev/null +++ b/test/MeshWeaver.Graph.Test/CompileSourceOrderTest.cs @@ -0,0 +1,76 @@ +#pragma warning disable CS1591 + +using MeshWeaver.Compiler; +using MeshWeaver.Mesh; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace MeshWeaver.Graph.Test; + +/// +/// 🚨 The compile's source ORDER is a function of the SOURCE SET, never of how it was delivered. +/// +/// The join order is part of the emitted bytes — NodeCompileShaping.CombineSources +/// concatenates in this order — and it used to be whatever order the snapshot arrived in. On the +/// mesh path that delivery is ImmutableDictionary.Values (see +/// MeshNodeCompilationService's source probe), i.e. hash-bucket order over string hashes +/// that .NET RANDOMISES PER PROCESS. So the mesh compiled the same content into a different +/// assembly on every process: not reproducible against the compiler-driven bake, and not +/// reproducible against itself. It is why the assembly store's digest of the emitted bytes could +/// never dedupe, and it made the generated-input content key move on every compile. +/// +/// Pinned here as a unit test as well as in BakeEquivalenceTest, because the +/// equivalence test is an expensive integration test in a different project and this property is +/// cheap to state: a reversed delivery must produce an identical compile unit. +/// +public class CompileSourceOrderTest +{ + private static MeshNode Code(string path, string code) => + new(path[(path.LastIndexOf('/') + 1)..], path[..path.LastIndexOf('/')]) + { + NodeType = "Code", + Content = new CodeConfiguration { Code = code, Language = "csharp" }, + }; + + [Fact] + public void CollectCompileSources_OrdersByPath_WhateverOrderTheSnapshotDelivered() + { + MeshNode[] delivered = + [ + Code("Widget/Thing/Test/ThingTests", "class T {}"), + Code("Lib/Shared/Source/Helper", "class H {}"), + Code("Widget/Thing/Source/Sub/Deep", "class D {}"), + Code("Widget/Thing/Source/Thing", "class Th {}"), + ]; + + var (_, matched) = NodeCompileShaping.CollectCompileSources( + delivered, "Widget/Thing", NullLogger.Instance); + + matched.Should().Equal( + "Lib/Shared/Source/Helper", + "Widget/Thing/Source/Sub/Deep", + "Widget/Thing/Source/Thing", + "Widget/Thing/Test/ThingTests"); + } + + [Fact] + public void TheCombinedCompileUnit_IsIdentical_UnderAnyDeliveryOrder() + { + MeshNode[] ascending = + [ + Code("Pkg/T/Source/A", "class A {}"), + Code("Pkg/T/Source/B", "class B {}"), + Code("Pkg/T/Source/C", "class C {}"), + ]; + var descending = ascending.Reverse().ToArray(); + + var one = NodeCompileShaping.CombineSources( + NodeCompileShaping.CollectCompileSources(ascending, "Pkg/T", NullLogger.Instance).Sources); + var other = NodeCompileShaping.CombineSources( + NodeCompileShaping.CollectCompileSources(descending, "Pkg/T", NullLogger.Instance).Sources); + + one!.Code.Should().Be(other!.Code); + // …and the order is the SET's, not the first caller's. + one.Code.Should().Be("class A {}\n\nclass B {}\n\nclass C {}"); + } +} From 3bf42ed6da75fbf72a4ca47b5def3835acab431b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Fri, 21 Aug 2026 18:33:05 +0200 Subject: [PATCH 4/5] compile: normalise the importer's node timestamp out of the content key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the source order fixed the two bakes still disagreed, and the mesh key was still different on every run (iaa2ed0ef…, ib704fb17… against a stable i770851b4…). The remaining wall clock is not in the generator: the skeleton emits the NodeType node's own `LastModified`, and `PackageInstaller.BulkSave` stamps that field with `DateTimeOffset.UtcNow` on EVERY import (PackageInstaller.cs:2158). Same repo content, imported twice, generates different text. That is fatal to the key rather than inconvenient. A CI bake and a portal import the same commit at different moments BY CONSTRUCTION, so a key that discriminated on the stamp could never match a published bundle against the input a portal regenerates — the adopt case the key exists to serve. Unlike the other three normalisations this one is a real trade and is documented as such: the value IS a string literal in the emitted IL, so two assemblies differing only in it hash equal. What differs is one provenance timestamp on the provider's node — not a type, a member, a signature or a reference — against the alternative of a key that cannot work at all. The match is anchored to the generator's exact emitted shape, so the only false positive available is a same-shaped line in user code, whose only consequence is that one timestamp literal stops discriminating; a differently-shaped mention still moves the key, which the test pins. With both fixes the mesh-driven and compiler-driven bakes agree, and agree on three consecutive runs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../GeneratedInputIdentity.cs | 31 +++++++++++++++++- .../GeneratedInputIdentityTest.cs | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs b/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs index 8a2f21e01c..2a8b123f19 100644 --- a/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs +++ b/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs @@ -61,6 +61,20 @@ namespace MeshWeaver.Compiler; /// hit. It is a comment: it contributes no IL. (It does move the PDB's document hash, which is /// why the assembly-store's digest-of-the-emitted-bytes can never dedupe two compiles of /// identical content either — the observation that motivates this type.) +/// The skeleton's LastModified = DateTimeOffset.Parse("…") line is +/// replaced by a fixed marker () — the SECOND wall +/// clock, and the one that is not obvious. The generator emits the NodeType node's own +/// LastModified into the provider's node, and PackageInstaller.BulkSave stamps that +/// field with DateTimeOffset.UtcNow on EVERY import. So the same repo content imported +/// twice generates different text: a key that discriminated on it would be unique per import, +/// never hit, and — decisively — a CI bake and a portal import the same commit at different +/// moments BY CONSTRUCTION, so a bundle could never match the input a portal would regenerate. +/// 🚨 This one is a real trade, unlike the other three: the value IS a string literal in the +/// emitted IL, so two assemblies differing only in it hash equal. What differs is one provenance +/// timestamp on the provider's node — not a type, a member, a signature or a reference — and the +/// alternative is a key that cannot work at all. The match is anchored to the generator's exact +/// emitted shape, so a same-shaped line in USER code is the only false positive available, and its +/// only consequence is that one timestamp literal stops discriminating. /// /// Nothing else is touched: no trimming, no whitespace collapsing, no comment stripping. Every /// other byte of the generated text is part of the key. @@ -82,6 +96,10 @@ public static class GeneratedInputIdentity /// The fixed text every // Generated at: … line normalises to. internal const string NormalizedGeneratedAtLine = "// Generated at: (normalized)"; + /// The fixed text the skeleton's node-timestamp line normalises to. + internal const string NormalizedLastModifiedLine = + " LastModified = DateTimeOffset.Parse(\"(normalized)\"),"; + /// /// Matches the skeleton's wall-clock header line. Anchored to a whole line (Multiline) and /// deliberately narrow — it must not touch a line of USER code that happens to mention the @@ -91,6 +109,16 @@ public static class GeneratedInputIdentity private static readonly Regex GeneratedAtLinePattern = new( @"^//[ \t]*Generated at:.*$", RegexOptions.Multiline | RegexOptions.Compiled); + /// + /// Matches the skeleton's node-timestamp line. Anchored to a whole line and to the generator's + /// exact emitted shape — the assignment, the DateTimeOffset.Parse call, a simple string + /// literal, the trailing comma — so it cannot swallow an arbitrary line of user code that + /// merely mentions LastModified. + /// + private static readonly Regex LastModifiedLinePattern = new( + @"^[ \t]*LastModified = DateTimeOffset\.Parse\(""[^""\\]*""\),[ \t]*$", + RegexOptions.Multiline | RegexOptions.Compiled); + /// /// The identity of the Roslyn that will do the compiling. A compiler upgrade can change the /// emitted bytes for identical input, and it is NOT covered by anything else here: the @@ -123,7 +151,8 @@ public static string NormalizeGeneratedSource(string? source) var text = source[0] == '\uFEFF' ? source[1..] : source; text = text.Replace("\r\n", "\n", StringComparison.Ordinal) .Replace('\r', '\n'); - return GeneratedAtLinePattern.Replace(text, NormalizedGeneratedAtLine); + text = GeneratedAtLinePattern.Replace(text, NormalizedGeneratedAtLine); + return LastModifiedLinePattern.Replace(text, NormalizedLastModifiedLine); } /// diff --git a/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs b/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs index d60c08d3d1..728be767e9 100644 --- a/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs +++ b/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs @@ -120,6 +120,38 @@ public void TheGeneratorsWallClockHeader_IsNormalizedOut() Digest(inUserCode).Should().NotBe(Digest(GeneratedSource)); } + /// + /// 🚨 THE SECOND WALL CLOCK, and the one that is not obvious. The skeleton emits the NodeType + /// node's own LastModified into the provider's node, and PackageInstaller.BulkSave + /// stamps that field with DateTimeOffset.UtcNow on EVERY import — so the same repo + /// content imported twice generates different text. A CI bake and a portal import the same + /// commit at different moments by construction, so a key that discriminated on it could never + /// match a bundle against the input a portal regenerates. + /// + /// Caught by BakeEquivalenceTest: with only the source order fixed, the mesh + /// producer still emitted a different key on every run (iaa2ed0ef…, ib704fb17…) + /// while the compiler producer sat on i770851b4…. + /// + [Fact] + public void TheNodeTimestampStamp_IsNormalizedOut_ButOnlyInItsGeneratedShape() + { + const string WithStamp = " [\n new MeshNode(\"Demo/Thing\")\n" + + " {\n" + + " LastModified = DateTimeOffset.Parse(\"2026-08-21T09:14:02.1234567+00:00\"),\n" + + " HubConfiguration = ConfigureHub\n }\n ];\n"; + var reimported = WithStamp.Replace( + "2026-08-21T09:14:02.1234567+00:00", "2027-03-04T11:22:33.4455667+00:00", + StringComparison.Ordinal); + reimported.Should().NotBe(WithStamp); + + Digest(GeneratedSource + reimported).Should().Be(Digest(GeneratedSource + WithStamp)); + + // …and it is anchored to the generator's emitted SHAPE: a user-code line that merely + // mentions the member, or a different call shape, is content and still moves the key. + Digest(GeneratedSource + "public string LastModified = \"2026-01-01\";\n") + .Should().NotBe(Digest(GeneratedSource + "public string LastModified = \"2027-01-01\";\n")); + } + // ── 2. a generator body change that alters the generated input ⇒ the key MOVES ─────────── /// From 4834705dce8d9c108e5a1479483ce103873c60b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Fri, 21 Aug 2026 18:39:47 +0200 Subject: [PATCH 5/5] compile: a colliding generator file name aggregates, never overwrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #1994. `AssemblyFileIdentities` keyed by file name and assigned, so two DIFFERENT assemblies resolving to the same file name — two NuGet packages, or two target-framework folders, both of which `RunSourceGenerators` loads — silently dropped one from the key. Two things wrong with that, and the second is the reason it matters here. The key would claim "same input" for a genuinely different effective generator set, which is the UNDER-invalidating direction: stale bytes, the outcome this whole slice is written to avoid. And which of the two survived depended on enumeration order, reintroducing exactly the order-dependence the type exists to remove — the same defect as the source-join order two commits ago, one level down. The value is now the ordinal-sorted, de-duplicated SET of identities for that file name. The same assembly reached by two paths still collapses to one id, because it IS one input. Keyed by file name rather than path is unchanged and deliberate: a host path could never match between a bake container and a portal pod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../GeneratedInputIdentity.cs | 36 +++++++++++--- .../GeneratedInputIdentityTest.cs | 48 +++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs b/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs index 2a8b123f19..d18dde77f9 100644 --- a/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs +++ b/src/MeshWeaver.Compiler/GeneratedInputIdentity.cs @@ -226,26 +226,48 @@ public static string Combine( } /// - /// Assembly file name → implementation MVID ("N" format), read METADATA-ONLY so nothing is - /// loaded into the process. Keyed by FILE NAME rather than by full path: a path is a property - /// of the host's layout, and a key that carried one could never match between a bake container - /// and a portal pod. A missing or unreadable file resolves — absence is - /// part of the key. + /// Assembly file name → its implementation MVID(s) ("N" format), read METADATA-ONLY so nothing + /// is loaded into the process. Keyed by FILE NAME rather than by full path: a path is a + /// property of the host's layout, and a key that carried one could never match between a bake + /// container and a portal pod. A missing or unreadable file resolves — + /// absence is part of the key. + /// + /// 🚨 A file name can be AMBIGUOUS, so the value is a SET, not the last writer. + /// NuGet resolution can hand back two different assemblies with the same file name (two + /// packages, or two target-framework folders), and RunSourceGenerators loads BOTH paths. + /// Overwriting on collision would drop one of them from the key: the key would then claim + /// "same input" for a genuinely different effective generator set — the UNDER-invalidating + /// direction, i.e. stale bytes — and which one survived would depend on enumeration order, + /// reintroducing exactly the order-dependence this type exists to remove. So colliding entries + /// are de-duplicated and joined as ordinal-sorted MVIDs (). + /// The same assembly resolved twice by different paths still collapses to one id, which is + /// correct: it IS one input. /// public static ImmutableSortedDictionary AssemblyFileIdentities( IEnumerable assemblyPaths) { ArgumentNullException.ThrowIfNull(assemblyPaths); - var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + var byName = new SortedDictionary>(StringComparer.Ordinal); foreach (var path in assemblyPaths) { if (string.IsNullOrEmpty(path)) continue; - builder[Path.GetFileName(path)] = ReadMvid(path) ?? AbsentId; + var name = Path.GetFileName(path); + if (!byName.TryGetValue(name, out var ids)) + byName[name] = ids = new SortedSet(StringComparer.Ordinal); + ids.Add(ReadMvid(path) ?? AbsentId); } + var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var (name, ids) in byName) + builder[name] = string.Join(IdentitySeparator, ids); return builder.ToImmutable(); } + /// Joins the several MVIDs a single file NAME resolved to — see + /// . Not a hex digit, so it can never be mistaken for part + /// of one MVID. + public const string IdentitySeparator = "+"; + private static string? ReadMvid(string path) { if (!File.Exists(path)) diff --git a/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs b/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs index 728be767e9..cfc5a0be8b 100644 --- a/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs +++ b/test/MeshWeaver.Graph.Test/GeneratedInputIdentityTest.cs @@ -394,6 +394,54 @@ public void DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchain() .Should().Contain(CompiledDependencies.ContentKey); } + /// + /// 🚨 A file NAME can be ambiguous — NuGet can resolve two different assemblies to the same + /// file name (two packages, or two TFM folders), and RunSourceGenerators loads BOTH. + /// Overwriting on collision would drop one from the key, so the key would claim "same input" + /// for a genuinely different effective generator set — the UNDER-invalidating direction — and + /// which one survived would depend on enumeration order, reintroducing the order-dependence + /// this type exists to remove. + /// + [Fact] + public void ACollidingGeneratorFileName_AggregatesEveryIdentity_OrderIndependently() + { + var dir = Directory.CreateTempSubdirectory("mw-gen-collide-"); + try + { + // Two DIFFERENT unreadable-but-present files would both resolve `absent`, which cannot + // show aggregation — so use the real toolchain assemblies, which have distinct MVIDs, + // copied under ONE shared file name in two directories. + var real = typeof(GeneratedInputIdentity).Assembly.Location; + var other = typeof(Xunit.FactAttribute).Assembly.Location; + var a = Path.Combine(dir.FullName, "a"); Directory.CreateDirectory(a); + var b = Path.Combine(dir.FullName, "b"); Directory.CreateDirectory(b); + var first = Path.Combine(a, "Gen.dll"); + var second = Path.Combine(b, "Gen.dll"); + File.Copy(real, first); + File.Copy(other, second); + + var ascending = GeneratedInputIdentity.AssemblyFileIdentities([first, second]); + var descending = GeneratedInputIdentity.AssemblyFileIdentities([second, first]); + + ascending.Keys.Should().Equal("Gen.dll"); + // BOTH identities are represented, and the order they arrived in does not matter. + ascending["Gen.dll"].Should().Contain(GeneratedInputIdentity.IdentitySeparator); + descending["Gen.dll"].Should().Be(ascending["Gen.dll"]); + + // …and dropping one of the two MUST move the key — that is the whole point. + GeneratedInputIdentity.AssemblyFileIdentities([first])["Gen.dll"] + .Should().NotBe(ascending["Gen.dll"]); + + // The SAME assembly reached by two paths is ONE input, so it collapses to one id. + var duplicated = GeneratedInputIdentity.AssemblyFileIdentities([first, first]); + duplicated["Gen.dll"].Should().NotContain(GeneratedInputIdentity.IdentitySeparator); + } + finally + { + dir.Delete(recursive: true); + } + } + [Fact] public void AssemblyFileIdentities_AreKeyedByFileName_NotByHostPath() {