From c8585b2e700e0756168ebd6ed3e0931cf148d17e Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Fri, 9 Oct 2026 17:11:37 +0200 Subject: [PATCH] fix: build cycle exception paths lazily Retain an inline first segment and immutable ancestor links instead of copying full path prefixes on successful recursion. Preserve exact exception metadata, active-path cleanup, shallow allocation behavior, and portable generated code. Add allocation, runtime, sample, and package-consumer coverage. Fixes #32. Co-authored-by: Codex --- CHANGELOG.md | 1 + DECISIONS.md | 8 + IMPLEMENTATION_PLAN.md | 8 + PERFORMANCE_OPTIMIZATION_NOTES.md | 20 ++ STATUS.md | 12 + docs/USAGE.md | 2 + .../LazyCyclePathExample.cs | 286 +++++++++++++++ .../Program.cs | 1 + .../LiteMapperGenerator.cs | 54 ++- .../LazyCyclePathTests.cs | 329 ++++++++++++++++++ .../Milestone12RecursiveCycleTests.cs | 2 +- .../ConsumerMatrixTests.cs | 5 +- .../Milestone14PackagingAndAotTests.cs | 3 + 13 files changed, 718 insertions(+), 13 deletions(-) create mode 100644 samples/Mammoth.LiteMapper.Samples.Basic/LazyCyclePathExample.cs create mode 100644 tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 869f003..ae27bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- Issue #32: recursive cycle paths retain compact immutable segments and format the full path only when a cycle is detected, removing quadratic path-prefix allocations while preserving exception metadata and first-edge allocation behavior. [#32](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/32) - Issue #31: dictionary mappings preserve compatible key comparers independently of value conversion. Keys transformed by custom converters use the destination default comparer, even when key types match. [#31](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/31) - Issue #30: set mappings probe exact accessible HashSet capacity constructors and use portable fallbacks when absent, including on netstandard2.0. Capacity-plus-comparer support is detected independently. [#30](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/30) - Issue #28: IgnoreCase rejects multiple case-insensitive source matches even when one is exact; nested structural ambiguity retains LITEMAPPER1004. Exact and ExactThenIgnoreCase keep their existing exact-match priority. [#28](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/28) diff --git a/DECISIONS.md b/DECISIONS.md index 5473bc0..898f98f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Decision Log +## Issue #32: immutable diagnostic paths with an inline first segment (2026-10-09) + +- Context: each tracked member descent concatenated the complete prefix even when mapping succeeded. Current-develop measurements confirm increasing allocation at safe depths; collection bodies and acyclic/value bridges must retain the same path without adding segments. +- Decision: pass a private readonly Path value containing its literal leaf and an immutable ancestor link. The first segment stays inline; only extension of a nonempty path allocates a link. This preserves shallow and wide first-edge allocation behavior without mutable path-stack cleanup. The existing active-reference Enter/finally/Exit scopes and single tracker field remain unchanged. +- Formatting: only duplicate-reference Enter calls Path.ToString; two iterative passes count and reverse-fill a segment array, then join once. Successful active-path storage and total chain-link allocation are O(depth), with no copied full prefixes, source-object retention, reflection, or public API. Empty root collection paths and method metadata keep their existing meaning. +- Contract: sections 5.9, 14.1, 17.2-17.6, 19.5, 22.14, 23.3, and 28.7 already permit this private representation; no specification semantics change is needed. A mutable push/pop stack was avoided because context-only bridges and failed Enter calls would require new cleanup scopes. +- Evidence: the initial 13-case suite has two allocation/storage failures and 11 controls on unchanged production. At depth 256 total allocations fall from 421,520 to 32,392 bytes per mapping; depth 1/2 and 256 shallow sibling chains are unchanged. These are allocation measurements, not throughput or general stack-safety claims. See PERFORMANCE_OPTIMIZATION_NOTES.md for method and full results. + ## Issue #31: classify the selected dictionary key conversion (2026-10-09) - Context: the shared comparer predicate compared dictionary value types, so widening or structurally mapping values discarded an otherwise compatible key comparer. It also preserved comparers for same-type keys without checking whether a custom conversion was selected. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 4ca0ce3..e8e3cc0 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #32: lazy cycle diagnostic paths (2026-10-09) + +- Scope: avoid copying full path prefixes on successful recursive descent, preserving exact exception paths and public entry names, active-reference semantics, acyclic bridges, collections, and value-type traversal. No public API or specification semantics change. +- Test-first: measure current allocations at safe depths 16/32/64/128/256, then add linear-storage/scaling checks and exact-path, sibling, concurrency, cleanup, deterministic C# 9, and nonrecursive allocation controls before production edits. +- Implementation: use compact private path state and format the full string only on cycle detection. Keep reference Enter/finally/Exit behavior and one tracker per recursive call. +- Validation: focused/full generator, runtime/integration, source and clean-package consumers, framework/language matrix, available packaging/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI. Report allocation evidence separately from throughput. +- Boundary: issue #32 only, then draft PR/human review. No merge, release, tag, package publication, or workflow dispatch. AgentStack CLI is absent; authorized connector fallback adds/readbacks readiness labels without issue comments or claim writes. + ## Issue #31: dictionary key comparer preservation (2026-10-09) - Scope: preserve a concrete Dictionary source's compatible key comparer when key resolution selects language identity, independently of value conversion. Converted keys use the destination default comparer; retain Add collision semantics and existing set behavior. diff --git a/PERFORMANCE_OPTIMIZATION_NOTES.md b/PERFORMANCE_OPTIMIZATION_NOTES.md index 9487358..a50902d 100644 --- a/PERFORMANCE_OPTIMIZATION_NOTES.md +++ b/PERFORMANCE_OPTIMIZATION_NOTES.md @@ -2,6 +2,26 @@ These notes are non-authoritative follow-up candidates. `SPECIFICATION.md` remains the product contract. +## Issue #32: lazy cycle-path allocation evidence (2026-10-09) + +- Baseline: unchanged develop `7b19def74753df90cb3770c7410ff5d7a3951244`. Candidate: issue #32's private inline-leaf/immutable-link diagnostic path representation. +- Environment: Debian 13 Linux x64 cloud container, AMD EPYC 9V74 host as exposed by `/proc/cpuinfo`, SDK 10.0.401, runtime 10.0.12, Roslyn 4.8.0, Release compilation, C# 9, `DOTNET_PROCESSOR_COUNT=1`. This is not a controlled release throughput benchmark. +- Harness: `LazyCyclePathTests.SuccessfulChainAllocationsScaleLinearlyAtSafeDepths`, using `GC.GetAllocatedBytesForCurrentThread`. Each depth uses 40 warmups followed by three rounds of 100 mappings. Source construction and reporting are outside measurement; each mapped chain is validated without allocating. All three rounds give identical values. The no-tracking control allocates destination nodes only. + +| Chain depth | Previous ThrowOnCycle bytes/op | Lazy-path bytes/op | None bytes/op (unchanged) | +|---:|---:|---:|---:| +| 16 | 3,032 | 1,744 | 512 | +| 32 | 9,200 | 3,560 | 1,024 | +| 64 | 31,000 | 7,440 | 2,048 | +| 128 | 111,792 | 15,528 | 4,096 | +| 256 | 421,520 | 32,392 | 8,192 | + +`ShallowAndWideTraversalAllocationControls` additionally measures depths 1 and 2 at 232 and 264 bytes/op, and an array of 256 sibling depth-2 chains at 18,656 bytes/op. These values are unchanged by the optimization. Runtime manual controls verify that first named edges introduce no path-object allocation and that shallow siblings share only one tracker overhead. + +Generated source retains one literal segment per immutable link and no materialized prefix; formatting occurs only in the duplicate-reference throwing branch. This establishes linear active diagnostic-path storage by code structure. The numbers above are total managed allocations, including destination objects and tracker/HashSet growth, not isolated diagnostic allocations or peak-memory measurements. No latency, throughput, release acceptance, or general stack-safety improvement is claimed. Safe test depth is capped at 256. + +Reproduce after explicit restore/build with `dotnet test tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj -c Release --no-build --no-restore --filter LazyCyclePathTests --logger trx`; per-round measurements are retained in TRX standard output. The existing `RecursiveCycleAllocationBenchmarks` remains available for controlled BenchmarkDotNet throughput comparison; its historical manual tracker does not build public exception paths and is not a diagnostic-path allocation baseline. + ## Flat-object benchmark observations - Benchmark: `FlatObjectBenchmarks`. diff --git a/STATUS.md b/STATUS.md index 6dbee0e..756bd5a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,17 @@ # Mammoth.LiteMapper Status +## GitHub issue #32 lazy cycle-path checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop 7b19def74753df90cb3770c7410ff5d7a3951244 after PR #45 merged. Issue #32 was the only open issue, with no blocked-by dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added/read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. +- Red-first: the initial 13-case C# 9 suite produced two allocation/storage failures and 11 passing controls, zero skips, on unchanged production. The tracked chain retained quadratic full-string prefixes. Exact metadata, sibling/concurrent calls, same-tracker cleanup, and None controls were already passing; no expected runtime contract was changed. +- Implementation: private readonly Path values keep the first literal segment inline and allocate immutable ancestor links only when extending a nonempty path. Duplicate-reference Enter formats the path with two iterative passes and one join. Active-reference Enter/finally/Exit scopes, public names, collection forwarding, and the one-field tracker remain unchanged. No public API, reflection, runtime dispatch, or specification semantics change. +- Allocation evidence: depths 16/32/64/128/256 improve from 3,032/9,200/31,000/111,792/421,520 to 1,744/3,560/7,440/15,528/32,392 total bytes per mapping in three identical rounds. None remains 512/1,024/2,048/4,096/8,192; shallow depths 1/2 remain 232/264 and 256 sibling depth-2 chains remain 18,656. Runtime manual controls verify no added first-edge path allocation. PERFORMANCE_OPTIMIZATION_NOTES.md records environment and method; no throughput, peak-memory, release, or general stack-safety claim is made. +- Core evidence: final Release solution build has zero warnings/errors. All 917 generator tests, five runtime tests, and one integration test pass with zero failures/skips. All 13 focused cases and three source samples pass. The new shared Basic example covers depth 256, exact direct/indirect/bridged collection paths, public method names, shared siblings, and later calls after exceptions. Independent read-only review, consumer-skill validation, and CRLF-aware diff checks pass. +- Package evidence: Available packaging retry: 224 passed, two infrastructure failures, zero skips. All 12 framework/language combinations, normal/clean-package consumers, usage validation, package/API checks, and benchmark smoke pass. The shared cycle example executes in each framework/language consumer, including genuine netstandard2.0 references; actual netstandard2.0/C#9 generated source was inspected. Trimming and Native AOT fail before publish behavior is established at ILLink ComputeManagedAssemblies task-host creation (MSB4216 / Unix-domain SocketException 13). No local trimming/AOT success is claimed. +- Validation recovery: the first candidate passed 111 of 112 focused cases; one existing string-specific source regex was updated solely for the new representation while retaining its runtime assertions. Strengthening shallow allocation controls exposed CS0762 in the test input before source generation; a lambda wrapper corrected the fixture without changing production or weakening the clean-input check. A complete 917-test rerun then passed. The first available-package attempt was interrupted by automatic approval-review cancellation and produced no final TRX; it is not completion evidence. +- Environment: existing official SDK 10.0.401, runtimes 8.0.31/9.0.20/10.0.12, task-local temporary files/caches, and one-processor builds. PowerShell and clang are absent; 49 PowerShell-dependent cases are unavailable locally. No assertion, warning, dependency, prerequisite, credential, or security setting was weakened. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR. +- Boundary: issue #32 only, then human review. The previously documented nullable-intermediate enum defect remains outside scope. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch. + ## GitHub issue #31 dictionary comparer checkpoint (2026-10-09) - Base: isolated cloud worktree from develop c973aac8e457cc40a4069889aeab49d8278ccca3 after PR #44 merged. Issue #31 was the older remaining P3, with no blocked-by dependencies, sub-issues, or competing PR. Explicitly authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. diff --git a/docs/USAGE.md b/docs/USAGE.md index eb4a3b2..8c49106 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -898,6 +898,8 @@ public static partial class CycleMapper `ReferenceHandling.ThrowOnCycle` enables generated cycle tracking for recursive type graphs. A detected cycle throws `LiteMapperCycleException`. Non-recursive mappings do not allocate cycle-tracker state. +Successful recursive traversal retains compact diagnostic path segments rather than allocating a full path string at every level. The complete `MemberPath` is formatted only when a cycle is detected; active diagnostic-path storage grows linearly with depth. This does not remove the call-stack limit of recursive mapping. The [Basic cycle example](../samples/Mammoth.LiteMapper.Samples.Basic/LazyCyclePathExample.cs) exercises finite chains, exact direct/indirect/collection paths, shared siblings, and later calls after exceptions. + Acyclic object and collection wrappers can lead into a recursive graph. The same tracker is forwarded through those wrappers, while only recursive reference objects participate in cycle detection. The Basic sample maps `WrappedSource.Envelope.Nodes`; a self-cycle in a node reports `Envelope.Nodes.Next`, and a shared node in separate collection positions maps independently. `LiteMapperCycleException` exposes `SourceType`, `DestinationType`, `MappingMethod`, and `MemberPath` for the detected active-path cycle. It does not expose `SourcePath` or `DestinationPath` properties. diff --git a/samples/Mammoth.LiteMapper.Samples.Basic/LazyCyclePathExample.cs b/samples/Mammoth.LiteMapper.Samples.Basic/LazyCyclePathExample.cs new file mode 100644 index 0000000..e805ea9 --- /dev/null +++ b/samples/Mammoth.LiteMapper.Samples.Basic/LazyCyclePathExample.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using Mammoth.LiteMapper; + +namespace Mammoth.LiteMapper.Samples.Basic +{ + public static class LazyCyclePathExample + { + public static void Run() + { + CheckFiniteDepthAndConverterRecovery(); + CheckDirectCycleAndRecovery(); + CheckIndirectCycle(); + CheckBridgedCollectionCycle(); + CheckSharedSiblingBranches(); + } + + private static void CheckFiniteDepthAndConverterRecovery() + { + var source = BuildDirectChain(256); + var mapped = LazyCyclePathMapper.MapDirect(source); + if (Count(mapped) != 256) + { + throw new InvalidOperationException("A finite recursive chain must map to its full depth."); + } + + LazyCyclePathMapper.ThrowOnValue(256); + try + { + LazyCyclePathMapper.MapDirect(source); + throw new InvalidOperationException("The configured converter failure was not observed."); + } + catch (InvalidOperationException error) when (error.Message == "Expected converter failure.") + { + } + + if (Count(LazyCyclePathMapper.MapDirect(source)) != 256) + { + throw new InvalidOperationException("Mapping must succeed after a converter exception."); + } + } + + private static void CheckDirectCycleAndRecovery() + { + var source = new LazyDirectSource { Value = 7 }; + source.Next = source; + ExpectCycle( + () => LazyCyclePathMapper.MapDirect(source), + typeof(LazyDirectSource), + typeof(LazyDirectTarget), + "MapDirect", + "Next"); + + source.Next = null; + var mapped = LazyCyclePathMapper.MapDirect(source); + if (mapped.Value != 7 || mapped.Next != null) + { + throw new InvalidOperationException("Mapping must succeed after a cycle exception."); + } + } + + private static void CheckIndirectCycle() + { + var source = new LazyIndirectASource(); + source.B = new LazyIndirectBSource { A = source }; + ExpectCycle( + () => LazyCyclePathMapper.MapIndirect(source), + typeof(LazyIndirectASource), + typeof(LazyIndirectATarget), + "MapIndirect", + "B.A"); + } + + private static void CheckBridgedCollectionCycle() + { + var node = new LazyBridgeNodeSource { Value = 11 }; + node.Next = node; + var source = new LazyBridgeRootSource + { + Envelope = new LazyBridgeEnvelopeSource + { + Nodes = new[] { node }, + }, + }; + ExpectCycle( + () => LazyCyclePathMapper.MapBridged(source), + typeof(LazyBridgeNodeSource), + typeof(LazyBridgeNodeTarget), + "MapBridged", + "Envelope.Nodes.Next"); + } + + private static void CheckSharedSiblingBranches() + { + var shared = new LazySiblingNodeSource + { + Value = 13, + Next = new LazySiblingNodeSource { Value = 17 }, + }; + var mapped = LazyCyclePathMapper.MapSiblings(new LazySiblingRootSource + { + Left = shared, + Right = shared, + }); + if (mapped.Left.Value != 13 || mapped.Right.Next?.Value != 17 || + ReferenceEquals(mapped.Left, mapped.Right) || + ReferenceEquals(mapped.Left.Next, mapped.Right.Next)) + { + throw new InvalidOperationException("Shared sibling branches must map independently without being treated as cycles."); + } + } + + private static LazyDirectSource BuildDirectChain(int depth) + { + var root = new LazyDirectSource { Value = 1 }; + var current = root; + for (var value = 2; value <= depth; value++) + { + var next = new LazyDirectSource { Value = value }; + current.Next = next; + current = next; + } + + return root; + } + + private static int Count(LazyDirectTarget source) + { + var count = 0; + for (LazyDirectTarget? current = source; current != null; current = current.Next) + { + count++; + } + + return count; + } + + private static void ExpectCycle( + Action map, + Type sourceType, + Type destinationType, + string mappingMethod, + string memberPath) + { + try + { + map(); + } + catch (LiteMapperCycleException error) when ( + error.SourceType == sourceType && + error.DestinationType == destinationType && + error.MappingMethod == mappingMethod && + error.MemberPath == memberPath) + { + return; + } + + throw new InvalidOperationException( + "Cycle detection did not report the expected public mapping method and member path."); + } + } + + [LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] + public static partial class LazyCyclePathMapper + { + private static int? throwOnValue; + + public static partial LazyDirectTarget MapDirect(LazyDirectSource source); + + public static partial LazyIndirectATarget MapIndirect(LazyIndirectASource source); + + public static partial LazyBridgeRootTarget MapBridged(LazyBridgeRootSource source); + + public static partial LazySiblingRootTarget MapSiblings(LazySiblingRootSource source); + + internal static void ThrowOnValue(int value) => throwOnValue = value; + + [MappingConverter] + private static int MapValue(int value) + { + if (throwOnValue == value) + { + throwOnValue = null; + throw new InvalidOperationException("Expected converter failure."); + } + + return value; + } + } + + public sealed class LazyDirectSource + { + public int Value { get; set; } + + public LazyDirectSource? Next { get; set; } + } + + public sealed class LazyDirectTarget + { + public int Value { get; set; } + + public LazyDirectTarget? Next { get; set; } + } + + public sealed class LazyIndirectASource + { + public LazyIndirectBSource? B { get; set; } + } + + public sealed class LazyIndirectBSource + { + public LazyIndirectASource? A { get; set; } + } + + public sealed class LazyIndirectATarget + { + public LazyIndirectBTarget? B { get; set; } + } + + public sealed class LazyIndirectBTarget + { + public LazyIndirectATarget? A { get; set; } + } + + public sealed class LazyBridgeRootSource + { + public LazyBridgeEnvelopeSource Envelope { get; set; } = new LazyBridgeEnvelopeSource(); + } + + public sealed class LazyBridgeEnvelopeSource + { + public LazyBridgeNodeSource[] Nodes { get; set; } = new LazyBridgeNodeSource[0]; + } + + public sealed class LazyBridgeNodeSource + { + public int Value { get; set; } + + public LazyBridgeNodeSource? Next { get; set; } + } + + public sealed class LazyBridgeRootTarget + { + public LazyBridgeEnvelopeTarget Envelope { get; set; } = new LazyBridgeEnvelopeTarget(); + } + + public sealed class LazyBridgeEnvelopeTarget + { + public List Nodes { get; set; } = new List(); + } + + public sealed class LazyBridgeNodeTarget + { + public int Value { get; set; } + + public LazyBridgeNodeTarget? Next { get; set; } + } + + public sealed class LazySiblingRootSource + { + public LazySiblingNodeSource Left { get; set; } = new LazySiblingNodeSource(); + + public LazySiblingNodeSource Right { get; set; } = new LazySiblingNodeSource(); + } + + public sealed class LazySiblingNodeSource + { + public int Value { get; set; } + + public LazySiblingNodeSource? Next { get; set; } + } + + public sealed class LazySiblingRootTarget + { + public LazySiblingNodeTarget Left { get; set; } = new LazySiblingNodeTarget(); + + public LazySiblingNodeTarget Right { get; set; } = new LazySiblingNodeTarget(); + } + + public sealed class LazySiblingNodeTarget + { + public int Value { get; set; } + + public LazySiblingNodeTarget? Next { get; set; } + } +} diff --git a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs index 974fbbe..07047ce 100644 --- a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs @@ -8,6 +8,7 @@ internal static class Program { private static void Main() { + LazyCyclePathExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); var staticTarget = StaticMapper.Map(new StaticSource diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index c980ad1..ced689d 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -4033,7 +4033,7 @@ private static void AppendDeclaredTrackingWrapper(StringBuilder builder, Mapping { builder.Append(", "); } - builder.AppendLine(trackerLocal + ", string.Empty, \"" + method.Name + "\");"); + builder.AppendLine(trackerLocal + ", default(__LiteMapperCycleTracker.Path), \"" + method.Name + "\");"); builder.AppendLine(" }"); } @@ -4149,7 +4149,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H } if (trackedHelper) { - builder.Append(", __LiteMapperCycleTracker " + trackerLocal + ", string " + memberPathLocal + ", string " + mappingMethodLocal); + builder.Append(", __LiteMapperCycleTracker " + trackerLocal + ", __LiteMapperCycleTracker.Path " + memberPathLocal + ", string " + mappingMethodLocal); } builder.AppendLine(")"); @@ -4205,7 +4205,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H if (trackedSource) { - builder.AppendLine(" " + trackerLocal + ".Enter(" + EscapeIdentifier(parameter.Name) + "!, typeof(" + DisplayType(sourceType).TrimEnd('?') + "), typeof(" + DisplayType(targetType).TrimEnd('?') + "), " + mappingMethodExpression + ", " + (trackedPublicEntry ? "string.Empty" : memberPathLocal) + ");"); + builder.AppendLine(" " + trackerLocal + ".Enter(" + EscapeIdentifier(parameter.Name) + "!, typeof(" + DisplayType(sourceType).TrimEnd('?') + "), typeof(" + DisplayType(targetType).TrimEnd('?') + "), " + mappingMethodExpression + ", " + (trackedPublicEntry ? "default(__LiteMapperCycleTracker.Path)" : memberPathLocal) + ");"); builder.AppendLine(" try"); builder.AppendLine(" {"); } @@ -4213,7 +4213,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H if (mapping.CustomBody != null) { builder.Append(RenderTrackedCalls(mapping.CustomBody, trackerHelperNames, recursiveDeclaredMappings, trackedMapping, - mapping.DeclaredCalls, mapping.HelperName == null ? "string.Empty" : memberPathLocal, trackerLocal, mappingMethodExpression)); + mapping.DeclaredCalls, mapping.HelperName == null ? "default(__LiteMapperCycleTracker.Path)" : memberPathLocal, trackerLocal, mappingMethodExpression)); if (trackedSource) { builder.AppendLine(" }"); @@ -4517,11 +4517,10 @@ private static bool CanReach(IMethodSymbol current, IMethodSymbol target, Dictio private static string PathExpression(MappingModel mapping, string memberName, string memberPathName) { - if (mapping.HelperName == null) - { - return "\"" + memberName + "\""; - } - return "(" + memberPathName + ".Length == 0 ? \"" + memberName + "\" : " + memberPathName + " + \"." + memberName + "\")"; + var member = Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(memberName, quote: true); + return mapping.HelperName == null + ? "new __LiteMapperCycleTracker.Path(" + member + ")" + : memberPathName + ".Append(" + member + ")"; } private static IEnumerable DeclaredCallSequence(IMethodSymbol? declaredMapping) @@ -4636,11 +4635,11 @@ private static void AppendCycleTracker(StringBuilder builder) builder.AppendLine(" {"); builder.AppendLine(" private readonly global::System.Collections.Generic.HashSet _active = new global::System.Collections.Generic.HashSet(__ReferenceIdentityComparer.Instance);"); builder.AppendLine(); - builder.AppendLine(" public void Enter(object source, global::System.Type sourceType, global::System.Type destinationType, string mappingMethod, string memberPath)"); + builder.AppendLine(" public void Enter(object source, global::System.Type sourceType, global::System.Type destinationType, string mappingMethod, Path memberPath)"); builder.AppendLine(" {"); builder.AppendLine(" if (!_active.Add(source))"); builder.AppendLine(" {"); - builder.AppendLine(" throw new global::Mammoth.LiteMapper.LiteMapperCycleException(sourceType, destinationType, mappingMethod, memberPath);"); + builder.AppendLine(" throw new global::Mammoth.LiteMapper.LiteMapperCycleException(sourceType, destinationType, mappingMethod, memberPath.ToString());"); builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); @@ -4648,6 +4647,39 @@ private static void AppendCycleTracker(StringBuilder builder) builder.AppendLine(" {"); builder.AppendLine(" _active.Remove(source);"); builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" public readonly struct Path"); + builder.AppendLine(" {"); + builder.AppendLine(" private readonly Link? _parent;"); + builder.AppendLine(" private readonly string? _member;"); + builder.AppendLine(); + builder.AppendLine(" public Path(string member) : this(null, member) { }"); + builder.AppendLine(" private Path(Link? parent, string member) { _parent = parent; _member = member; }"); + builder.AppendLine(); + builder.AppendLine(" public Path Append(string member) => new Path(_member == null ? null : new Link(this), member);"); + builder.AppendLine(); + builder.AppendLine(" public override string ToString()"); + builder.AppendLine(" {"); + builder.AppendLine(" var count = 0;"); + builder.AppendLine(" for (var path = this; path._member != null; path = path._parent == null ? default : path._parent.Value)"); + builder.AppendLine(" {"); + builder.AppendLine(" count++;"); + builder.AppendLine(" }"); + builder.AppendLine(" if (count == 0) return string.Empty;"); + builder.AppendLine(" var segments = new string[count];"); + builder.AppendLine(" for (var path = this; path._member != null; path = path._parent == null ? default : path._parent.Value)"); + builder.AppendLine(" {"); + builder.AppendLine(" segments[--count] = path._member;"); + builder.AppendLine(" }"); + builder.AppendLine(" return string.Join(\".\", segments);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" private sealed class Link"); + builder.AppendLine(" {"); + builder.AppendLine(" public readonly Path Value;"); + builder.AppendLine(" public Link(Path value) { Value = value; }"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" private sealed class __ReferenceIdentityComparer : global::System.Collections.Generic.IEqualityComparer"); diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs new file mode 100644 index 0000000..ef18f42 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs @@ -0,0 +1,329 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Mammoth.LiteMapper; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + [TestClass] + public sealed class LazyCyclePathTests + { + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void SuccessfulChainAllocationsScaleLinearlyAtSafeDepths(bool tracking) + { + var result = Generate(ChainFixture(tracking) + @" +public static class Probe +{ + public static long[] Measure() + { + var depths = new[] { 16, 32, 64, 128, 256 }; + var allocated = new long[depths.Length * 3]; + for (var d = 0; d < depths.Length; d++) + { + var root = Node.Chain(depths[d]); + for (var warmup = 0; warmup < 40; warmup++) Validate(Mapper.Map(root), depths[d]); + for (var round = 0; round < 3; round++) + { + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) Validate(Mapper.Map(root), depths[d]); + allocated[round * depths.Length + d] = (GC.GetAllocatedBytesForCurrentThread() - before) / 100; + } + } + return allocated; + } + private static void Validate(NodeDto? value, int depth) + { + for (var i = 0; i < depth; i++, value = value!.Child) + if (value == null || value.Value != i) throw new InvalidOperationException(""Invalid finite chain""); + if (value != null) throw new InvalidOperationException(""Unexpected tail""); + } +} +"); + var values = (long[])Compile(result).GetType("Probe")!.GetMethod("Measure")!.Invoke(null, null)!; + Console.WriteLine((tracking ? "ThrowOnCycle" : "None") + " bytes/op at 16/32/64/128/256: " + string.Join(", ", values)); + for (var round = 0; round < 3; round++) + { + // A broad allocation ratio rejects quadratic prefixes without a machine-sensitive latency budget. + Assert.IsTrue(values[round * 5 + 4] < values[round * 5 + 3] * 3, + "Doubling safe depth must not allocate quadratic full-path prefixes: " + string.Join(", ", values)); + if (!tracking) + Assert.AreEqual(values[round * 5] * 16, values[round * 5 + 4], "None must allocate only destination nodes."); + } + } + + [TestMethod] + public void ShallowAndWideTraversalAllocationControls() + { + var result = Generate(ChainFixture(true) + @" +public static class Probe +{ + public static long[] Measure(bool tracking) + { + Func map = tracking ? source => Mapper.Map(source) : Manual; + var roots = new[] { Node.Chain(1), Node.Chain(2) }; + var values = new long[6]; + for (var round = 0; round < 3; round++) + for (var depth = 0; depth < roots.Length; depth++) + { + for (var warmup = 0; warmup < 40; warmup++) GC.KeepAlive(map(roots[depth])); + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) GC.KeepAlive(map(roots[depth])); + values[round * 2 + depth] = (GC.GetAllocatedBytesForCurrentThread() - before) / 100; + } + return values; + } + private static NodeDto Manual(Node source) => new NodeDto { Value = source.Value, Child = source.Child == null ? null : Manual(source.Child) }; +} +"); + var measure = Compile(result).GetType("Probe")!.GetMethod("Measure")!; + var values = (long[])measure.Invoke(null, new object[] { true })!; + var manual = (long[])measure.Invoke(null, new object[] { false })!; + Console.WriteLine("ThrowOnCycle bytes/op at depth 1/2: " + string.Join(", ", values)); + // The first named edge already used a literal; it must not gain a path-node allocation. + for (var round = 0; round < 3; round++) Assert.AreEqual(manual[round * 2 + 1] - manual[round * 2], values[round * 2 + 1] - values[round * 2]); + + var wide = Generate(ChainFixture(true).Replace("NodeDto Map(Node source)", "NodeDto[] Map(Node[] source)") + @" +public static class Probe +{ + public static long Measure(bool tracking) + { + Func map = tracking ? source => Mapper.Map(source) : Manual; + var roots = Enumerable.Range(0, 256).Select(_ => Node.Chain(2)).ToArray(); + for (var warmup = 0; warmup < 40; warmup++) GC.KeepAlive(map(roots)); + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) GC.KeepAlive(map(roots)); + return (GC.GetAllocatedBytesForCurrentThread() - before) / 100; + } + private static NodeDto[] Manual(Node[] source) + { + var result = new NodeDto[source.Length]; + for (var i = 0; i < source.Length; i++) result[i] = new NodeDto + { + Value = source[i].Value, + Child = new NodeDto { Value = source[i].Child!.Value } + }; + return result; + } +} +"); + var wideMeasure = Compile(wide).GetType("Probe")!.GetMethod("Measure")!; + var bytes = (long)wideMeasure.Invoke(null, new object[] { true })!; + var manualBytes = (long)wideMeasure.Invoke(null, new object[] { false })!; + Console.WriteLine("ThrowOnCycle bytes/op for 256 sibling depth-2 chains: " + bytes); + Assert.AreEqual(values[0] - manual[0], bytes - manualBytes, "Shallow siblings may share one tracker, but must not allocate a path object for every first edge."); + } + + [TestMethod] + public void GeneratedSuccessfulDescentStoresSegmentsAndFormatsOnlyAtCycleDetection() + { + var result = Generate(ChainFixture(true)); + Compile(result); + var root = CSharpSyntaxTree.ParseText(result.Source).GetRoot(); + var tracker = root.DescendantNodes().OfType() + .Single(type => type.Identifier.ValueText == "__LiteMapperCycleTracker"); + var path = tracker.Members.OfType().SingleOrDefault(); + Assert.IsNotNull(path, "Diagnostic paths need compact linked segments instead of full string prefixes."); + Assert.IsTrue(path.Members.OfType().All(field => field.Modifiers.Any(SyntaxKind.ReadOnlyKeyword)), + "Immutable per-call segments cannot leak sibling or exception state."); + Assert.AreEqual(1, path.Members.OfType().Count(field => field.Declaration.Type.ToString() == "string?"), + "Each path entry retains one literal segment, never a materialized prefix."); + Assert.IsFalse(result.Source.Contains("__memberPath +", StringComparison.Ordinal), result.Source); + var enter = tracker.Members.OfType().Single(method => method.Identifier.ValueText == "Enter"); + var cycle = enter.DescendantNodes().OfType().Single(); + StringAssert.Contains(cycle.ToString(), ".ToString()"); + Assert.AreEqual(1, root.DescendantNodes().OfType() + .Count(call => call.Expression is MemberAccessExpressionSyntax member && member.Name.Identifier.ValueText == "ToString"), + "Full diagnostic formatting must occur only on the throwing path."); + } + + [TestMethod] + [DataRow(1)] + [DataRow(3)] + [DataRow(64)] + [DataRow(256)] + public void DirectAndIndirectCyclesRetainExactPublicMetadata(int depth) + { + var result = Generate(ChainFixture(true) + @" +public static class Probe +{ + public static bool Run(int depth) + { + var source = Node.Chain(depth); + var tail = source; + while (tail.Child != null) tail = tail.Child; + tail.Child = source; + try { Mapper.Map(source); return false; } + catch (LiteMapperCycleException error) + { + var path = string.Join(""."", Enumerable.Repeat(""Child"", depth)); + return error.SourceType == typeof(Node) && error.DestinationType == typeof(NodeDto) && + error.MappingMethod == ""Map"" && error.MemberPath == path && + error.Message == new LiteMapperCycleException(typeof(Node), typeof(NodeDto), ""Map"", path).Message; + } + } +} +"); + Assert.IsTrue((bool)Compile(result).GetType("Probe")!.GetMethod("Run")!.Invoke(null, new object[] { depth })!); + } + + [TestMethod] + public void SiblingBranchesAndConcurrentPublicCallsKeepIndependentPaths() + { + var result = Generate(ChainFixture(true).Replace("NodeDto Map(Node source)", "PairDto Map(Pair source)") + @" +public sealed class Pair { public Node First { get; set; } = null!; public Node Second { get; set; } = null!; } +public sealed class PairDto { public NodeDto First { get; set; } = null!; public NodeDto Second { get; set; } = null!; } +public static class Probe +{ + public static bool Run() + { + return Enumerable.Range(1, 16).AsParallel().All(depth => + { + var shared = Node.Chain(depth); + var pair = new Pair { First = shared, Second = shared }; + var mapped = Mapper.Map(pair); + if (ReferenceEquals(mapped.First, mapped.Second) || mapped.Second.Value != 0) return false; + var cycle = new Node(); cycle.Child = cycle; + pair.Second = cycle; + try { Mapper.Map(pair); return false; } + catch (LiteMapperCycleException error) + { + if (error.MemberPath != ""Second.Child"" || error.MappingMethod != ""Map"") return false; + } + pair.Second = shared; + return Mapper.Map(pair).Second.Value == 0; + }); + } +} +"); + Assert.AreEqual(true, Compile(result).GetType("Probe")!.GetMethod("Run")!.Invoke(null, null)); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void TheSameTrackerCanBeReusedAfterCycleOrConverterFailure(bool converterFailure) + { + var result = Generate(ChainFixture(true).Replace("public static partial NodeDto Map(Node source);", @" +public static partial NodeDto Map(Node source); +public static readonly InvalidOperationException Failure = new InvalidOperationException(""converter failed""); +[MappingConverter] private static int Convert(int value) => value < 0 ? throw Failure : value;") + @" +public static class Probe +{ + public static Node Create() => Node.Chain(4); + public static void Break(Node value, bool converter) + { + var tail = value; while (tail.Child != null) tail = tail.Child; + if (converter) tail.Value = -1; else tail.Child = value; + } +} +"); + var assembly = Compile(result); + var mapper = assembly.GetType("Mapper")!; + var trackerType = mapper.GetNestedType("__LiteMapperCycleTracker", BindingFlags.NonPublic)!; + var tracker = Activator.CreateInstance(trackerType)!; + var helper = mapper.GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(method => method.Name.StartsWith("MapNested_", StringComparison.Ordinal)); + var pathType = helper.GetParameters()[2].ParameterType; + object? EmptyPath() => pathType == typeof(string) ? string.Empty : Activator.CreateInstance(pathType); + var probe = assembly.GetType("Probe")!; + var broken = probe.GetMethod("Create")!.Invoke(null, null)!; + probe.GetMethod("Break")!.Invoke(null, new[] { broken, converterFailure }); + var error = Assert.ThrowsExactly(() => helper.Invoke(null, new[] { broken, tracker, EmptyPath(), "Map" })); + if (converterFailure) Assert.AreSame(mapper.GetField("Failure")!.GetValue(null), error.InnerException); + else Assert.IsInstanceOfType(error.InnerException); + var active = trackerType.GetField("_active", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(tracker)!; + Assert.AreEqual(0, active.GetType().GetProperty("Count")!.GetValue(active), "Every entered reference must unwind after failure."); + var fresh = probe.GetMethod("Create")!.Invoke(null, null)!; + Assert.IsNotNull(helper.Invoke(null, new[] { fresh, tracker, EmptyPath(), "Map" })); + Assert.AreEqual(0, active.GetType().GetProperty("Count")!.GetValue(active)); + } + + [TestMethod] + public void NonrecursiveMappingsEmitNoDiagnosticPathOrTrackerState() + { + var result = Generate(ChainFixture(true).Replace("public Node? Child { get; set; }", "public Leaf? Child { get; set; }") + .Replace("public NodeDto? Child { get; set; }", "public LeafDto? Child { get; set; }") + .Replace(ChainFactory, "") + @" +public sealed class Leaf { public int Value { get; set; } } +public sealed class LeafDto { public int Value { get; set; } } +"); + Compile(result); + Assert.IsFalse(result.Source.Contains("__LiteMapperCycleTracker", StringComparison.Ordinal), result.Source); + } + + [TestMethod] + public void LazyDiagnosticPathEmissionIsDeterministic() + { + var source = ChainFixture(true); + var first = Generate(source); + var second = Generate(source); + Compile(first); + Assert.AreEqual(first.Source, second.Source); + } + + private const string ChainFactory = @" + public static Node Chain(int depth) + { + var root = new Node(); var tail = root; + for (var i = 1; i < depth; i++) { tail.Child = new Node { Value = i }; tail = tail.Child; } + return root; + }"; + + private static string ChainFixture(bool tracking) => @" +using System; +using System.Linq; +using Mammoth.LiteMapper; +[LiteMapper(ReferenceHandling = ReferenceHandling." + (tracking ? "ThrowOnCycle" : "None") + @")] +public static partial class Mapper { public static partial NodeDto Map(Node source); } +public sealed class Node +{ + public int Value { get; set; } + public Node? Child { get; set; } +" + ChainFactory + @" +} +public sealed class NodeDto { public int Value { get; set; } public NodeDto? Child { get; set; } } +"; + + private static Assembly Compile(GeneratorRun result) + { + var diagnostics = result.Compilation.GetDiagnostics().Where(d => d.Severity >= DiagnosticSeverity.Warning).ToArray(); + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(d => d.ToString()))); + using var stream = new MemoryStream(); + var emitted = result.Compilation.Emit(stream); + Assert.IsTrue(emitted.Success, string.Join(Environment.NewLine, emitted.Diagnostics)); + return Assembly.Load(stream.ToArray()); + } + + private static GeneratorRun Generate(string source) + { + var options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + var references = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!.Split(Path.PathSeparator) + .Select(static path => MetadataReference.CreateFromFile(path)) + .Concat(new[] { MetadataReference.CreateFromFile(typeof(LiteMapperAttribute).Assembly.Location) }); + var compilation = CSharpCompilation.Create("LazyCyclePathTests", new[] { CSharpSyntaxTree.ParseText(source, options) }, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable, + optimizationLevel: OptimizationLevel.Release)); + var inputErrors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && d.Id != "CS8795").ToArray(); + Assert.AreEqual(0, inputErrors.Length, "Invalid test input: " + string.Join(Environment.NewLine, inputErrors.Select(d => d.ToString()))); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new LiteMapperGenerator().AsSourceGenerator() }, parseOptions: options); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + var result = driver.GetRunResult(); + Assert.AreEqual(0, result.Diagnostics.Count(d => d.Severity >= DiagnosticSeverity.Warning), string.Join(Environment.NewLine, result.Diagnostics)); + return new GeneratorRun(output, result.GeneratedTrees.Single().GetText().ToString()); + } + + private sealed class GeneratorRun + { + public GeneratorRun(Compilation compilation, string source) { Compilation = compilation; Source = source; } + public Compilation Compilation { get; } + public string Source { get; } + } + } +} diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/Milestone12RecursiveCycleTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/Milestone12RecursiveCycleTests.cs index c29f5e5..59a409d 100644 --- a/tests/Mammoth.LiteMapper.Generator.Tests/Milestone12RecursiveCycleTests.cs +++ b/tests/Mammoth.LiteMapper.Generator.Tests/Milestone12RecursiveCycleTests.cs @@ -63,7 +63,7 @@ public sealed class NodeDto { public string? Name { get; set; } public NodeDto? var generated = SingleGeneratedSource(result.RunResult); StringAssert.Contains(generated, "__LiteMapperCycleTracker"); StringAssert.Matches(generated, new System.Text.RegularExpressions.Regex( - "source\\.Child is \\{ \\} (__sourcePath_Child_[0-9A-F]{8}) \\? MapNested_Node_To_NodeDto_[0-9A-F]{8}\\(\\1, __tracker, \\\"Child\\\", \\\"ToDto\\\"\\)")); + "source\\.Child is \\{ \\} (__sourcePath_Child_[0-9A-F]{8}) \\? MapNested_Node_To_NodeDto_[0-9A-F]{8}\\(\\1, __tracker, new __LiteMapperCycleTracker\\.Path\\(\\\"Child\\\"\\), \\\"ToDto\\\"\\)")); AssertNoRuntimeFeatures(generated); var assembly = Emit(result.Compilation); diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index 89bac15..b724b85 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs @@ -52,13 +52,15 @@ public void PackageMappingsCompileAndExecuteAcrossFrameworksAndLanguages(string Path.Combine(consumer, "PortableSetExample.cs")); File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs"), Path.Combine(consumer, "DictionaryComparerExample.cs")); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Basic/LazyCyclePathExample.cs"), + Path.Combine(consumer, "LazyCyclePathExample.cs")); PackageSources(packageFeed, "https://api.nuget.org/v3/index.json") .Save(Path.Combine(directory, "NuGet.Config")); Milestone14PackagingAndAotTests.RunDotnet("restore --no-cache", consumer); Milestone14PackagingAndAotTests.RunDotnet("build -c Release --no-restore", consumer); var generated = Directory.GetFiles(Path.Combine(consumer, "obj", "generated"), "*.g.cs", SearchOption.AllDirectories); - Assert.AreEqual(11, generated.Length, "All eleven mapper containers must generate in " + framework + "/" + language + "."); + Assert.AreEqual(12, generated.Length, "All twelve mapper containers must generate in " + framework + "/" + language + "."); var setSource = File.ReadAllText(generated.Single(static file => Path.GetFileName(file).StartsWith( "Mammoth.LiteMapper.Samples.Collections.PortableSetMapper.", StringComparison.Ordinal))); StringAssert.Contains(setSource, "new global::System.Collections.Generic.HashSet(" + @@ -189,6 +191,7 @@ public static void Run() { Mammoth.LiteMapper.Samples.Collections.PortableSetExample.Run(); Mammoth.LiteMapper.Samples.Collections.DictionaryComparerExample.Run(); + Mammoth.LiteMapper.Samples.Basic.LazyCyclePathExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); if (Environment.Version.Major != EXPECTED_RUNTIME_MAJOR) throw new InvalidOperationException(""Wrong runtime was used.""); diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs index ed98fbd..774cc6c 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -225,6 +225,7 @@ private static string CreateConsumer(string feed, string targetFramework) Mammoth.LiteMapper.Samples.Collections.PortableSetExample.Run(); Mammoth.LiteMapper.Samples.Collections.DictionaryComparerExample.Run(); +Mammoth.LiteMapper.Samples.Basic.LazyCyclePathExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); @@ -482,6 +483,8 @@ public sealed class PatchUpdaterChildTarget { public int Value { get; set; } } Path.Combine(directory, "PortableSetExample.cs")); File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs"), Path.Combine(directory, "DictionaryComparerExample.cs")); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Basic/LazyCyclePathExample.cs"), + Path.Combine(directory, "LazyCyclePathExample.cs")); return directory; }