From 1fa4ed9b20b1df146d362eb5e2a190e00f2ccc21 Mon Sep 17 00:00:00 2001 From: "peaceful-conductor[bot]" <285126712+peaceful-conductor[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:49:15 +0000 Subject: [PATCH 1/2] Release 0.2.0-preview.2 Promote 0.2.0-preview.2 to public. See CHANGELOG.md for consumer-facing changes. --- CHANGELOG.md | 108 +++++ Directory.Build.props | 2 +- src/Daml.Codegen.CSharp.Cli/Program.cs | 46 ++- .../CodeGen/CSharpCodeGenerator.cs | 2 +- .../CodeGen/ChoiceCreatedSlots.cs | 77 +++- .../ChoiceEmitter.ContractIdExercisers.cs | 46 ++- .../CodeGen/DarCrossPackageResolver.cs | 21 +- .../CodeGen/EnumEmitter.cs | 4 + .../CodeGen/Identifiers.cs | 23 +- .../CodeGen/InterfaceEmitter.cs | 11 +- .../CodeGen/PackageEmitContext.cs | 26 ++ .../CodeGen/ProjectFileGenerator.cs | 4 +- .../Model/FourPartPackageVersion.cs | 37 +- .../Model/PackageVersionParser.cs | 2 +- .../IntermediatePackageContentHash.cs | 36 -- .../Versioning/JsonReleaseCounterStore.cs | 283 +++++++++---- .../Versioning/NuGetVersionResolver.cs | 19 +- .../Versioning/ReleaseCounterEntry.cs | 13 - .../Generated/Richtypes/Asset.cs | 4 +- .../Generated/Richtypes/IHolding.cs | 6 +- .../Generated/Richtypes/Marker.cs | 4 +- .../Generated/Richtypes/RichRecord.cs | 8 +- .../Generated/Richtypes/Suit.cs | 58 +++ .../Commands/CommandsSubmission.cs | 24 +- .../Commands/DisclosedContract.cs | 45 +++ src/Daml.Runtime/Contracts/ContractEvent.cs | 27 +- src/Daml.Runtime/Contracts/TransactionTree.cs | 133 +++++++ .../Contracts/TransactionTreeExtensions.cs | 110 ++++++ .../Streams/ContractStreamEvent.cs | 23 ++ .../CliErrorReportingTests.cs | 2 + .../CliExitCodeTests.cs | 1 + .../CliReleaseCountersTests.cs | 174 ++++++-- .../ConsoleRedirectionCollection.cs | 15 + .../ChoiceCreatedSlotsTests.cs | 72 +++- .../ChoiceResultStructTests.cs | 119 ++++++ .../CliReleaseCountersTests.cs | 145 ------- .../DarCrossPackageResolverTests.cs | 34 ++ .../EmittedTemplateChoiceCompilesTests.cs | 135 +++++++ .../EnumEmitterTests.cs | 11 + .../FourPartPackageVersionTests.cs | 28 +- .../IdentifiersTests.cs | 18 + ...erfaceChoiceResultCs0117RegressionTests.cs | 371 ++++++++++++++++++ .../IntermediatePackageContentHashTests.cs | 43 -- .../NuGetPackIntegrationTests.cs | 2 +- .../NuGetVersionResolverTests.cs | 23 +- .../ProjectFileGeneratorTests.Versioning.cs | 4 +- .../ReleaseCounterStoreTests.cs | 284 +++++++++++--- ...etConversionRateFeed_ArchiveAsDsoResult.cs | 1 + .../Splice/Api/Token/Holding/V1/IHolding.cs | 5 +- .../VersioningApiSurfaceTests.cs | 2 - .../MarkerTests.cs | 2 +- .../RichRecordChoiceTests.cs | 1 + .../RichRecordRoundTripTests.cs | 10 + .../SubmissionExtensionsTests.cs | 1 + .../SuitRoundTripTests.cs | 64 +++ .../CaughtExceptionTests.cs | 62 +++ tests/Daml.Runtime.Tests/CommandTypesTests.cs | 122 +++++- .../ContractStreamEventTests.cs | 27 +- .../SynchronizerIdFieldTypingTests.cs | 42 ++ .../TransactionTreeTests.cs | 187 +++++++++ .../WitnessPartiesTypedTests.cs | 3 + 61 files changed, 2684 insertions(+), 528 deletions(-) delete mode 100644 src/Daml.Codegen.CSharp/Versioning/IntermediatePackageContentHash.cs delete mode 100644 src/Daml.Codegen.CSharp/Versioning/ReleaseCounterEntry.cs create mode 100644 src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Suit.cs create mode 100644 src/Daml.Runtime/Commands/DisclosedContract.cs create mode 100644 src/Daml.Runtime/Contracts/TransactionTree.cs create mode 100644 src/Daml.Runtime/Contracts/TransactionTreeExtensions.cs create mode 100644 tests/Daml.Codegen.CSharp.Cli.Tests/ConsoleRedirectionCollection.cs delete mode 100644 tests/Daml.Codegen.CSharp.Tests/CliReleaseCountersTests.cs create mode 100644 tests/Daml.Codegen.CSharp.Tests/InterfaceChoiceResultCs0117RegressionTests.cs delete mode 100644 tests/Daml.Codegen.CSharp.Tests/IntermediatePackageContentHashTests.cs create mode 100644 tests/Daml.Codegen.Testing.Conformance.Tests/SuitRoundTripTests.cs create mode 100644 tests/Daml.Runtime.Tests/CaughtExceptionTests.cs create mode 100644 tests/Daml.Runtime.Tests/TransactionTreeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a21c277..96320e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,114 @@ because they are versioned in lockstep: ### Security +## [0.2.0-preview.2] — 2026-07-02 + +### Added + +- `Daml.Runtime.Commands.DisclosedContract(string ContractId, Identifier TemplateId, + ReadOnlyMemory CreatedEventBlob)` — a new record type carrying an explicitly + disclosed contract (Daml 3.x explicit disclosure). `CommandsSubmission` gains an + optional trailing `IReadOnlyList? DisclosedContracts` parameter and + a `WithDisclosedContracts(params DisclosedContract[])` fluent method, defaulting to + `null` so existing submissions are unaffected; calling it with no arguments, `null`, + or an empty array clears the field back to `null`. Record equality compares `CreatedEventBlob` by content, not by + memory reference. This repo only carries the value — mapping it onto the gRPC + `DisclosedContract` message lives in the ledger-client repo. (#482) +- `Daml.Runtime.Commands.CommandsSubmission` gains an optional trailing + `SynchronizerId? SynchronizerId` parameter and a `WithSynchronizerId(SynchronizerId)` + fluent method, mirroring `WithWorkflowId`/`WithCommandId`, so callers can carry a + submission-time synchronizer pin alongside a submission. This repo only carries the + value — wiring it into `BuildCommands`/proto conversion lives in the ledger-client + repo. +- `Daml.Runtime`: `ContractStreamEvent.Unclassified(long Offset, string Kind)` + — new variant surfaced when a transport delivers an event that cannot be + mapped to any other discriminated-union case, so consumers can honour a + no-silent-drop policy instead of the event being dropped. Code that + exhaustively switches over `ContractStreamEvent` needs a new arm. +- `Daml.Runtime.Contracts`: new `CaughtException(string ErrorId, string Message, + IReadOnlyDictionary Metadata)` record and an + `ExercisedEvent.CaughtExceptions` init-only property (defaults to empty), so + consumers can tell whether a successful exercise recovered from a Daml + `try`/`catch`. Additive — existing positional `ExercisedEvent` constructions + stay source-compatible. Populating `CaughtExceptions` from the ledger wire + format is client-side and not yet implemented (#483). +- `Daml.Runtime.Contracts.TransactionTree` and `TreeEvent` (`Created`/`Exercised` + cases) — a transport-neutral, tree-shaped sibling of `TransactionResult` that + preserves the parent/child hierarchy of a transaction's events (which creates + and sub-exercises a given exercise caused), with wire-level `DamlValue` + payloads consistent with `ExercisedEvent`. `TreeEvent.DescendantEvents()` and + `TransactionTreeExtensions.AllEvents`/`ToTransactionResult` give depth-first + traversal and compat-flattening to the existing `TransactionResult` shape. + Additive — `TransactionResult` is unchanged. (#481) +- `Daml.Codegen.Testing.Conformance.Richtypes.Suit` — a new pure + nullary-constructor Daml `enum` type in the `richtypes` conformance corpus, + plus a `SuitExtensions` class (`ToDamlEnum()`/`FromDamlEnum()`) and a new + `Suit Suit` field on `RichRecord` (positional constructor argument added + after `Outcome`). Closes the enum coverage gap flagged as a follow-up in + the bundle-level determinism gate (#485). (#487) + +### Changed + +- **BREAKING: `ContractStreamEvent.Created`, `.Archived`, and `.Exercised` now carry + a `SynchronizerId SynchronizerId` parameter**, positioned right after `Offset` (before + `WitnessParties`), matching where `Assigned`/`Unassigned` already carry + `Source`/`Target : SynchronizerId`. Every positional construction of these three + records must pass a `SynchronizerId` argument in the new position; regenerate/update + call sites accordingly. +- **The 4th NuGet version segment (`M.m.p.g`) of generated Splice/Daml.Finance + packages is now a uniform codegen-generation ordinal.** It is keyed to the + codegen-tool version and shared by every package — and every co-produced sibling + dependency floor — in a release, incrementing only when the codegen version + changes rather than per DAR-content change. This replaces the former per-package, + content-hash-driven revision counter and fixes two publish-time failures a codegen + upgrade could trigger: a new codegen version that changed emitted C# but not the + DAR proto hash previously froze the 4th segment, so regenerated packages collided + at the same version as the already-published set (`CS8920` on build, `NU1605` on + restore); and because all packages in a release now share one ordinal, co-produced + sibling `` floors can no longer diverge from the versions actually + published together. The first post-upgrade ordinal is seeded above every published + revision (Splice → 3, Daml.Finance → 2). (#474) + +### Fixed + +- Generated Daml `enum` types now carry an XML doc comment (`/// ... + enum constructor.`) above each constructor, matching every other + generated member. Previously the emitter produced undocumented constructors, + which built fine only because no pure nullary-constructor `enum` had ever + been generated; the first one (`Richtypes.Suit`, added in this release) failed + the build with `CS1591` under `TreatWarningsAsErrors`. (#487) +- A Daml template whose name equals another interface's generated `I`-prefixed + marker name (e.g. template `IFactory` alongside interface `Factory`) no longer + collides with it. A package's generated types all share one flat C# namespace, + so both were previously declared as public `IFactory` types in that namespace, + and the generated set failed to compile with `CS0101`. The interface marker + name now appends a trailing `_` until it no longer collides with a template in + its own package, consistently wherever the marker is referenced (declaration, + file name, and every in-package or cross-package type reference to it). (#488) + +- The `Result` projector (`FromCreatedContracts`) now matches an + interface-typed created slot against the created contract's `InterfaceIds` + rather than its `TemplateId`. A choice returning `ContractId I` (where `I` is a + Daml interface) previously emitted `IFactory.TemplateId.ModuleName` — but + generated interface markers expose no public `TemplateId` (it is an explicit + `IDamlType` member), so the projector failed to compile with `CS0117`. Slots to + a concrete template are unchanged. Surfaced by the full Splice/Daml.Finance + release build (`daml-finance-interface-holding-v4`, + `daml-finance-interface-instrument-base-v4`). (#473) +- Generated interface markers now expose a plain `public static Identifier InterfaceId + { get; }` alongside the existing explicit `IDamlInterface.InterfaceId` + implementation. For a `ContractId I` choice-result slot targeting a foreign + (cross-package) interface, the `Result` projector's interface-matching + branch reads `{Interface}.InterfaceId.ModuleName`/`.EntityName` off this new member + instead of baking the interface's module/entity as string literals into the + emitted source — robustness/consistency with the template branch, which already + reads `{Template}.TemplateId`. Slots targeting a *local* interface ref keep + matching via string literals baked at codegen time: the LF-mandated record + RecordEmitter always emits alongside a local `interface I where ...` declaration + is a throwing `ITemplate` placeholder stub with no `InterfaceId` member, so those + slots cannot safely reference a generated symbol. (#473) + + ## [0.2.0-preview.1] — 2026-06-30 ### Added diff --git a/Directory.Build.props b/Directory.Build.props index 00d002b..28f2103 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,7 +20,7 @@ icon.png - 0.2.0-preview.1 + 0.2.0-preview.2 0.2.0.0 0.2.0.0 diff --git a/src/Daml.Codegen.CSharp.Cli/Program.cs b/src/Daml.Codegen.CSharp.Cli/Program.cs index 35fb2ef..0754073 100644 --- a/src/Daml.Codegen.CSharp.Cli/Program.cs +++ b/src/Daml.Codegen.CSharp.Cli/Program.cs @@ -109,7 +109,7 @@ internal static async Task Main(string[] args) var emitterCounterOption = new Option("--emitter-counter") { - Description = "4th segment of the generated NuGet version (Major.Minor.Patch.Revision). Defaults to 0; set a monotonic counter to distinguish republished builds of the same source.", + Description = "4th segment of the generated NuGet version (Major.Minor.Patch.Generation). Defaults to 0; set a monotonic counter to distinguish republished builds of the same source. Overridden by --release-counters, which resolves the segment as a codegen-generation ordinal.", DefaultValueFactory = _ => 0 }; emitterCounterOption.Validators.Add(result => @@ -122,9 +122,31 @@ internal static async Task Main(string[] args) var releaseCountersOption = new Option("--release-counters") { - Description = "Path to a JSON release-counter store. Requires --intermediate (the content hash that keys the store is computed from the IntermediateDar proto bytes). When set, the 4th NuGet version segment is resolved from this store, overriding --emitter-counter. The store is created on first use and atomically updated on each run." + Description = "Path to a JSON release-counter store. When set, the 4th NuGet version segment is resolved from this store as a codegen-generation ordinal keyed by --codegen-version, overriding --emitter-counter. The store is created on first use and atomically updated when a new codegen version is first seen." }; + var codegenVersionOption = new Option("--codegen-version") + { + Description = "Codegen-tool version that keys the release-counter generation ordinal (the 4th NuGet version segment). Every package produced by one codegen version shares the ordinal, which increments when the version changes. Defaults to this emitter build's informational version (AssemblyInformationalVersionAttribute) with any '+' build metadata stripped." + }; + codegenVersionOption.Validators.Add(result => + { + var value = result.GetValue(codegenVersionOption); + if (value is null) + { + return; + } + if (string.IsNullOrWhiteSpace(value)) + { + result.AddError("--codegen-version must be a non-empty version string when specified (e.g. 0.2.0-preview.3)."); + return; + } + if (result.GetValue(releaseCountersOption) is null) + { + result.AddError("--codegen-version has no effect without --release-counters; supply --release-counters to key the generation ordinal, or drop --codegen-version."); + } + }); + var versionSuffixOption = new Option("--version-suffix") { Description = "SemVer prerelease suffix appended to generated package versions, e.g. 'preview.2'. Mirrors the emitter prerelease tag. No leading dash." @@ -178,6 +200,7 @@ internal static async Task Main(string[] args) rootCommand.Options.Add(contractIdentifiersOption); rootCommand.Options.Add(emitterCounterOption); rootCommand.Options.Add(releaseCountersOption); + rootCommand.Options.Add(codegenVersionOption); rootCommand.Options.Add(packageLicenseOption); rootCommand.Options.Add(versionSuffixOption); rootCommand.Options.Add(repositoryUrlOption); @@ -198,6 +221,7 @@ internal static async Task Main(string[] args) parseResult.GetValue(contractIdentifiersOption), parseResult.GetValue(emitterCounterOption), parseResult.GetValue(releaseCountersOption), + parseResult.GetValue(codegenVersionOption), parseResult.GetValue(packageLicenseOption)!, parseResult.GetValue(versionSuffixOption), parseResult.GetValue(repositoryUrlOption)), @@ -265,7 +289,7 @@ private static async Task GenerateFromIntermediate(FileInfo file, CodegenArgs ar logger.Debug($" Dependencies: {dar.Dependencies.Count}"); var effectiveCounter = args.ReleaseCountersFile is not null - ? ResolveReleaseCounter(args.ReleaseCountersFile, proto, dar.MainPackage.Name, dar.MainPackage.Version, logger) + ? ResolveReleaseCounter(args.ReleaseCountersFile, ResolveCodegenVersion(args), dar.MainPackage.Name, dar.MainPackage.Version, logger) : args.EmitterCounter; var generator = new CSharpCodeGenerator(BuildOptions(args, effectiveCounter), logger); @@ -273,21 +297,24 @@ private static async Task GenerateFromIntermediate(FileInfo file, CodegenArgs ar await WriteGeneratedFiles(generatedFiles, args, logger, cancellationToken); } + private static string ResolveCodegenVersion(CodegenArgs args) => + string.IsNullOrWhiteSpace(args.CodegenVersion) + ? ProjectFileGenerator.EmitterLockstepVersion + : args.CodegenVersion; + private static int ResolveReleaseCounter( FileInfo storeFile, - IntermediateDar proto, + string codegenVersion, string packageName, Version packageVersion, ConsoleLogger logger) { - var hash = IntermediatePackageContentHash.Compute(proto.Main); var store = JsonReleaseCounterStore.OpenOrCreate(storeFile.FullName); - var version = NuGetVersionResolver.Compute(packageName, packageVersion, hash, store); + var version = NuGetVersionResolver.Compute(packageVersion, codegenVersion, store); - var truncated = hash[..Math.Min(12, hash.Length)]; - logger.Info($" Release counter: {packageName}@{packageVersion.Major}.{packageVersion.Minor}.{Math.Max(0, packageVersion.Build)} content_hash={truncated}… version={version}"); + logger.Info($" Release counter: codegen_version={codegenVersion}; {packageName} {packageVersion.Major}.{packageVersion.Minor}.{Math.Max(0, packageVersion.Build)} version={version}"); - return version.Revision; + return version.Generation; } private static CodeGenOptions BuildOptions(CodegenArgs args, int emitterCounter) => @@ -348,6 +375,7 @@ internal sealed record CodegenArgs( bool GenerateContractIdentifiers, int EmitterCounter, FileInfo? ReleaseCountersFile, + string? CodegenVersion, string PackageLicenseExpression, string? VersionSuffix, string? RepositoryUrl); diff --git a/src/Daml.Codegen.CSharp/CodeGen/CSharpCodeGenerator.cs b/src/Daml.Codegen.CSharp/CodeGen/CSharpCodeGenerator.cs index a312a73..84e8c62 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/CSharpCodeGenerator.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/CSharpCodeGenerator.cs @@ -193,7 +193,7 @@ private IEnumerable GeneratePackage(ICrossPackageResolver resolve } var code = GenerateInterface(context, interfaceEmitter, package, module, iface); - var path = RelativeFilePath(rootNamespace, $"{Identifiers.InterfaceMarkerName(iface.Name)}.cs"); + var path = RelativeFilePath(rootNamespace, $"{Identifiers.InterfaceMarkerName(iface.Name, context.LocalTemplateClassNames)}.cs"); yield return GeneratedFile.Text(path, code); } diff --git a/src/Daml.Codegen.CSharp/CodeGen/ChoiceCreatedSlots.cs b/src/Daml.Codegen.CSharp/CodeGen/ChoiceCreatedSlots.cs index a480733..c375b18 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/ChoiceCreatedSlots.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/ChoiceCreatedSlots.cs @@ -18,16 +18,55 @@ internal enum CreatedCardinality List, } +/// +/// The Daml interface a ContractId I slot targets, identified by the +/// (module, entity) pair carried in a created contract's interface ids. Present +/// only when the slot's target is an interface marker; for +/// concrete-template slots. +/// +/// +/// checks this record for presence (a non- slot is an interface slot) and then branches on : for a fully-emitted interface marker, the comparison reads +/// {marker}.InterfaceId.ModuleName/EntityName off the generated marker at +/// runtime, mirroring how the template branch reads {template}.TemplateId. Local +/// interface refs resolve to a C# name whose class shape RecordEmitter alone decides — +/// it always emits the LF-mandated accompanying record as a throwing ITemplate +/// stub with no InterfaceId member (see +/// RecordEmitter.WriteInterfacePlaceholderRecord) — so those stay on the +/// string-literal comparison baked at codegen time instead of trusting a generated +/// symbol that this slot cannot itself confirm exists. and +/// remain here as the slot's resolved identity for both the +/// literal comparison and testing. +/// +/// The interface's declaring Daml module name. +/// The interface's entity (declaration) name. +/// +/// when the slot targets a local interface ref — RecordEmitter +/// always emits the local placeholder record as a throwing stub with no +/// InterfaceId, so the projector must match via string literals rather than a +/// generated symbol. for foreign interfaces resolved against +/// another package's own module declarations, which carry no equivalent +/// placeholder-record concept in this package's emission. +/// +internal sealed record InterfaceMatcher(string ModuleName, string EntityName, bool IsPlaceholder); + /// /// One declared ContractId T-bearing slot in a choice's return type. /// /// PascalCase C# field name on the emitted <Choice>Result record. -/// C# name of the template type (e.g. Agreement, SwapRecord). +/// C# name of the template or interface-marker type (e.g. Agreement, IFactory). /// How many created contracts of this template the choice should produce. +/// +/// Set when the slot targets a Daml interface marker — generated interface markers expose +/// no TemplateId, so the projector matches an interface slot against the created +/// contract's interface ids rather than its template id. +/// internal sealed record ChoiceCreatedSlot( string FieldName, string CSharpTemplateType, - CreatedCardinality Cardinality); + CreatedCardinality Cardinality, + InterfaceMatcher? Interface = null); /// /// Walks a choice's return type for embedded ContractId T references and returns @@ -105,11 +144,12 @@ private static void Walk( // than a Single one. case DamlTypeApp { Base: DamlPrimitiveType { Primitive: DamlPrimitive.ContractId }, Arguments: [var arg] }: { - var (templateName, csharpName) = ResolveContractIdTarget(context, resolver, mapper, arg); + var (templateName, csharpName, interfaceMatcher) = ResolveContractIdTarget(context, resolver, mapper, arg); slots.Add(new ChoiceCreatedSlot( FieldName: templateName, CSharpTemplateType: csharpName, - Cardinality: parentCardinality)); + Cardinality: parentCardinality, + Interface: interfaceMatcher)); return; } // Optional (ContractId T) — recurse with Optional cardinality. @@ -135,7 +175,7 @@ when tupleName.StartsWith("Tuple", StringComparison.Ordinal): } } - private static (string FieldName, string CSharpTemplateType) ResolveContractIdTarget(PackageEmitContext context, ICrossPackageResolver resolver, DamlTypeMapper mapper, DamlType arg) + private static (string FieldName, string CSharpTemplateType, InterfaceMatcher? Interface) ResolveContractIdTarget(PackageEmitContext context, ICrossPackageResolver resolver, DamlTypeMapper mapper, DamlType arg) { switch (arg) { @@ -143,20 +183,41 @@ private static (string FieldName, string CSharpTemplateType) ResolveContractIdTa { var fieldName = Identifiers.Sanitize(typeRef.Name); var csharpName = resolver.Resolve(typeRef, context); - return (fieldName, csharpName); + return (fieldName, csharpName, ResolveInterfaceMatcher(context, resolver, typeRef)); } case DamlTypeApp { Base: DamlTypeRef typeRef }: { var fieldName = Identifiers.Sanitize(typeRef.Name); var csharpName = mapper.MapType(arg); - return (fieldName, csharpName); + return (fieldName, csharpName, ResolveInterfaceMatcher(context, resolver, typeRef)); } default: // Type variable or otherwise opaque target — fall back to the mapped C# // name and a synthetic field name. Generated code may not compile in this // case; callers will see a clear loud failure at consumer build time. var mapped = mapper.MapType(arg); - return ("Created", mapped); + return ("Created", mapped, null); + } + } + + // Mirrors the interface-marker branches of DarCrossPackageResolver.Resolve: a slot + // targets a Daml interface exactly when the resolver would emit an interface-marker + // name for it. Local interfaces live in the placeholder set; foreign interfaces are + // read from the referenced package's interface declarations. + private static InterfaceMatcher? ResolveInterfaceMatcher(PackageEmitContext context, ICrossPackageResolver resolver, DamlTypeRef typeRef) + { + if (context.IsLocalRef(typeRef)) + { + var isLocalInterface = context.InterfacePlaceholderQualifiedNames.Contains($"{typeRef.Module}:{typeRef.Name}"); + return isLocalInterface ? new InterfaceMatcher(typeRef.Module, typeRef.Name, IsPlaceholder: true) : null; } + + var isForeignInterface = resolver.LookupPackage(typeRef.PackageId) is { } pkg + && !StdlibPackages.IsStdlibPackage(pkg.Name) + && !StdlibPackages.IsPlaceholderPackageName(pkg.Name) + && pkg.Modules.Any(module => module.Name == typeRef.Module + && module.Interfaces.Any(iface => iface.Name == typeRef.Name)); + + return isForeignInterface ? new InterfaceMatcher(typeRef.Module, typeRef.Name, IsPlaceholder: false) : null; } } diff --git a/src/Daml.Codegen.CSharp/CodeGen/ChoiceEmitter.ContractIdExercisers.cs b/src/Daml.Codegen.CSharp/CodeGen/ChoiceEmitter.ContractIdExercisers.cs index 3bf811a..750641e 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/ChoiceEmitter.ContractIdExercisers.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/ChoiceEmitter.ContractIdExercisers.cs @@ -519,14 +519,21 @@ string Q(string templateName) => // on the floor: the second slot's branch was unreachable, so its bucket // stayed empty and the projector returned `.None` for any caller using the // duplicate-template feature this PR otherwise advertises. - var templateGroups = new List<(string TemplateRef, List SlotIndexes)>(); + var templateGroups = new List<(string TemplateRef, InterfaceMatcher? Interface, List SlotIndexes)>(); for (var i = 0; i < slots.Count; i++) { var templateRef = Q(slots[i].CSharpTemplateType); + var slotInterface = slots[i].Interface; var groupIndex = -1; for (var g = 0; g < templateGroups.Count; g++) { - if (string.Equals(templateGroups[g].TemplateRef, templateRef, StringComparison.Ordinal)) + // Key on TemplateRef *and* the interface matcher, not TemplateRef alone — + // a template and an interface marker can share the same generated C# name + // (e.g. template `IFactory` vs the marker generated for interface `Factory`), + // and merging their slots would pick whichever slot's Interface came first, + // matching the other slot on the wrong branch. + if (string.Equals(templateGroups[g].TemplateRef, templateRef, StringComparison.Ordinal) + && templateGroups[g].Interface == slotInterface) { groupIndex = g; break; @@ -535,7 +542,7 @@ string Q(string templateName) => if (groupIndex < 0) { - templateGroups.Add((templateRef, new List { i })); + templateGroups.Add((templateRef, slots[i].Interface, new List { i })); } else { @@ -555,11 +562,34 @@ string Q(string templateName) => for (var g = 0; g < templateGroups.Count; g++) { var prefix = g == 0 ? "if" : "else if"; - var templateRef = templateGroups[g].TemplateRef; - indent.AppendLine($"{prefix} (string.Equals(item.TemplateId.ModuleName, {templateRef}.TemplateId.ModuleName, StringComparison.Ordinal)"); - indent.Indent(); - indent.AppendLine($"&& string.Equals(item.TemplateId.EntityName, {templateRef}.TemplateId.EntityName, StringComparison.Ordinal))"); - indent.Dedent(); + var group = templateGroups[g]; + if (group.Interface is not null) + { + // Interface-marker slots carry no public TemplateId; a created contract + // surfaces the interfaces it implements via InterfaceIds. Match on the + // interface's (module, entity), sourced from InterfaceMatcher.IsPlaceholder. + indent.Require("System.Linq"); + indent.AppendLine($"{prefix} (item.InterfaceIds.Any(interfaceId =>"); + indent.Indent(); + if (group.Interface.IsPlaceholder) + { + indent.AppendLine($"string.Equals(interfaceId.ModuleName, \"{group.Interface.ModuleName}\", StringComparison.Ordinal)"); + indent.AppendLine($"&& string.Equals(interfaceId.EntityName, \"{group.Interface.EntityName}\", StringComparison.Ordinal)))"); + } + else + { + indent.AppendLine($"string.Equals(interfaceId.ModuleName, {group.TemplateRef}.InterfaceId.ModuleName, StringComparison.Ordinal)"); + indent.AppendLine($"&& string.Equals(interfaceId.EntityName, {group.TemplateRef}.InterfaceId.EntityName, StringComparison.Ordinal)))"); + } + indent.Dedent(); + } + else + { + indent.AppendLine($"{prefix} (string.Equals(item.TemplateId.ModuleName, {group.TemplateRef}.TemplateId.ModuleName, StringComparison.Ordinal)"); + indent.Indent(); + indent.AppendLine($"&& string.Equals(item.TemplateId.EntityName, {group.TemplateRef}.TemplateId.EntityName, StringComparison.Ordinal))"); + indent.Dedent(); + } indent.AppendLine("{"); indent.Indent(); indent.AppendLine($"templateMatches{g}.Add(item.ContractId);"); diff --git a/src/Daml.Codegen.CSharp/CodeGen/DarCrossPackageResolver.cs b/src/Daml.Codegen.CSharp/CodeGen/DarCrossPackageResolver.cs index aa55b30..0d21979 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/DarCrossPackageResolver.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/DarCrossPackageResolver.cs @@ -18,6 +18,7 @@ public sealed class DarCrossPackageResolver : ICrossPackageResolver private readonly HashSet _discoveredExternalPackageIds = []; private readonly Dictionary> _foreignChoiceArgCache = []; private readonly Dictionary> _foreignInterfaceCache = []; + private readonly Dictionary> _foreignTemplateClassNameCache = []; /// Creates a resolver scoped to a single . public DarCrossPackageResolver(IDarSource dar, ICodegenLogger logger) @@ -46,7 +47,7 @@ public string Resolve(DamlTypeRef typeRef, PackageEmitContext context) { if (context.InterfacePlaceholderQualifiedNames.Contains($"{typeRef.Module}:{typeRef.Name}")) { - return Identifiers.InterfaceMarkerName(typeRef.Name); + return Identifiers.InterfaceMarkerName(typeRef.Name, context.LocalTemplateClassNames); } if (context.LocalChoiceArgToTemplate.TryGetValue($"{typeRef.Module}:{typeRef.Name}", out var parentTemplate)) { @@ -77,7 +78,7 @@ public string Resolve(DamlTypeRef typeRef, PackageEmitContext context) var foreignNs = Identifiers.DeriveNamespace(foreignPkg.Name); if (ForeignInterfaceQualifiedNames(foreignPkg).Contains($"{typeRef.Module}:{typeRef.Name}")) { - return $"{foreignNs}.{Identifiers.InterfaceMarkerName(typeRef.Name)}"; + return $"{foreignNs}.{Identifiers.InterfaceMarkerName(typeRef.Name, ForeignTemplateClassNames(foreignPkg))}"; } if (!_foreignChoiceArgCache.TryGetValue(typeRef.PackageId, out var foreignChoiceArgMap)) { @@ -103,6 +104,22 @@ private IReadOnlySet ForeignInterfaceQualifiedNames(DamlPackage pkg) return qualifiedNames; } + /// + /// Sanitised C# class names of every template declared in , + /// mirroring for a foreign + /// package — needed so a marker referenced across packages disambiguates against + /// the same reserved set the declaring package's own emission used. + /// + private IReadOnlySet ForeignTemplateClassNames(DamlPackage pkg) + { + if (!_foreignTemplateClassNameCache.TryGetValue(pkg.PackageId, out var templateClassNames)) + { + templateClassNames = PackageEmitContext.TemplateClassNames(pkg); + _foreignTemplateClassNameCache[pkg.PackageId] = templateClassNames; + } + return templateClassNames; + } + /// /// Builds a mapping of choice-argument type's module-qualified (Module:Name) /// name to parent template name for the given package, used to qualify cross-package diff --git a/src/Daml.Codegen.CSharp/CodeGen/EnumEmitter.cs b/src/Daml.Codegen.CSharp/CodeGen/EnumEmitter.cs index 51b5d09..a44f656 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/EnumEmitter.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/EnumEmitter.cs @@ -40,6 +40,10 @@ internal void WriteEnumType(IndentWriter indent, DamlDataType dataType, DamlEnum foreach (var ctor in enumDef.Constructors) { + if (options.GenerateXmlDocs) + { + indent.AppendLine($"/// {ctor} enum constructor."); + } indent.AppendLine($"{EmitterHelpers.SanitizeIdentifier(ctor)},"); } diff --git a/src/Daml.Codegen.CSharp/CodeGen/Identifiers.cs b/src/Daml.Codegen.CSharp/CodeGen/Identifiers.cs index c7a83da..2d5eea2 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/Identifiers.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/Identifiers.cs @@ -110,11 +110,26 @@ internal static string MemberName(string damlFieldName, string enclosingTypeName /// /// Builds the C# marker-interface name for a Daml interface: the sanitised /// interface name prefixed with I (e.g. Daml Holding → - /// IHolding). Shared by the interface emitter and the type resolver so a - /// reference to an interface names the same marker on the field-type path as on - /// the choice-exercise path. + /// IHolding), appending a trailing _ until the result is absent + /// from — a package's namespace is flat + /// across all its modules, so a Daml template anywhere in the package can legally + /// be named after an interface marker (e.g. template IFactory alongside + /// interface Factory), which would otherwise emit two public IFactory + /// declarations in the same namespace (CS0101). Shared by the interface emitter + /// and the type resolver so a reference to an interface names the same marker on + /// the field-type path as on the choice-exercise path — callers must pass the + /// same reserved-name set (the declaring package's local template class names) + /// for every reference to a given interface. /// - internal static string InterfaceMarkerName(string interfaceName) => "I" + Sanitize(interfaceName); + internal static string InterfaceMarkerName(string interfaceName, IReadOnlySet reservedTypeNames) + { + var marker = "I" + Sanitize(interfaceName); + while (reservedTypeNames.Contains(marker)) + { + marker += "_"; + } + return marker; + } /// /// Appends a trailing _ when equals diff --git a/src/Daml.Codegen.CSharp/CodeGen/InterfaceEmitter.cs b/src/Daml.Codegen.CSharp/CodeGen/InterfaceEmitter.cs index 6a74045..aaaabca 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/InterfaceEmitter.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/InterfaceEmitter.cs @@ -42,7 +42,7 @@ internal void WriteInterfaceType(IndentWriter indent, DamlPackage package, DamlM indent.AppendLine("/// "); } - var interfaceName = Identifiers.InterfaceMarkerName(iface.Name); + var interfaceName = Identifiers.InterfaceMarkerName(iface.Name, context.LocalTemplateClassNames); indent.CurrentTypeName = interfaceName; var viewType = iface.ViewType is not null ? mapper.MapType(iface.ViewType) : null; @@ -80,7 +80,14 @@ private void WriteInterfaceMetadata(IndentWriter indent, DamlPackage package, Da { indent.AppendLine("/// Gets the interface identifier."); } - indent.AppendLine($"static {context.Qualifier.Qualify(RuntimeTypeNames.Identifier, context.RootNamespace)} {context.Qualifier.Qualify(RuntimeTypeNames.IDamlInterface, context.RootNamespace)}.InterfaceId => new(\"{package.PackageId}\", \"{module.Name}\", \"{iface.Name}\");"); + indent.AppendLine($"static {context.Qualifier.Qualify(RuntimeTypeNames.Identifier, context.RootNamespace)} {context.Qualifier.Qualify(RuntimeTypeNames.IDamlInterface, context.RootNamespace)}.InterfaceId => InterfaceId;"); + indent.AppendLine(); + + if (options.GenerateXmlDocs) + { + indent.AppendLine("/// Gets the interface identifier."); + } + indent.AppendLine($"public static new {context.Qualifier.Qualify(RuntimeTypeNames.Identifier, context.RootNamespace)} InterfaceId {{ get; }} = new(\"{package.PackageId}\", \"{module.Name}\", \"{iface.Name}\");"); indent.AppendLine(); if (options.GenerateXmlDocs) diff --git a/src/Daml.Codegen.CSharp/CodeGen/PackageEmitContext.cs b/src/Daml.Codegen.CSharp/CodeGen/PackageEmitContext.cs index 7372fc4..721f910 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/PackageEmitContext.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/PackageEmitContext.cs @@ -26,6 +26,14 @@ public sealed class PackageEmitContext /// Last-wins lookup of every data type across all modules, keyed by simple name. public IReadOnlyDictionary DataTypes { get; } + /// + /// Sanitised C# class names of every template declared anywhere in the package. + /// The package's C# namespace is flat across all its modules, so this set is the + /// reserved-name input disambiguates + /// interface marker names against. + /// + public IReadOnlySet LocalTemplateClassNames { get; } + /// /// Module-qualified (Module:Name) names of enums declared in the package. /// Required because Daml allows the same simple name in multiple modules. @@ -63,6 +71,7 @@ private PackageEmitContext( string rootNamespace, TypeReferenceQualifier qualifier, IReadOnlyDictionary dataTypes, + IReadOnlySet localTemplateClassNames, IReadOnlySet localEnumQualifiedNames, IReadOnlySet localVariantQualifiedNames, IReadOnlySet interfacePlaceholderQualifiedNames, @@ -72,6 +81,7 @@ private PackageEmitContext( RootNamespace = rootNamespace; Qualifier = qualifier; DataTypes = dataTypes; + LocalTemplateClassNames = localTemplateClassNames; LocalEnumQualifiedNames = localEnumQualifiedNames; LocalVariantQualifiedNames = localVariantQualifiedNames; InterfacePlaceholderQualifiedNames = interfacePlaceholderQualifiedNames; @@ -97,6 +107,8 @@ public static PackageEmitContext ForPackage( var rootNamespace = options.RootNamespace ?? Identifiers.DeriveNamespace(package.Name); var qualifier = new TypeReferenceQualifier([rootNamespace]); + var localTemplateClassNames = TemplateClassNames(package); + var dataTypes = new Dictionary(); var localEnumQualifiedNames = new HashSet(); var localVariantQualifiedNames = new HashSet(); @@ -151,9 +163,23 @@ public static PackageEmitContext ForPackage( rootNamespace, qualifier, dataTypes, + localTemplateClassNames, localEnumQualifiedNames, localVariantQualifiedNames, interfacePlaceholderQualifiedNames, localChoiceArgToTemplate); } + + /// + /// Computes the sanitised C# class name of every template declared anywhere in + /// — the single source of the reserved-name set + /// disambiguates against, shared by + /// (for the emitting package) and the cross-package + /// resolver (for foreign packages) so both sides derive the same marker name. + /// + internal static IReadOnlySet TemplateClassNames(DamlPackage package) => + package.Modules + .SelectMany(module => module.Templates) + .Select(template => Identifiers.Sanitize(template.Name)) + .ToHashSet(); } diff --git a/src/Daml.Codegen.CSharp/CodeGen/ProjectFileGenerator.cs b/src/Daml.Codegen.CSharp/CodeGen/ProjectFileGenerator.cs index 30e09b3..9614aad 100644 --- a/src/Daml.Codegen.CSharp/CodeGen/ProjectFileGenerator.cs +++ b/src/Daml.Codegen.CSharp/CodeGen/ProjectFileGenerator.cs @@ -234,14 +234,14 @@ private string FormatPackageVersion(Version darVersion) if (darVersion.Build < 0) { throw new InvalidOperationException( - $"Daml package version must be 3-part (Major.Minor.Patch) to produce a 4-part M.m.p.r NuGet version, " + + $"Daml package version must be 3-part (Major.Minor.Patch) to produce a 4-part M.m.p.g NuGet version, " + $"but got '{darVersion}'. The IntermediateDarReader guarantees a 3-part version; " + $"a 2-part version here indicates a regression in the upstream parser."); } if (_options.EmitterCounter < 0) { throw new InvalidOperationException( - $"CodeGenOptions.EmitterCounter is the monotonic 4th segment of the M.m.p.r versioning scheme; " + + $"CodeGenOptions.EmitterCounter is the monotonic 4th segment of the M.m.p.g versioning scheme; " + $"negative values are not valid NuGet versions, got {_options.EmitterCounter}."); } return FourPartPackageVersion.FromIntrinsic(darVersion, _options.EmitterCounter, _options.VersionSuffix).ToString(); diff --git a/src/Daml.Codegen.CSharp/Model/FourPartPackageVersion.cs b/src/Daml.Codegen.CSharp/Model/FourPartPackageVersion.cs index e3e365b..dc16db7 100644 --- a/src/Daml.Codegen.CSharp/Model/FourPartPackageVersion.cs +++ b/src/Daml.Codegen.CSharp/Model/FourPartPackageVersion.cs @@ -6,11 +6,12 @@ namespace Daml.Codegen.CSharp.Model; /// -/// 4-part NuGet version Major.Minor.Patch.Revision, optionally carrying a +/// 4-part NuGet version Major.Minor.Patch.Generation, optionally carrying a /// SemVer prerelease suffix (e.g. 0.1.6.1-preview.2). -/// Segments 1–3 are the DAR-intrinsic version; segment 4 () is -/// the monotonic emitter counter that disambiguates content-identical re-emissions -/// of the same DAR-intrinsic version under different emitter versions. +/// Segments 1–3 are the DAR-intrinsic version; segment 4 () is +/// the codegen-generation ordinal — a uniform, DAR-independent value shared by every +/// package produced by one codegen version, incremented only when the codegen version +/// changes. /// is stored without the leading dash; an empty, /// null, or whitespace value means no suffix. /// @@ -18,27 +19,27 @@ internal readonly record struct FourPartPackageVersion( int Major, int Minor, int Patch, - int Revision, + int Generation, string? PrereleaseSuffix = null) { /// /// Lifts a 3-part DAR-intrinsic (as produced by /// ) into a 4-part version by - /// attaching the supplied as segment 4 and, + /// attaching the supplied as segment 4 and, /// when supplied, the SemVer (without /// a leading dash). /// - public static FourPartPackageVersion FromIntrinsic(Version intrinsic, int revision, string? prereleaseSuffix = null) + public static FourPartPackageVersion FromIntrinsic(Version intrinsic, int generation, string? prereleaseSuffix = null) { ArgumentNullException.ThrowIfNull(intrinsic); - ArgumentOutOfRangeException.ThrowIfNegative(revision); + ArgumentOutOfRangeException.ThrowIfNegative(generation); var patch = Math.Max(0, intrinsic.Build); - return new FourPartPackageVersion(intrinsic.Major, intrinsic.Minor, patch, revision, NormalizeSuffix(prereleaseSuffix)); + return new FourPartPackageVersion(intrinsic.Major, intrinsic.Minor, patch, generation, NormalizeSuffix(prereleaseSuffix)); } /// - /// Parses a version string M.m.p.r, optionally followed by a SemVer - /// prerelease suffix -suffix. The trailing r segment is + /// Parses a version string M.m.p.g, optionally followed by a SemVer + /// prerelease suffix -suffix. The trailing generation segment g is /// optional and defaults to 0 when absent (so "0.1.17" ≡ /// "0.1.17.0"). The numeric core's segments must be non-negative /// values; the suffix, when present, must be a non-empty @@ -69,24 +70,24 @@ public static bool TryParse(string? raw, out FourPartPackageVersion version) return false; } - var revision = 0; - if (segments.Length == 4 && !TryParseSegment(segments[3], out revision)) + var generation = 0; + if (segments.Length == 4 && !TryParseSegment(segments[3], out generation)) { return false; } - version = new FourPartPackageVersion(major, minor, patch, revision, suffix); + version = new FourPartPackageVersion(major, minor, patch, generation, suffix); return true; } /// - /// Returns the canonical "M.m.p.r" string form, appending - /// "-{suffix}" when a prerelease suffix is present. + /// Returns the canonical "M.m.p.g" string form, where segment 4 is the + /// generation ordinal, appending "-{suffix}" when a prerelease suffix is present. /// public override string ToString() => string.IsNullOrWhiteSpace(PrereleaseSuffix) - ? string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}.{Revision}") - : string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}.{Revision}-{PrereleaseSuffix}"); + ? string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}.{Generation}") + : string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}.{Generation}-{PrereleaseSuffix}"); private static string? NormalizeSuffix(string? suffix) => string.IsNullOrWhiteSpace(suffix) ? null : suffix; diff --git a/src/Daml.Codegen.CSharp/Model/PackageVersionParser.cs b/src/Daml.Codegen.CSharp/Model/PackageVersionParser.cs index 2baf582..260249f 100644 --- a/src/Daml.Codegen.CSharp/Model/PackageVersionParser.cs +++ b/src/Daml.Codegen.CSharp/Model/PackageVersionParser.cs @@ -8,7 +8,7 @@ namespace Daml.Codegen.CSharp.Model; /// /// Parses a Daml package version string into a . The /// DAR-intrinsic version is a 3-part Major.Minor.Patch shape; -/// the optional 4th segment of the generated package's M.m.p.r NuGet version +/// the optional 4th segment of the generated package's M.m.p.g NuGet version /// is added downstream at pack time. Used by both the proto-direct /// IntermediateDarReader and the parser-direct DalfReader /// so the two paths report identical versions for the same DAR. diff --git a/src/Daml.Codegen.CSharp/Versioning/IntermediatePackageContentHash.cs b/src/Daml.Codegen.CSharp/Versioning/IntermediatePackageContentHash.cs deleted file mode 100644 index c09e014..0000000 --- a/src/Daml.Codegen.CSharp/Versioning/IntermediatePackageContentHash.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2026 Peaceful Studio OÜ -// SPDX-License-Identifier: Apache-2.0 - -using System.Security.Cryptography; -using Daml.Codegen.Intermediate; -using Google.Protobuf; - -namespace Daml.Codegen.CSharp.Versioning; - -/// -/// Computes a stable hex SHA-256 over the deterministic protobuf encoding of an -/// . This is the content-stability signal fed into -/// : two emissions whose -/// IntermediatePackage serializes byte-for-byte the same will resolve to the -/// same 4th-segment revision; any difference bumps the revision. -/// -internal static class IntermediatePackageContentHash -{ - /// - /// Returns the lowercase hex SHA-256 of the package's deterministic proto bytes. - /// - public static string Compute(IntermediatePackage package) - { - ArgumentNullException.ThrowIfNull(package); - - using var buffer = new MemoryStream(); - using (var output = new CodedOutputStream(buffer, leaveOpen: true)) - { - output.Deterministic = true; - package.WriteTo(output); - } - - var hash = SHA256.HashData(buffer.ToArray()); - return Convert.ToHexStringLower(hash); - } -} diff --git a/src/Daml.Codegen.CSharp/Versioning/JsonReleaseCounterStore.cs b/src/Daml.Codegen.CSharp/Versioning/JsonReleaseCounterStore.cs index 130de4f..90ada4b 100644 --- a/src/Daml.Codegen.CSharp/Versioning/JsonReleaseCounterStore.cs +++ b/src/Daml.Codegen.CSharp/Versioning/JsonReleaseCounterStore.cs @@ -1,36 +1,57 @@ // Copyright 2026 Peaceful Studio OÜ // SPDX-License-Identifier: Apache-2.0 -using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; namespace Daml.Codegen.CSharp.Versioning; /// -/// File-backed store of emitter-counter values keyed by -/// {packageName}@{Major.Minor.Patch}. The 4th NuGet version segment -/// is derived from this store: 0 for any first emission of a -/// (package, intrinsic) pair, incremented only when the same pair is -/// re-emitted with a different content hash. +/// File-backed store mapping each codegen-tool version to a generation ordinal — +/// the 4th NuGet version segment. Every package produced by a given codegen +/// version, within one source's store, shares the same ordinal; the ordinal +/// increments when the codegen version changes, independent of DAR content. /// /// -/// Single-writer precondition. Instances are not thread-safe and -/// the on-disk file uses no cross-process locking. Callers must serialize -/// access to a given store path — both across threads in one process and -/// across processes. The release pipeline that owns the store path satisfies -/// this naturally: it runs as a single job, sequentially per package. If concurrent writers were ever allowed against the same path, -/// the last-writer-wins truncating write would silently drop a revision -/// bump and two distinct content hashes could end up sharing the same -/// 4th-segment value. -/// Atomic on-disk update. Each +/// Single-writer precondition. Instances are not thread-safe and the +/// on-disk file uses no cross-process locking. Callers must serialize access to a +/// given store path — both across threads in one process and across processes. The +/// release pipeline that owns the store path satisfies this naturally: it runs as a +/// single job, sequentially per package. +/// Atomic on-disk update. Each minting /// write goes via a sibling .tmp file and an atomic -/// , so a crash mid-write -/// leaves the previous valid file intact rather than truncating it to -/// empty. +/// , so a crash mid-write leaves the +/// previous valid file intact rather than truncating it to empty. +/// Legacy migration. A live old-shape store keyed by +/// {packageName}@{Major.Minor.Patch} with per-entry content_hash and +/// revision values is validated strictly: the highest legacy revision seeds the +/// high-water mark, so the first minted ordinal is strictly greater than any published +/// revision. The first mint rewrites the file in the new shape, dropping the legacy +/// entries — safe because the recorded ordinal preserves the floor. A malformed legacy +/// entry is rejected rather than skipped, so a store of only-corrupt entries cannot +/// silently seed a below-published floor and republish an already-taken version. A +/// legacy store predates the store_version marker entirely (see below) and is not +/// required to carry one. +/// Schema-version marker. Every store this code persists stamps a +/// top-level store_version field (currently 1) alongside +/// codegen_generations. On load, a store that carries a codegen_generations +/// field is treated as "new-shape" and must also carry a recognized store_version; +/// a missing or unrecognized marker on such a store throws rather than silently resolving +/// as fresh, because a genuinely fresh or legacy-only store can never reach this branch +/// (see ). This closes the gap where a misspelled, moved, or +/// wrong-shape codegen_generations field was indistinguishable from a legitimately +/// empty store (issue #477). /// internal sealed class JsonReleaseCounterStore { + private const string GenerationsField = "codegen_generations"; + private const string StoreVersionField = "store_version"; + private const string LegacyContentHashField = "content_hash"; + private const string LegacyRevisionField = "revision"; + private const string RecoveryGuidance = + "Repair the file or restore it from its last good state; do not reset it to an empty table, which re-zeros every recorded ordinal and can republish an already-taken version."; + private const int CurrentStoreVersion = 1; + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, @@ -39,27 +60,39 @@ internal sealed class JsonReleaseCounterStore }; private readonly string _path; - private readonly Dictionary _entries; + private readonly Dictionary _generations; + private readonly int _legacyHighWater; - private JsonReleaseCounterStore(string path, Dictionary entries) + private JsonReleaseCounterStore(string path, Dictionary generations, int legacyHighWater) { _path = path; - _entries = entries; + _generations = generations; + _legacyHighWater = legacyHighWater; } /// - /// Opens an existing JSON store at , or returns an - /// empty in-memory store that will be persisted on the first - /// call if the file does not yet exist. + /// Opens an existing JSON store at , or returns an empty + /// in-memory store that will be persisted on the first minting + /// call if the file does not yet exist. /// /// - /// Thrown when the file exists but does not parse as the expected JSON - /// shape (truncated, hand-edited, merge-conflict markers). The exception - /// names the offending path and JSON position so the failure is - /// diagnosable mid-CI-run. Recovery is a human decision (silently - /// rebuilding from empty would re-zero every recorded revision and break - /// monotonicity), so this never falls back to an empty store on parse - /// failure. + /// Thrown when the file exists but is empty or whitespace-only, does not parse as a + /// JSON object, carries a malformed codegen_generations map, carries a + /// malformed legacy entry (a non-object value, a missing or non-string + /// content_hash, or a missing, non-integer, or negative revision), or + /// carries a codegen_generations field without a matching top-level + /// store_version marker (missing entirely, or set to a value this build does + /// not recognize), or carries a top-level store_version marker without a + /// codegen_generations field. A truncated or zero-byte store is treated as corruption rather + /// than a fresh start: minting from an empty table would re-zero every recorded + /// ordinal and could republish an already-taken version. A valid empty JSON object + /// ({}) is not corruption — it resolves as an empty store, preserving the + /// first-run bootstrap, and is not required to carry a store_version (nothing + /// written by this code is ever lost by treating it as fresh). A legacy-only store + /// (no codegen_generations field) likewise is not required to carry a + /// store_version, since it predates the marker. The exception names the + /// offending path so the failure is diagnosable mid-CI-run. Recovery is a human + /// decision, so this never falls back to an empty store on a load failure. /// public static JsonReleaseCounterStore OpenOrCreate(string path) { @@ -67,96 +100,178 @@ public static JsonReleaseCounterStore OpenOrCreate(string path) if (!File.Exists(path)) { - return new JsonReleaseCounterStore(path, new Dictionary(StringComparer.Ordinal)); + return Empty(path); } var json = File.ReadAllText(path); if (string.IsNullOrWhiteSpace(json)) { - return new JsonReleaseCounterStore(path, new Dictionary(StringComparer.Ordinal)); + throw new InvalidDataException( + $"Release-counter store at '{path}' is empty or whitespace-only. {RecoveryGuidance}"); } - Dictionary? loaded; + JsonDocument document; try { - loaded = JsonSerializer.Deserialize>(json, SerializerOptions); + document = JsonDocument.Parse(json); } catch (JsonException inner) { throw new InvalidDataException( - $"Release-counter store at '{path}' is not valid JSON (line {inner.LineNumber}, position {inner.BytePositionInLine}). Repair the file or delete it to start from an empty counter table.", + $"Release-counter store at '{path}' is not valid JSON (line {inner.LineNumber}, position {inner.BytePositionInLine}). {RecoveryGuidance}", inner); } - var validated = new Dictionary(StringComparer.Ordinal); - if (loaded is null) return new JsonReleaseCounterStore(path, validated); - - foreach (var (key, entry) in loaded) + using (document) { - if (entry is null) + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) { throw new InvalidDataException( - $"Release-counter store at '{path}' has a null entry for key '{key}'. Repair the file or delete it to start from an empty counter table."); + $"Release-counter store at '{path}' must have a JSON object at its root. {RecoveryGuidance}"); } - if (string.IsNullOrEmpty(entry.ContentHash)) - { - throw new InvalidDataException( - $"Release-counter store at '{path}' has a missing or empty content_hash for key '{key}'. Repair the file or delete it to start from an empty counter table."); - } + var generations = new Dictionary(StringComparer.Ordinal); + var legacyHighWater = -1; + var hasGenerationsField = false; + JsonElement? storeVersionElement = null; - if (entry.Revision < 0) + foreach (var property in root.EnumerateObject()) { - throw new InvalidDataException( - $"Release-counter store at '{path}' has a negative revision ({entry.Revision}) for key '{key}'. Repair the file or delete it to start from an empty counter table."); + if (string.Equals(property.Name, StoreVersionField, StringComparison.Ordinal)) + { + storeVersionElement = property.Value; + continue; + } + + if (string.Equals(property.Name, GenerationsField, StringComparison.Ordinal)) + { + hasGenerationsField = true; + ReadGenerations(property.Value, path, generations); + continue; + } + + legacyHighWater = Math.Max(legacyHighWater, ReadLegacyRevision(property.Value, property.Name, path)); } - validated[key] = entry; - } + ValidateSchemaMarker(hasGenerationsField, storeVersionElement, path); - return new JsonReleaseCounterStore(path, validated); + return new JsonReleaseCounterStore(path, generations, legacyHighWater); + } } /// - /// Resolves and persists the 4th-segment revision for a given - /// (, , - /// ) tuple. Semantics: - /// - /// Unknown key → write hash@0, return 0. - /// Known key + identical hash → return the recorded revision unchanged. - /// Known key + differing hash → bump revision, write new hash@(r+1), return new revision. - /// + /// Resolves the generation ordinal for . + /// A version already recorded returns its ordinal unchanged (idempotent, no + /// write). An unseen version mints highWater + 1 — where highWater + /// spans every recorded ordinal and every migrated legacy revision, or -1 + /// for a completely empty store — records it, persists, and returns it. /// - public int ResolveRevision(string packageName, Version intrinsicVersion, string contentHash) + public int ResolveGeneration(string codegenVersion) + { + ArgumentException.ThrowIfNullOrWhiteSpace(codegenVersion); + + if (_generations.TryGetValue(codegenVersion, out var existing)) + { + return existing; + } + + var ordinal = HighWater() + 1; + _generations[codegenVersion] = ordinal; + Persist(); + return ordinal; + } + + private int HighWater() { - ArgumentException.ThrowIfNullOrWhiteSpace(packageName); - ArgumentNullException.ThrowIfNull(intrinsicVersion); - ArgumentException.ThrowIfNullOrWhiteSpace(contentHash); + var water = _legacyHighWater; + foreach (var ordinal in _generations.Values) + { + water = Math.Max(water, ordinal); + } + return water; + } - var key = ComposeKey(packageName, intrinsicVersion); + private static JsonReleaseCounterStore Empty(string path) => + new(path, new Dictionary(StringComparer.Ordinal), legacyHighWater: -1); - if (_entries.TryGetValue(key, out var existing)) + private static void ValidateSchemaMarker(bool hasGenerationsField, JsonElement? storeVersionElement, string path) + { + if (!hasGenerationsField) { - if (string.Equals(existing.ContentHash, contentHash, StringComparison.Ordinal)) + if (storeVersionElement is not null) { - return existing.Revision; + throw new InvalidDataException( + $"Release-counter store at '{path}' has a top-level '{StoreVersionField}' field but no '{GenerationsField}' field. Every store this build writes stamps both; a '{StoreVersionField}' without '{GenerationsField}' means the file was hand-edited, truncated, or produced by incompatible code, and resolving it as a fresh store would re-zero every recorded ordinal. {RecoveryGuidance}"); } - var bumped = existing.Revision + 1; - _entries[key] = new ReleaseCounterEntry(contentHash, bumped); - Persist(); - return bumped; + return; } - _entries[key] = new ReleaseCounterEntry(contentHash, 0); - Persist(); - return 0; + if (storeVersionElement is not { } element) + { + throw new InvalidDataException( + $"Release-counter store at '{path}' has a '{GenerationsField}' field but no top-level '{StoreVersionField}' field. Every store this build writes stamps '{StoreVersionField}': {CurrentStoreVersion}; a missing marker means the file was hand-edited, moved from elsewhere, or produced by incompatible code. {RecoveryGuidance}"); + } + + if (element.ValueKind != JsonValueKind.Number + || !element.TryGetInt32(out var storeVersion) + || storeVersion != CurrentStoreVersion) + { + throw new InvalidDataException( + $"Release-counter store at '{path}' has an unrecognized '{StoreVersionField}' value ('{element.GetRawText()}'); this build only understands store_version {CurrentStoreVersion}. {RecoveryGuidance}"); + } + } + + private static void ReadGenerations(JsonElement element, string path, Dictionary generations) + { + if (element.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException( + $"Release-counter store at '{path}' has a '{GenerationsField}' field that is not a JSON object. {RecoveryGuidance}"); + } + + foreach (var generation in element.EnumerateObject()) + { + if (generation.Value.ValueKind != JsonValueKind.Number + || !generation.Value.TryGetInt32(out var ordinal) + || ordinal < 0) + { + throw new InvalidDataException( + $"Release-counter store at '{path}' has an invalid generation ordinal for codegen version '{generation.Name}'; it must be a non-negative integer. {RecoveryGuidance}"); + } + + generations[generation.Name] = ordinal; + } } - private static string ComposeKey(string packageName, Version intrinsic) => - string.Create( - CultureInfo.InvariantCulture, - $"{packageName}@{intrinsic.Major}.{intrinsic.Minor}.{Math.Max(0, intrinsic.Build)}"); + private static int ReadLegacyRevision(JsonElement element, string key, string path) + { + if (element.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException( + $"Release-counter store at '{path}' has a legacy entry for key '{key}' that is not a JSON object. {RecoveryGuidance}"); + } + + if (!element.TryGetProperty(LegacyContentHashField, out var contentHash) + || contentHash.ValueKind != JsonValueKind.String + || string.IsNullOrEmpty(contentHash.GetString())) + { + throw new InvalidDataException( + $"Release-counter store at '{path}' has a missing or empty content_hash for legacy entry '{key}'. {RecoveryGuidance}"); + } + + if (!element.TryGetProperty(LegacyRevisionField, out var revisionElement) + || revisionElement.ValueKind != JsonValueKind.Number + || !revisionElement.TryGetInt32(out var revision) + || revision < 0) + { + throw new InvalidDataException( + $"Release-counter store at '{path}' has a missing, non-integer, or negative revision for legacy entry '{key}'. {RecoveryGuidance}"); + } + + return revision; + } private void Persist() { @@ -166,10 +281,14 @@ private void Persist() Directory.CreateDirectory(directory); } - var ordered = new SortedDictionary(_entries, StringComparer.Ordinal); - var json = JsonSerializer.Serialize(ordered, SerializerOptions); + var document = new StoreDocument( + CurrentStoreVersion, + new SortedDictionary(_generations, StringComparer.Ordinal)); + var json = JsonSerializer.Serialize(document, SerializerOptions); var temporaryPath = _path + ".tmp"; File.WriteAllText(temporaryPath, json); File.Move(temporaryPath, _path, overwrite: true); } + + private sealed record StoreDocument(int StoreVersion, SortedDictionary CodegenGenerations); } diff --git a/src/Daml.Codegen.CSharp/Versioning/NuGetVersionResolver.cs b/src/Daml.Codegen.CSharp/Versioning/NuGetVersionResolver.cs index ea4f088..6685198 100644 --- a/src/Daml.Codegen.CSharp/Versioning/NuGetVersionResolver.cs +++ b/src/Daml.Codegen.CSharp/Versioning/NuGetVersionResolver.cs @@ -6,28 +6,29 @@ namespace Daml.Codegen.CSharp.Versioning; /// -/// Entry point for the 4-part M.m.p.r NuGet versioning scheme. Composes +/// Entry point for the 4-part M.m.p.g NuGet versioning scheme. Composes /// a DAR-intrinsic (segments 1–3, from the package metadata) -/// with the emitter counter (segment 4) derived from the supplied +/// with the codegen-generation ordinal (segment 4) resolved from the supplied /// . Intended to be called by the NuGet packing -/// step once per package being packed. +/// step once per package being packed; every package packed under one codegen +/// version resolves to the same ordinal. /// internal static class NuGetVersionResolver { /// /// Computes the 4-part NuGet version for one package being packed. The - /// is mutated and persisted in-place per the - /// semantics in . + /// is mutated and persisted in-place the first + /// time is seen, per the semantics in + /// . /// public static FourPartPackageVersion Compute( - string packageName, Version intrinsicVersion, - string contentHash, + string codegenVersion, JsonReleaseCounterStore counterStore) { ArgumentNullException.ThrowIfNull(counterStore); - var revision = counterStore.ResolveRevision(packageName, intrinsicVersion, contentHash); - return FourPartPackageVersion.FromIntrinsic(intrinsicVersion, revision); + var ordinal = counterStore.ResolveGeneration(codegenVersion); + return FourPartPackageVersion.FromIntrinsic(intrinsicVersion, ordinal); } } diff --git a/src/Daml.Codegen.CSharp/Versioning/ReleaseCounterEntry.cs b/src/Daml.Codegen.CSharp/Versioning/ReleaseCounterEntry.cs deleted file mode 100644 index 1d0e1e6..0000000 --- a/src/Daml.Codegen.CSharp/Versioning/ReleaseCounterEntry.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2026 Peaceful Studio OÜ -// SPDX-License-Identifier: Apache-2.0 - -namespace Daml.Codegen.CSharp.Versioning; - -/// -/// A single entry in the : the -/// content hash that produced the recorded revision, and the revision itself -/// (the 4th NuGet version segment r of the M.m.p.r versioning scheme). -/// -/// Hex-encoded content hash recorded at the last write. -/// Monotonic emitter counter; non-negative. -internal sealed record ReleaseCounterEntry(string ContentHash, int Revision); diff --git a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Asset.cs b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Asset.cs index 5276ad2..0c6549f 100644 --- a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Asset.cs +++ b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Asset.cs @@ -23,10 +23,10 @@ namespace Daml.Codegen.Testing.Conformance.Richtypes; public sealed partial record Asset([property: DamlFieldAttribute("issuer")] Party Issuer, [property: DamlFieldAttribute("amount")] decimal Amount) : ITemplate { /// Gets the template identifier. - public static Identifier TemplateId { get; } = new("29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb", "RichTypes", "Asset"); + public static Identifier TemplateId { get; } = new("22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491", "RichTypes", "Asset"); /// Gets the package ID. - public static string PackageId => "29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb"; + public static string PackageId => "22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491"; /// Gets the package name. public static string PackageName => "richtypes"; diff --git a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/IHolding.cs b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/IHolding.cs index fe3e9b0..f0dc5f4 100644 --- a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/IHolding.cs +++ b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/IHolding.cs @@ -22,10 +22,10 @@ namespace Daml.Codegen.Testing.Conformance.Richtypes; public interface IHolding : IDamlInterface, IHasView { /// Gets the interface identifier. - static Identifier IDamlInterface.InterfaceId => new("29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb", "RichTypes", "Holding"); + static Identifier IDamlInterface.InterfaceId => new("22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491", "RichTypes", "Holding"); /// Gets the package ID. - static string IDamlInterface.PackageId => "29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb"; + static string IDamlInterface.PackageId => "22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491"; /// Gets the package name. static string IDamlInterface.PackageName => "richtypes"; @@ -34,7 +34,7 @@ public interface IHolding : IDamlInterface, IHasView static Version IDamlInterface.PackageVersion => new(0, 0, 1); /// Gets the compile-time Daml type descriptor. - static DamlTypeDescriptor global::Daml.Runtime.IDamlType.DamlTypeId => new(new Identifier("29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb", "RichTypes", "Holding"), DamlTypeKind.Interface, "richtypes"); + static DamlTypeDescriptor global::Daml.Runtime.IDamlType.DamlTypeId => new(new Identifier("22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491", "RichTypes", "Holding"), DamlTypeKind.Interface, "richtypes"); // Interface method Archive. // Choice Archive() -> DamlUnit diff --git a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Marker.cs b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Marker.cs index 6803fa2..7193767 100644 --- a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Marker.cs +++ b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Marker.cs @@ -23,10 +23,10 @@ namespace Daml.Codegen.Testing.Conformance.Richtypes; public sealed partial record Marker([property: DamlFieldAttribute("owner")] Party Owner) : ITemplate { /// Gets the template identifier. - public static Identifier TemplateId { get; } = new("29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb", "RichTypes", "Marker"); + public static Identifier TemplateId { get; } = new("22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491", "RichTypes", "Marker"); /// Gets the package ID. - public static string PackageId => "29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb"; + public static string PackageId => "22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491"; /// Gets the package name. public static string PackageName => "richtypes"; diff --git a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/RichRecord.cs b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/RichRecord.cs index 2b062bc..2230551 100644 --- a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/RichRecord.cs +++ b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/RichRecord.cs @@ -22,13 +22,13 @@ namespace Daml.Codegen.Testing.Conformance.Richtypes; /// /// Generated from Daml template RichTypes:RichRecord /// -public sealed partial record RichRecord([property: DamlFieldAttribute("owner")] Party Owner, [property: DamlFieldAttribute("count")] long Count, [property: DamlFieldAttribute("amount")] decimal Amount, [property: DamlFieldAttribute("label")] string Label, [property: DamlFieldAttribute("active")] bool Active, [property: DamlFieldAttribute("asOf")] DateOnly AsOf, [property: DamlFieldAttribute("observedAt")] DateTimeOffset ObservedAt, [property: DamlFieldAttribute("note")] string? Note, [property: DamlFieldAttribute("tags")] IReadOnlyList Tags, [property: DamlFieldAttribute("attributes")] IReadOnlyDictionary Attributes, [property: DamlFieldAttribute("marker")] ContractId Marker, [property: DamlFieldAttribute("holdingCid")] ContractId HoldingCid, [property: DamlFieldAttribute("holdingCids")] IReadOnlyList> HoldingCids, [property: DamlFieldAttribute("profile")] Profile Profile, [property: DamlFieldAttribute("outcome")] Outcome Outcome, [property: DamlFieldAttribute("fee")] decimal Fee) : ITemplate +public sealed partial record RichRecord([property: DamlFieldAttribute("owner")] Party Owner, [property: DamlFieldAttribute("count")] long Count, [property: DamlFieldAttribute("amount")] decimal Amount, [property: DamlFieldAttribute("label")] string Label, [property: DamlFieldAttribute("active")] bool Active, [property: DamlFieldAttribute("asOf")] DateOnly AsOf, [property: DamlFieldAttribute("observedAt")] DateTimeOffset ObservedAt, [property: DamlFieldAttribute("note")] string? Note, [property: DamlFieldAttribute("tags")] IReadOnlyList Tags, [property: DamlFieldAttribute("attributes")] IReadOnlyDictionary Attributes, [property: DamlFieldAttribute("marker")] ContractId Marker, [property: DamlFieldAttribute("holdingCid")] ContractId HoldingCid, [property: DamlFieldAttribute("holdingCids")] IReadOnlyList> HoldingCids, [property: DamlFieldAttribute("profile")] Profile Profile, [property: DamlFieldAttribute("outcome")] Outcome Outcome, [property: DamlFieldAttribute("suit")] Suit Suit, [property: DamlFieldAttribute("fee")] decimal Fee) : ITemplate { /// Gets the template identifier. - public static Identifier TemplateId { get; } = new("29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb", "RichTypes", "RichRecord"); + public static Identifier TemplateId { get; } = new("22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491", "RichTypes", "RichRecord"); /// Gets the package ID. - public static string PackageId => "29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb"; + public static string PackageId => "22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491"; /// Gets the package name. public static string PackageName => "richtypes"; @@ -56,6 +56,7 @@ public DamlRecord ToRecord() => DamlRecord.Create( DamlField.Create("holdingCids", new DamlList(HoldingCids.Select(x => (DamlValue)x.ToDamlValue()).ToList())), DamlField.Create("profile", Profile.ToRecord()), DamlField.Create("outcome", Outcome.ToVariant()), + DamlField.Create("suit", Suit.ToDamlEnum()), DamlField.Create("fee", new DamlNumeric(Fee)) ); @@ -76,6 +77,7 @@ public DamlRecord ToRecord() => DamlRecord.Create( HoldingCids: (IReadOnlyList>)record.GetRequiredField("holdingCids").As().Values.Select(x => new ContractId(x.As().Value)).ToList(), Profile: Profile.FromRecord(record.GetRequiredField("profile").As()), Outcome: Outcome.FromVariant(record.GetRequiredField("outcome").As()), + Suit: SuitExtensions.FromDamlEnum(record.GetRequiredField("suit").As()), Fee: record.GetRequiredField("fee").As().Value ); diff --git a/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Suit.cs b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Suit.cs new file mode 100644 index 0000000..f87a36b --- /dev/null +++ b/src/Daml.Codegen.Testing.Conformance/Generated/Richtypes/Suit.cs @@ -0,0 +1,58 @@ +// +// This code was generated by daml-codegen-csharp. +// Do not edit this file manually. +// + +#nullable enable + +using Daml.Runtime.Data; +using System; + +namespace Daml.Codegen.Testing.Conformance.Richtypes; + +/// +/// Generated from Daml enum Suit +/// +public enum Suit +{ + /// Clubs enum constructor. + Clubs, + /// Diamonds enum constructor. + Diamonds, + /// Hearts enum constructor. + Hearts, + /// Spades enum constructor. + Spades, +} + +/// +/// Extension methods for Suit serialization. +/// +public static class SuitExtensions +{ + /// Converts to a DamlEnum value. + public static DamlEnum ToDamlEnum(this Suit value) + { + return value switch + { + Suit.Clubs => DamlEnum.Create("Clubs"), + Suit.Diamonds => DamlEnum.Create("Diamonds"), + Suit.Hearts => DamlEnum.Create("Hearts"), + Suit.Spades => DamlEnum.Create("Spades"), + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) + }; + } + + /// Creates an instance from a DamlEnum value. + public static Suit FromDamlEnum(DamlEnum value) + { + return value.Constructor switch + { + "Clubs" => Suit.Clubs, + "Diamonds" => Suit.Diamonds, + "Hearts" => Suit.Hearts, + "Spades" => Suit.Spades, + _ => throw new ArgumentOutOfRangeException(nameof(value), value.Constructor, null) + }; + } +} diff --git a/src/Daml.Runtime/Commands/CommandsSubmission.cs b/src/Daml.Runtime/Commands/CommandsSubmission.cs index 551df87..4c0eef1 100644 --- a/src/Daml.Runtime/Commands/CommandsSubmission.cs +++ b/src/Daml.Runtime/Commands/CommandsSubmission.cs @@ -13,12 +13,20 @@ namespace Daml.Runtime.Commands; /// Unique command identifier for deduplication. /// Parties to act as when submitting. /// Parties whose contracts are visible. +/// Optional synchronizer to pin the submission to. +/// +/// Optional contracts explicitly disclosed alongside this submission, for parties +/// that don't natively see them. preserves today's behaviour +/// (no explicit disclosure). +/// public sealed record CommandsSubmission( IReadOnlyList Commands, WorkflowId? WorkflowId = null, CommandId? CommandId = null, IReadOnlyList? ActAs = null, - IReadOnlyList? ReadAs = null) + IReadOnlyList? ReadAs = null, + SynchronizerId? SynchronizerId = null, + IReadOnlyList? DisclosedContracts = null) { /// /// Creates a submission with a single command. @@ -44,6 +52,12 @@ public CommandsSubmission WithWorkflowId(WorkflowId workflowId) => public CommandsSubmission WithCommandId(CommandId commandId) => this with { CommandId = commandId }; + /// + /// Adds a synchronizer ID to this submission. + /// + public CommandsSubmission WithSynchronizerId(SynchronizerId synchronizerId) => + this with { SynchronizerId = synchronizerId }; + /// /// Sets the parties to act as. /// @@ -56,6 +70,14 @@ public CommandsSubmission WithActAs(params Party[] parties) => public CommandsSubmission WithReadAs(params Party[] parties) => this with { ReadAs = parties }; + /// + /// Sets the contracts to explicitly disclose alongside this submission. + /// Passing no contracts, , or an empty array clears + /// the field back to . + /// + public CommandsSubmission WithDisclosedContracts(params DisclosedContract[]? disclosedContracts) => + this with { DisclosedContracts = disclosedContracts is { Length: > 0 } ? disclosedContracts : null }; + /// /// Applies a — sets both and /// from the submitter's party sets in a single call. The diff --git a/src/Daml.Runtime/Commands/DisclosedContract.cs b/src/Daml.Runtime/Commands/DisclosedContract.cs new file mode 100644 index 0000000..4e52517 --- /dev/null +++ b/src/Daml.Runtime/Commands/DisclosedContract.cs @@ -0,0 +1,45 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using Daml.Runtime.Data; + +namespace Daml.Runtime.Commands; + +/// +/// A contract explicitly disclosed alongside a submission so that a party +/// without native visibility into it can still be authorized to act on it — +/// Daml 3.x explicit disclosure. The Canton ledger client owns mapping this +/// onto the gRPC DisclosedContract message; this type is transport-neutral. +/// +/// The disclosed contract's identifier. +/// The disclosed contract's template identifier. +/// +/// The raw created_event_blob bytes from the gRPC CreatedEvent, +/// carried verbatim and opaque to this library — no encoding is imposed on callers. +/// +public sealed record DisclosedContract( + string ContractId, + Identifier TemplateId, + ReadOnlyMemory CreatedEventBlob) +{ + /// + /// Compares byte-for-byte, unlike the synthesized + /// record equality, which compares only the memory segment's reference, offset, + /// and length. + /// + public bool Equals(DisclosedContract? other) => + other is not null + && ContractId == other.ContractId + && TemplateId == other.TemplateId + && CreatedEventBlob.Span.SequenceEqual(other.CreatedEventBlob.Span); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(ContractId); + hash.Add(TemplateId); + hash.AddBytes(CreatedEventBlob.Span); + return hash.ToHashCode(); + } +} diff --git a/src/Daml.Runtime/Contracts/ContractEvent.cs b/src/Daml.Runtime/Contracts/ContractEvent.cs index 5fc2111..a7badc6 100644 --- a/src/Daml.Runtime/Contracts/ContractEvent.cs +++ b/src/Daml.Runtime/Contracts/ContractEvent.cs @@ -33,6 +33,22 @@ public sealed record ArchivedEvent( Identifier TemplateId, IReadOnlyList WitnessParties); +/// +/// Represents a Daml exception caught by a try/catch block during +/// choice interpretation. Transport-neutral and wire-format-agnostic — the +/// Canton ledger client owns translating the gRPC exception representation +/// into this shape. +/// +/// The identifier of the caught exception (e.g. its +/// qualified Daml type name or the ledger's error code). +/// The human-readable message carried by the exception. +/// Additional key-value context associated with the +/// exception, as provided by the ledger. +public sealed record CaughtException( + string ErrorId, + string Message, + IReadOnlyDictionary Metadata); + /// /// Represents a choice-exercise event observed in a transaction. Carries the /// wire-level so codegen-emitted choice wrappers @@ -62,4 +78,13 @@ public sealed record ExercisedEvent( DamlValue ExerciseResult, bool Consuming, IReadOnlyList ActingParties, - IReadOnlyList WitnessParties); + IReadOnlyList WitnessParties) +{ + /// + /// Daml exceptions caught by a try/catch block during this + /// choice's interpretation. Defaults to an empty list — populated by + /// ledger-client transport implementations from the gRPC exception + /// status on the exercise node. + /// + public IReadOnlyList CaughtExceptions { get; init; } = Array.Empty(); +} diff --git a/src/Daml.Runtime/Contracts/TransactionTree.cs b/src/Daml.Runtime/Contracts/TransactionTree.cs new file mode 100644 index 0000000..b928727 --- /dev/null +++ b/src/Daml.Runtime/Contracts/TransactionTree.cs @@ -0,0 +1,133 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using Daml.Runtime.Data; + +namespace Daml.Runtime.Contracts; + +/// +/// Result of a submitted transaction, preserving the parent/child hierarchy +/// of its events. A tree-aware sibling of , +/// which flattens the same information into separate created/archived/exercised +/// lists. Use to +/// project a to that flattened shape. +/// +/// Ledger-assigned update identifier. +/// Offset at which the transaction was committed. +/// The transaction's top-level events, in transaction +/// order. Events caused by an exercise (its sub-creates and sub-exercises) are +/// not repeated here — they nest under that exercise's +/// . +public sealed record TransactionTree( + string UpdateId, + long CompletionOffset, + IReadOnlyList RootEvents); + +/// +/// A single node in a : either a contract +/// creation () or a choice exercise (). +/// Exercise nodes carry the events they directly caused, preserving the +/// ledger's causal hierarchy. +/// +public abstract record TreeEvent +{ + private TreeEvent() + { + } + + /// + /// Enumerates every event nested under this one, recursively, in + /// depth-first pre-order. Empty for events and for + /// events with no . + /// + public IEnumerable DescendantEvents() + { + if (this is not Exercised exercised) + { + yield break; + } + + foreach (var child in exercised.ChildEvents) + { + yield return child; + foreach (var descendant in child.DescendantEvents()) + { + yield return descendant; + } + } + } + + /// + /// A contract creation node in a . + /// + /// The ledger-assigned event identifier. + /// The on-ledger contract ID of the created contract. + /// The template identifier (package + module + entity). + /// Wire-level create-argument payload. + /// Parties notified of this event. + /// Parties that authorized the contract's creation. + /// Parties with read access to the contract. + /// The contract's key, when its template declares one; + /// null otherwise. Mirrors . + /// Ledger-effective time at which the contract was created; + /// null when the transport does not supply it. Mirrors + /// . + public sealed record Created( + string EventId, + string ContractId, + Identifier TemplateId, + DamlRecord CreateArguments, + IReadOnlyList WitnessParties, + IReadOnlyList Signatories, + IReadOnlyList Observers, + ContractKey? ContractKey = null, + DateTimeOffset? CreatedAt = null) : TreeEvent + { + /// + /// Interface ids the participant computed for this created event + /// (Canton gRPC CreatedEvent.interface_views[].interface_id). + /// Defaults to an empty list — populated by ledger-client transport + /// implementations for interface-only consumption, where a contract is + /// known only as an interface and must be dispatched at runtime. Flattened + /// through to by + /// . + /// + public IReadOnlyList InterfaceIds { get; init; } = Array.Empty(); + } + + /// + /// A choice-exercise node in a . Carries the + /// wire-level and , + /// consistent with , plus the events this + /// exercise directly caused as . + /// + /// The ledger-assigned event identifier. + /// The on-ledger contract ID the choice was exercised on. + /// The template that defines the exercised choice. The package + /// id may differ from the target contract's package id when the contract has been + /// upgraded or downgraded. + /// When the choice is inherited from an interface, the + /// interface identifier; null for choices defined directly on the template. + /// The choice that was exercised on the target contract. + /// The argument value passed to the choice. Wire-level + /// ; codegen-emitted wrappers deserialize to the typed argument. + /// The result returned by the choice. Wire-level + /// ; codegen-emitted wrappers deserialize to the typed return. + /// Whether the exercise consumed (archived) the target contract. + /// Parties that exercised the choice. + /// Parties notified of this event. + /// The events this exercise directly caused — its + /// sub-creates and sub-exercises — in transaction order. + public sealed record Exercised( + string EventId, + string ContractId, + Identifier TemplateId, + Identifier? InterfaceId, + string ChoiceName, + DamlValue ChoiceArgument, + DamlValue ExerciseResult, + bool Consuming, + IReadOnlyList ActingParties, + IReadOnlyList WitnessParties, + IReadOnlyList ChildEvents) : TreeEvent; +} diff --git a/src/Daml.Runtime/Contracts/TransactionTreeExtensions.cs b/src/Daml.Runtime/Contracts/TransactionTreeExtensions.cs new file mode 100644 index 0000000..c691a75 --- /dev/null +++ b/src/Daml.Runtime/Contracts/TransactionTreeExtensions.cs @@ -0,0 +1,110 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using Daml.Runtime.Data; +using Daml.Runtime.Serialization; + +namespace Daml.Runtime.Contracts; + +/// +/// Tree-walking and compatibility helpers for . +/// +public static class TransactionTreeExtensions +{ + /// + /// Enumerates every event in the tree — + /// followed by each root's — in + /// depth-first pre-order. + /// + public static IEnumerable AllEvents(this TransactionTree tree) + { + ArgumentNullException.ThrowIfNull(tree); + return AllEventsCore(tree); + } + + private static IEnumerable AllEventsCore(TransactionTree tree) + { + foreach (var root in tree.RootEvents) + { + yield return root; + foreach (var descendant in root.DescendantEvents()) + { + yield return descendant; + } + } + } + + /// + /// Projects this to the flattened + /// shape, for callers that don't need + /// hierarchy. nodes become + /// entries (with a JSON-serialized payload); + /// nodes become + /// entries, and consuming + /// exercises additionally contribute their target contract id to + /// . + /// + /// + /// This projection is lossy: has no slot for + /// , , + /// , , + /// , or , + /// and has no slot for ; + /// its is always empty here, since + /// doesn't carry that data. Callers that need those + /// fields must walk directly instead. + /// + public static TransactionResult ToTransactionResult(this TransactionTree tree) + { + ArgumentNullException.ThrowIfNull(tree); + + var createdContracts = new List(); + var archivedContractIds = new List(); + var exercisedEvents = new List(); + + foreach (var treeEvent in tree.AllEvents()) + { + switch (treeEvent) + { + case TreeEvent.Created created: + createdContracts.Add(new CreatedContract( + created.ContractId, + created.TemplateId, + DamlJsonSerializer.Serialize(created.CreateArguments)) + { + InterfaceIds = created.InterfaceIds, + }); + break; + case TreeEvent.Exercised exercised: + exercisedEvents.Add(new ExercisedEvent( + exercised.ContractId, + exercised.TemplateId, + exercised.InterfaceId, + exercised.ChoiceName, + exercised.ChoiceArgument, + exercised.ExerciseResult, + exercised.Consuming, + exercised.ActingParties, + exercised.WitnessParties)); + if (exercised.Consuming) + { + archivedContractIds.Add(exercised.ContractId); + } + break; + default: + throw new UnreachableException( + $"Unhandled {nameof(TreeEvent)} case: {treeEvent.GetType().Name}"); + } + } + + return new TransactionResult( + tree.UpdateId, + tree.CompletionOffset, + createdContracts, + archivedContractIds) + { + ExercisedEvents = exercisedEvents, + }; + } +} diff --git a/src/Daml.Runtime/Streams/ContractStreamEvent.cs b/src/Daml.Runtime/Streams/ContractStreamEvent.cs index 2d310a5..64d5ead 100644 --- a/src/Daml.Runtime/Streams/ContractStreamEvent.cs +++ b/src/Daml.Runtime/Streams/ContractStreamEvent.cs @@ -36,6 +36,9 @@ namespace Daml.Runtime.Streams; /// — the transport stream failed mid-flight. /// Surfaced as a value rather than thrown so the consuming /// await foreach loop can decide whether to retry, log, or stop. +/// — an event the transport delivered but +/// this layer could not map to any other variant; surfaced as a value so +/// consumers can implement a no-silent-drop policy for themselves. /// /// public abstract record ContractStreamEvent @@ -52,11 +55,13 @@ private protected ContractStreamEvent() { } /// The ledger offset at which the contract was /// created. Strictly increasing per synchronizer; suitable for use as /// the resume offset on a subsequent subscription (exclusive). + /// The synchronizer the contract was created on. /// Parties that witnessed the create event. public sealed record Created( ContractId ContractId, DamlRecord Payload, long Offset, + SynchronizerId SynchronizerId, IReadOnlyList WitnessParties) : ContractStreamEvent; /// @@ -64,10 +69,12 @@ public sealed record Created( /// /// The on-ledger contract ID. /// The ledger offset at which the contract was archived. + /// The synchronizer the contract was archived on. /// Parties that witnessed the archive event. public sealed record Archived( ContractId ContractId, long Offset, + SynchronizerId SynchronizerId, IReadOnlyList WitnessParties) : ContractStreamEvent; /// @@ -81,6 +88,7 @@ public sealed record Archived( /// The result returned by the choice. /// Whether the exercise consumed (archived) the contract. /// The ledger offset of the exercise. + /// The synchronizer the exercise occurred on. /// Parties that witnessed the exercise event. public sealed record Exercised( ContractId ContractId, @@ -89,6 +97,7 @@ public sealed record Exercised( DamlValue ExerciseResult, bool Consuming, long Offset, + SynchronizerId SynchronizerId, IReadOnlyList WitnessParties) : ContractStreamEvent; /// @@ -158,4 +167,18 @@ public sealed record Checkpoint(long Offset) : ContractStreamEvent; public sealed record StreamError( int StatusCode, string Message) : ContractStreamEvent; + + /// + /// An event the transport delivered but this layer could not map to any + /// of the other variants. Surfaced rather than silently dropped so + /// consumers can honour a no-silent-drop invariant — this is the + /// transport-agnostic Daml.Runtime layer, so no raw wire bytes + /// are available to attach here. + /// + /// The ledger offset at which the unrecognized event occurred. + /// A short description of the unrecognized event, for + /// logging/diagnostics. + public sealed record Unclassified( + long Offset, + string Kind) : ContractStreamEvent; } diff --git a/tests/Daml.Codegen.CSharp.Cli.Tests/CliErrorReportingTests.cs b/tests/Daml.Codegen.CSharp.Cli.Tests/CliErrorReportingTests.cs index 69d62da..0409c7b 100644 --- a/tests/Daml.Codegen.CSharp.Cli.Tests/CliErrorReportingTests.cs +++ b/tests/Daml.Codegen.CSharp.Cli.Tests/CliErrorReportingTests.cs @@ -7,6 +7,7 @@ namespace Daml.Codegen.CSharp.Cli.Tests; +[Collection("ConsoleRedirection")] public class CliErrorReportingTests : IDisposable { private const string FixtureSnapshotName = "splice-api-token-holding-v1"; @@ -66,6 +67,7 @@ public async Task cancellation_warns_that_partially_written_files_may_remain_in_ GenerateContractIdentifiers: true, EmitterCounter: 0, ReleaseCountersFile: null, + CodegenVersion: null, PackageLicenseExpression: "Apache-2.0", VersionSuffix: null, RepositoryUrl: null); diff --git a/tests/Daml.Codegen.CSharp.Cli.Tests/CliExitCodeTests.cs b/tests/Daml.Codegen.CSharp.Cli.Tests/CliExitCodeTests.cs index 4a791a3..a545f90 100644 --- a/tests/Daml.Codegen.CSharp.Cli.Tests/CliExitCodeTests.cs +++ b/tests/Daml.Codegen.CSharp.Cli.Tests/CliExitCodeTests.cs @@ -7,6 +7,7 @@ namespace Daml.Codegen.CSharp.Cli.Tests; +[Collection("ConsoleRedirection")] public class CliExitCodeTests { [Fact] diff --git a/tests/Daml.Codegen.CSharp.Cli.Tests/CliReleaseCountersTests.cs b/tests/Daml.Codegen.CSharp.Cli.Tests/CliReleaseCountersTests.cs index 0ee9088..2b450ae 100644 --- a/tests/Daml.Codegen.CSharp.Cli.Tests/CliReleaseCountersTests.cs +++ b/tests/Daml.Codegen.CSharp.Cli.Tests/CliReleaseCountersTests.cs @@ -1,9 +1,11 @@ // Copyright 2026 Peaceful Studio OÜ // SPDX-License-Identifier: Apache-2.0 +using System.Reflection; using System.Text.Json; using System.Xml.Linq; using Daml.Codegen.CSharp.Cli; +using Daml.Codegen.CSharp.CodeGen; using AwesomeAssertions; using Xunit; @@ -11,11 +13,13 @@ namespace Daml.Codegen.CSharp.Cli.Tests; /// /// CLI integration tests for the --release-counters <path> wire-up. -/// The Splice publish workflow points the CLI at a JSON store -/// of entries -/// and the CLI computes the 4th NuGet version segment from the store rather than -/// from the static --emitter-counter override. +/// The publish workflow points the CLI at a JSON store of +/// generation +/// ordinals and the CLI stamps the 4th NuGet version segment from the store — a +/// codegen-generation ordinal keyed by --codegen-version — rather than from +/// the static --emitter-counter override. /// +[Collection("ConsoleRedirection")] public class CliReleaseCountersTests : IDisposable { private const string FixtureSnapshotName = "splice-api-token-holding-v1"; @@ -33,10 +37,13 @@ public void Dispose() GC.SuppressFinalize(this); } + private static string FixtureIntermediate() => + Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, "intermediate.binpb"); + [Fact] - public async Task release_counters_flag_persists_a_store_entry_for_the_main_package() + public async Task release_counters_flag_stamps_generation_ordinal_into_generated_csproj() { - var intermediate = Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, "intermediate.binpb"); + var intermediate = FixtureIntermediate(); File.Exists(intermediate).Should().BeTrue($"fixture proto must ship at {intermediate}"); var counters = Path.Combine(_workspace, "release-counters.json"); @@ -45,57 +52,136 @@ public async Task release_counters_flag_persists_a_store_entry_for_the_main_pack "--intermediate", intermediate, "-o", _workspace, "--release-counters", counters, + "--codegen-version", "0.2.0-preview.3", "--generate-project" ]); exit.Should().Be(0); File.Exists(counters).Should().BeTrue( - "the CLI must persist the resolved revision back to the store path"); + "the CLI must persist the resolved generation ordinal back to the store path"); using var document = JsonDocument.Parse(await File.ReadAllTextAsync(counters, TestContext.Current.CancellationToken)); - var properties = document.RootElement.EnumerateObject().ToList(); - properties.Should().ContainSingle( - "a fresh run against one fixture proto must persist exactly one (name@M.m.p) entry"); - properties[0].Name.Should().MatchRegex( - @"^.+@\d+\.\d+\.\d+$", - "JsonReleaseCounterStore keys are `@` per its ComposeKey contract"); - var entry = properties[0].Value; - entry.GetProperty("content_hash").GetString().Should().NotBeNullOrEmpty( - "the content hash field must round-trip through the snake_case JSON shape"); - entry.GetProperty("revision").GetInt32().Should().Be(0, - "a first emission of (package, intrinsic-version) resolves to r=0 per JsonReleaseCounterStore.ResolveRevision"); + document.RootElement.GetProperty("codegen_generations") + .GetProperty("0.2.0-preview.3").GetInt32().Should().Be(0, + "a first-seen codegen version in an empty store mints generation ordinal 0"); + + FourthSegmentOfGeneratedVersion(_workspace).Should().Be("0", + "the generated package version's 4th segment is the codegen-generation ordinal"); } [Fact] - public async Task release_counters_flag_holds_revision_steady_on_re_emission_of_the_same_intermediate() + public async Task release_counters_flag_holds_generation_ordinal_steady_on_re_emission_of_the_same_codegen_version() { - var intermediate = Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, "intermediate.binpb"); + var intermediate = FixtureIntermediate(); var counters = Path.Combine(_workspace, "release-counters.json"); - var firstExit = await Program.Main( + (await Program.Main( [ "--intermediate", intermediate, "-o", _workspace, "--release-counters", counters, + "--codegen-version", "0.2.0-preview.3", "--generate-project" - ]); - firstExit.Should().Be(0); + ])).Should().Be(0); var secondWorkspace = Path.Combine(_workspace, "rerun"); Directory.CreateDirectory(secondWorkspace); - var secondExit = await Program.Main( + (await Program.Main( [ "--intermediate", intermediate, "-o", secondWorkspace, "--release-counters", counters, + "--codegen-version", "0.2.0-preview.3", "--generate-project" - ]); - secondExit.Should().Be(0); + ])).Should().Be(0); + + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(counters, TestContext.Current.CancellationToken)); + var generations = document.RootElement.GetProperty("codegen_generations").EnumerateObject().ToList(); + generations.Should().ContainSingle( + "re-emitting under the same codegen version must not mint a new ordinal"); + generations[0].Value.GetInt32().Should().Be(0); + + FourthSegmentOfGeneratedVersion(secondWorkspace).Should().Be("0"); + } + + [Fact] + public async Task codegen_version_flag_drives_the_generation_key() + { + var intermediate = FixtureIntermediate(); + var counters = Path.Combine(_workspace, "release-counters.json"); + + (await Program.Main( + [ + "--intermediate", intermediate, + "-o", _workspace, + "--release-counters", counters, + "--codegen-version", "0.2.0-preview.3", + "--generate-project" + ])).Should().Be(0); + + var secondWorkspace = Path.Combine(_workspace, "next-version"); + Directory.CreateDirectory(secondWorkspace); + (await Program.Main( + [ + "--intermediate", intermediate, + "-o", secondWorkspace, + "--release-counters", counters, + "--codegen-version", "0.2.0-preview.4", + "--generate-project" + ])).Should().Be(0); using var document = JsonDocument.Parse(await File.ReadAllTextAsync(counters, TestContext.Current.CancellationToken)); - document.RootElement.EnumerateObject().Single().Value - .GetProperty("revision").GetInt32().Should().Be(0, - "content-identical re-emissions must hold the revision steady per the M.m.p.r versioning scheme"); + var generations = document.RootElement.GetProperty("codegen_generations"); + generations.GetProperty("0.2.0-preview.3").GetInt32().Should().Be(0); + generations.GetProperty("0.2.0-preview.4").GetInt32().Should().Be(1); + + FourthSegmentOfGeneratedVersion(secondWorkspace).Should().Be("1", + "a newly-seen codegen version mints the next ordinal and stamps it into the package version"); + } + + [Fact] + public async Task release_counters_flag_defaults_the_generation_key_to_the_emitter_version_when_codegen_version_is_omitted() + { + var intermediate = FixtureIntermediate(); + var counters = Path.Combine(_workspace, "release-counters.json"); + + (await Program.Main( + [ + "--intermediate", intermediate, + "-o", _workspace, + "--release-counters", counters, + "--generate-project" + ])).Should().Be(0); + + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(counters, TestContext.Current.CancellationToken)); + var generations = document.RootElement.GetProperty("codegen_generations").EnumerateObject().ToList(); + generations.Should().ContainSingle( + "omitting --codegen-version keys the store by the emitter's own version"); + generations[0].Name.Should().Be(ExpectedEmitterGenerationKey(), + "the fallback keys the generation ordinal by ProjectFileGenerator.EmitterLockstepVersion"); + generations[0].Value.GetInt32().Should().Be(0); + + FourthSegmentOfGeneratedVersion(_workspace).Should().Be("0"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task codegen_version_flag_fails_loudly_when_blank(string blankCodegenVersion) + { + var intermediate = FixtureIntermediate(); + var counters = Path.Combine(_workspace, "release-counters.json"); + + var exit = await Program.Main( + [ + "--intermediate", intermediate, + "-o", _workspace, + "--release-counters", counters, + "--codegen-version", blankCodegenVersion + ]); + + exit.Should().NotBe(0, + "a blank --codegen-version is rejected at validation rather than keying the store by an empty generation"); } [Fact] @@ -110,34 +196,42 @@ public async Task release_counters_flag_fails_loudly_when_intermediate_is_not_pr ]); exit.Should().NotBe(0, - "the content hash that keys the counter store is only computable from the IntermediateDar proto; --release-counters without --intermediate must fail rather than silently emit r=0 against a different content baseline"); + "--intermediate is a required option, so invoking the CLI without it fails with a non-zero exit code even when --release-counters is supplied"); } [Fact] - public async Task release_counters_flag_writes_revision_zero_into_generated_csproj_on_first_emission() + public async Task codegen_version_flag_fails_loudly_when_release_counters_is_not_provided() { - var intermediate = Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, "intermediate.binpb"); - var counters = Path.Combine(_workspace, "release-counters.json"); + var intermediate = FixtureIntermediate(); var exit = await Program.Main( [ "--intermediate", intermediate, "-o", _workspace, - "--release-counters", counters, - "--generate-project" + "--codegen-version", "0.2.0-preview.3" ]); - exit.Should().Be(0); + exit.Should().NotBe(0, + "--codegen-version only keys the release-counter store, so supplying it without --release-counters fails loudly rather than being silently ignored"); + } - var csproj = Directory.GetFiles(_workspace, "*.csproj", SearchOption.TopDirectoryOnly).Single(); + private static string FourthSegmentOfGeneratedVersion(string workspace) + { + var csproj = Directory.GetFiles(workspace, "*.csproj", SearchOption.TopDirectoryOnly).Single(); var version = XDocument.Load(csproj) .Descendants("Version") .Single() .Value; - var segments = version.Split('.'); segments.Should().HaveCount(4); - segments[3].Should().Be("0", - "segment 4 specifically must be 0 on a first emission per the M.m.p.r versioning scheme; EndsWith(\".0\") would also match e.g. 10.0 or 0.20"); + return segments[3]; + } + + private static string ExpectedEmitterGenerationKey() + { + var informational = typeof(ProjectFileGenerator).Assembly + .GetCustomAttribute()!.InformationalVersion; + var metadataSeparator = informational.IndexOf('+', StringComparison.Ordinal); + return metadataSeparator >= 0 ? informational[..metadataSeparator] : informational; } } diff --git a/tests/Daml.Codegen.CSharp.Cli.Tests/ConsoleRedirectionCollection.cs b/tests/Daml.Codegen.CSharp.Cli.Tests/ConsoleRedirectionCollection.cs new file mode 100644 index 0000000..7232883 --- /dev/null +++ b/tests/Daml.Codegen.CSharp.Cli.Tests/ConsoleRedirectionCollection.cs @@ -0,0 +1,15 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using Xunit; + +namespace Daml.Codegen.CSharp.Cli.Tests; + +/// +/// Serializes the test classes that drive Program and therefore mutate or read +/// the process-global streams (Console.SetError +/// captures in one class must not overlap another class's console writes). Only these +/// classes are held out of the assembly's parallel run; everything else parallelizes. +/// +[CollectionDefinition("ConsoleRedirection", DisableParallelization = true)] +public sealed class ConsoleRedirectionCollection; diff --git a/tests/Daml.Codegen.CSharp.Tests/ChoiceCreatedSlotsTests.cs b/tests/Daml.Codegen.CSharp.Tests/ChoiceCreatedSlotsTests.cs index 29f6553..78162fa 100644 --- a/tests/Daml.Codegen.CSharp.Tests/ChoiceCreatedSlotsTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/ChoiceCreatedSlotsTests.cs @@ -12,13 +12,13 @@ public class ChoiceCreatedSlotsTests { private const string LocalPackageId = "pkg-id"; - private sealed class StubResolver(string resolvedName = "Resolved") : ICrossPackageResolver + private sealed class StubResolver(string resolvedName = "Resolved", Func? lookupPackage = null) : ICrossPackageResolver { public string Resolve(DamlTypeRef typeRef, PackageEmitContext context) => resolvedName; public IReadOnlySet DiscoveredExternalPackageIds => new HashSet(); - public DamlPackage? LookupPackage(string packageId) => null; + public DamlPackage? LookupPackage(string packageId) => lookupPackage?.Invoke(packageId); } private static DamlPackage Package() => @@ -67,6 +67,74 @@ public void single_contract_id_yields_one_single_slot() slots[0].Cardinality.Should().Be(CreatedCardinality.Single); } + [Fact] + public void template_typed_contract_id_has_no_interface_matcher() + { + var slots = Extract(ContractIdOf(Ref("Agreement"))); + + slots[0].Interface.Should().BeNull(); + } + + [Fact] + public void interface_typed_contract_id_yields_an_interface_matcher_with_the_interface_module_and_entity() + { + var module = new DamlModule + { + Name = "Main", + Templates = [], + DataTypes = [new DamlDataType { Name = "Holdable", Definition = new DamlRecordDefinition([]) }], + Interfaces = [new DamlInterface { Name = "Holdable", Choices = [], ViewType = null }], + }; + var package = new DamlPackage + { + PackageId = LocalPackageId, + Name = "test-package", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [module], + DependencyReferences = [], + }; + var context = PackageEmitContext.ForPackage(package, new CodeGenOptions { RootNamespace = "Test.Package" }); + var resolver = new StubResolver(); + var mapper = new DamlTypeMapper(context, resolver); + + var slots = ChoiceCreatedSlots.Extract(context, resolver, mapper, ContractIdOf(Ref("Holdable"))); + + slots.Should().ContainSingle(); + slots[0].Interface.Should().Be(new InterfaceMatcher("Main", "Holdable", IsPlaceholder: true)); + } + + [Fact] + public void interface_typed_contract_id_from_a_foreign_package_yields_an_interface_matcher() + { + const string ForeignPackageId = "foreign-pkg-id"; + var foreignModule = new DamlModule + { + Name = "Foreign.Module", + Templates = [], + DataTypes = [new DamlDataType { Name = "Holdable", Definition = new DamlRecordDefinition([]) }], + Interfaces = [new DamlInterface { Name = "Holdable", Choices = [], ViewType = null }], + }; + var foreignPackage = new DamlPackage + { + PackageId = ForeignPackageId, + Name = "foreign-package", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [foreignModule], + DependencyReferences = [], + }; + var resolver = new StubResolver(lookupPackage: id => id == ForeignPackageId ? foreignPackage : null); + var context = Context(); + var mapper = new DamlTypeMapper(context, resolver); + var foreignRef = new DamlTypeRef(ForeignPackageId, "Foreign.Module", "Holdable"); + + var slots = ChoiceCreatedSlots.Extract(context, resolver, mapper, ContractIdOf(foreignRef)); + + slots.Should().ContainSingle(); + slots[0].Interface.Should().Be(new InterfaceMatcher("Foreign.Module", "Holdable", IsPlaceholder: false)); + } + [Fact] public void optional_contract_id_yields_an_optional_slot() { diff --git a/tests/Daml.Codegen.CSharp.Tests/ChoiceResultStructTests.cs b/tests/Daml.Codegen.CSharp.Tests/ChoiceResultStructTests.cs index ab73fbe..dccce28 100644 --- a/tests/Daml.Codegen.CSharp.Tests/ChoiceResultStructTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/ChoiceResultStructTests.cs @@ -47,6 +47,9 @@ private static DamlType ContractIdOf(string templateName) => new DamlPrimitiveType(DamlPrimitive.ContractId), [new DamlTypeRef("", "Test.Module", templateName)]); + private static DamlType ContractIdOf(DamlTypeRef typeRef) => + new DamlTypeApp(new DamlPrimitiveType(DamlPrimitive.ContractId), [typeRef]); + private static DamlType OptionalOf(DamlType inner) => new DamlTypeApp(new DamlPrimitiveType(DamlPrimitive.Optional), [inner]); @@ -325,4 +328,120 @@ public void Generate_should_share_one_template_bucket_when_slots_repeat() code.Should().Contain("matches0.Add(templateMatches0[templateMatchIndex0]);"); code.Should().Contain("matches1.Add(templateMatches0[templateMatchIndex0]);"); } + + [Fact] + public void Generate_should_not_share_a_bucket_between_a_template_and_an_interface_with_the_same_generated_name() + { + // A template named `IFactory` and an interface named `Factory` both resolve to + // the C# name "IFactory" — the template because that's its own name, the + // interface because generated markers are prefixed with "I" (see + // Identifiers.InterfaceMarkerName). Grouping created-contract slots by that + // generated name alone would merge the two slots into one bucket and match both + // against whichever slot's Interface came first — reintroducing CS0117 for the + // template slot or matching the interface slot on the wrong branch. Slots must + // stay in distinct buckets keyed on (name, interface-or-not). + var module = new DamlModule + { + Name = "Test.Module", + Templates = + [ + new DamlTemplate + { + Name = "IFactory", + Fields = [], + Choices = [], + }, + new DamlTemplate + { + Name = "Vault", + Fields = [new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))], + Choices = + [ + new DamlChoice + { + Name = "IssueBoth", + Consuming = false, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = TupleType(ContractIdOf("IFactory"), ContractIdOf("Factory")), + }, + ], + }, + ], + DataTypes = + [ + new DamlDataType { Name = "IFactory", Definition = new DamlRecordDefinition([]) }, + new DamlDataType + { + Name = "Vault", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))]), + }, + // Interface marker `Factory` also surfaces as a serializable placeholder + // record of the same name — this is what flags the type as an interface. + new DamlDataType { Name = "Factory", Definition = new DamlRecordDefinition([]) }, + ], + Interfaces = [new DamlInterface { Name = "Factory", Choices = [], ViewType = null }], + }; + + var dar = new DamlModelBuilder().WithModule(module).WithDependency(DamlPrim).Build(); + var files = CreateGenerator().Generate(dar); + var code = files.First(f => f.RelativePath.EndsWith("Vault.cs", StringComparison.Ordinal)).Content; + + // Two distinct buckets, not one shared bucket. + code.Should().Contain("var templateMatches0 = new List();"); + code.Should().Contain("var templateMatches1 = new List();"); + + // One branch matches the template by TemplateId, the other matches the + // interface by InterfaceIds — neither slot silently inherits the other's branch. + // `Factory` is a local interface ref, so RecordEmitter's throwing placeholder + // stub (not a fully-emitted marker) backs it — the interface branch must match + // via string literals, not a generated InterfaceId symbol. + code.Should().Contain("item.InterfaceIds.Any(interfaceId =>"); + code.Should().Contain("string.Equals(interfaceId.ModuleName, \"Test.Module\", StringComparison.Ordinal)"); + code.Should().Contain("string.Equals(interfaceId.EntityName, \"Factory\", StringComparison.Ordinal)"); + code.Should().Contain("string.Equals(item.TemplateId.ModuleName, global::Test.Package.IFactory.TemplateId.ModuleName, StringComparison.Ordinal)"); + code.Should().NotContain("InterfaceId.ModuleName"); + code.Should().NotContain("InterfaceId.EntityName"); + } + + [Fact] + public void Generate_should_match_a_foreign_interface_typed_slot_via_its_generated_InterfaceId_symbol() + { + // Unlike a local interface (matched via string literals, see the test above — + // RecordEmitter's placeholder stub for local interfaces has no InterfaceId + // member), a foreign interface's marker is a fully-emitted symbol carrying a + // public InterfaceId. The projector must match on that symbol, not literals. + const string ForeignPackageId = "foreign-pkg-id"; + var foreignModule = new DamlModule + { + Name = "Foreign.Module", + Templates = [], + DataTypes = [new DamlDataType { Name = "Holdable", Definition = new DamlRecordDefinition([]) }], + Interfaces = [new DamlInterface { Name = "Holdable", Choices = [], ViewType = null }], + }; + var foreignPackage = new DamlPackage + { + PackageId = ForeignPackageId, + Name = "foreign-package", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [foreignModule], + DependencyReferences = [], + }; + var foreignRef = new DamlTypeRef(ForeignPackageId, "Foreign.Module", "Holdable"); + var module = ModuleWith(Template("Vault", ContractIdOf(foreignRef), choiceName: "Acquire")); + + var dar = new DamlModelBuilder() + .WithModule(module) + .WithDependency(DamlPrim) + .WithDependency(foreignPackage) + .Build(); + var files = CreateGenerator().Generate(dar); + var code = files.First(f => f.RelativePath.EndsWith("Vault.cs", StringComparison.Ordinal)).Content; + + code.Should().Contain("item.InterfaceIds.Any(interfaceId =>"); + code.Should().Contain("string.Equals(interfaceId.ModuleName, Foreign.Package.IHoldable.InterfaceId.ModuleName, StringComparison.Ordinal)"); + code.Should().Contain("string.Equals(interfaceId.EntityName, Foreign.Package.IHoldable.InterfaceId.EntityName, StringComparison.Ordinal)"); + code.Should().NotContain("\"Foreign.Module\""); + code.Should().NotContain("\"Holdable\""); + } } diff --git a/tests/Daml.Codegen.CSharp.Tests/CliReleaseCountersTests.cs b/tests/Daml.Codegen.CSharp.Tests/CliReleaseCountersTests.cs deleted file mode 100644 index 88f8b14..0000000 --- a/tests/Daml.Codegen.CSharp.Tests/CliReleaseCountersTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2026 Peaceful Studio OÜ -// SPDX-License-Identifier: Apache-2.0 - -using System.Text.Json; -using System.Xml.Linq; -using Daml.Codegen.CSharp.Cli; -using AwesomeAssertions; -using Xunit; - -namespace Daml.Codegen.CSharp.Tests; - -/// -/// CLI integration tests for the --release-counters <path> wire-up. -/// The Splice publish workflow points the CLI at a JSON store -/// of entries -/// and the CLI computes the 4th NuGet version segment from the store rather than -/// from the static --emitter-counter override. -/// -public class CliReleaseCountersTests : IDisposable -{ - private const string FixtureSnapshotName = "splice-api-token-holding-v1"; - private readonly string _workspace; - - public CliReleaseCountersTests() - { - _workspace = Path.Combine(Path.GetTempPath(), $"cli-counters-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_workspace); - } - - public void Dispose() - { - if (Directory.Exists(_workspace)) Directory.Delete(_workspace, recursive: true); - GC.SuppressFinalize(this); - } - - [Fact] - public async Task release_counters_flag_persists_a_store_entry_for_the_main_package() - { - var intermediate = Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, "intermediate.binpb"); - File.Exists(intermediate).Should().BeTrue($"fixture proto must ship at {intermediate}"); - var counters = Path.Combine(_workspace, "release-counters.json"); - - var exit = await Program.Main( - [ - "--intermediate", intermediate, - "-o", _workspace, - "--release-counters", counters, - "--generate-project" - ]); - - exit.Should().Be(0); - File.Exists(counters).Should().BeTrue( - "the CLI must persist the resolved revision back to the store path"); - - using var document = JsonDocument.Parse(await File.ReadAllTextAsync(counters, TestContext.Current.CancellationToken)); - var properties = document.RootElement.EnumerateObject().ToList(); - properties.Should().ContainSingle( - "a fresh run against one fixture proto must persist exactly one (name@M.m.p) entry"); - properties[0].Name.Should().MatchRegex( - @"^.+@\d+\.\d+\.\d+$", - "JsonReleaseCounterStore keys are `@` per its ComposeKey contract"); - var entry = properties[0].Value; - entry.GetProperty("content_hash").GetString().Should().NotBeNullOrEmpty( - "the content hash field must round-trip through the snake_case JSON shape"); - entry.GetProperty("revision").GetInt32().Should().Be(0, - "a first emission of (package, intrinsic-version) resolves to r=0 per JsonReleaseCounterStore.ResolveRevision"); - } - - [Fact] - public async Task release_counters_flag_holds_revision_steady_on_re_emission_of_the_same_intermediate() - { - var intermediate = Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, "intermediate.binpb"); - var counters = Path.Combine(_workspace, "release-counters.json"); - - var firstExit = await Program.Main( - [ - "--intermediate", intermediate, - "-o", _workspace, - "--release-counters", counters, - "--generate-project" - ]); - firstExit.Should().Be(0); - - var secondWorkspace = Path.Combine(_workspace, "rerun"); - Directory.CreateDirectory(secondWorkspace); - var secondExit = await Program.Main( - [ - "--intermediate", intermediate, - "-o", secondWorkspace, - "--release-counters", counters, - "--generate-project" - ]); - secondExit.Should().Be(0); - - using var document = JsonDocument.Parse(await File.ReadAllTextAsync(counters, TestContext.Current.CancellationToken)); - document.RootElement.EnumerateObject().Single().Value - .GetProperty("revision").GetInt32().Should().Be(0, - "content-identical re-emissions must hold the revision steady per the M.m.p.r versioning scheme"); - } - - [Fact] - public async Task release_counters_flag_fails_loudly_when_intermediate_is_not_provided() - { - var dar = Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, $"{FixtureSnapshotName}.dar"); - var counters = Path.Combine(_workspace, "release-counters.json"); - - var exit = await Program.Main( - [ - "--release-counters", counters, - "-o", _workspace, - dar - ]); - - exit.Should().NotBe(0, - "the content hash that keys the counter store is only computable from the IntermediateDar proto; the DAR-direct path must reject --release-counters rather than silently emit r=0 against a different content baseline"); - } - - [Fact] - public async Task release_counters_flag_writes_revision_zero_into_generated_csproj_on_first_emission() - { - var intermediate = Path.Combine(AppContext.BaseDirectory, "Snapshots", FixtureSnapshotName, "intermediate.binpb"); - var counters = Path.Combine(_workspace, "release-counters.json"); - - var exit = await Program.Main( - [ - "--intermediate", intermediate, - "-o", _workspace, - "--release-counters", counters, - "--generate-project" - ]); - - exit.Should().Be(0); - - var csproj = Directory.GetFiles(_workspace, "*.csproj", SearchOption.TopDirectoryOnly).Single(); - var version = XDocument.Load(csproj) - .Descendants("Version") - .Single() - .Value; - - var segments = version.Split('.'); - segments.Should().HaveCount(4); - segments[3].Should().Be("0", - "segment 4 specifically must be 0 on a first emission per the M.m.p.r versioning scheme; EndsWith(\".0\") would also match e.g. 10.0 or 0.20"); - } -} diff --git a/tests/Daml.Codegen.CSharp.Tests/DarCrossPackageResolverTests.cs b/tests/Daml.Codegen.CSharp.Tests/DarCrossPackageResolverTests.cs index db52930..cbfc351 100644 --- a/tests/Daml.Codegen.CSharp.Tests/DarCrossPackageResolverTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/DarCrossPackageResolverTests.cs @@ -70,6 +70,15 @@ private static DamlModule InterfaceModule(string moduleName, string interfaceNam Interfaces = [new DamlInterface { Name = interfaceName, Choices = [], ViewType = null }] }; + private static DamlModule InterfaceModuleWithMarkerReservingTemplate(string moduleName, string interfaceName) => + new() + { + Name = moduleName, + DataTypes = [Record(interfaceName), Record("I" + interfaceName)], + Templates = [new DamlTemplate { Name = "I" + interfaceName, Fields = [], Choices = [] }], + Interfaces = [new DamlInterface { Name = interfaceName, Choices = [], ViewType = null }] + }; + private static PackageEmitContext ContextFor(DamlPackage main) => PackageEmitContext.ForPackage(main, new CodeGenOptions()); @@ -109,6 +118,31 @@ public void resolve_returns_the_qualified_interface_marker_for_a_cross_package_i resolver.DiscoveredExternalPackageIds.Should().Contain("foreign-id"); } + [Fact] + public void resolve_disambiguates_a_local_interface_marker_reserved_by_a_template() + { + var main = Package("main-id", "my-pkg", InterfaceModuleWithMarkerReservingTemplate("M", "Holding")); + var resolver = new DarCrossPackageResolver(new FakeDarSource(main), Substitute.For()); + + var result = resolver.Resolve(new DamlTypeRef("main-id", "M", "Holding"), ContextFor(main)); + + result.Should().Be("IHolding_"); + } + + [Fact] + public void resolve_disambiguates_a_cross_package_interface_marker_reserved_by_a_foreign_template() + { + var main = Package("main-id", "my-pkg", Module("M", Record("Widget"))); + var foreign = Package( + "foreign-id", "foreign-pkg", InterfaceModuleWithMarkerReservingTemplate("Splice.Holding", "Holding")); + var resolver = new DarCrossPackageResolver( + new FakeDarSource(main, foreign), Substitute.For()); + + var result = resolver.Resolve(new DamlTypeRef("foreign-id", "Splice.Holding", "Holding"), ContextFor(main)); + + result.Should().Be("Foreign.Pkg.IHolding_"); + } + [Fact] public void resolve_treats_an_empty_package_id_as_local() { diff --git a/tests/Daml.Codegen.CSharp.Tests/EmittedTemplateChoiceCompilesTests.cs b/tests/Daml.Codegen.CSharp.Tests/EmittedTemplateChoiceCompilesTests.cs index ae855f5..c83ef1d 100644 --- a/tests/Daml.Codegen.CSharp.Tests/EmittedTemplateChoiceCompilesTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/EmittedTemplateChoiceCompilesTests.cs @@ -142,6 +142,141 @@ public void Emitted_template_with_create_bearing_choice_compiles() string.Join("\n", errors.Select(e => e.GetMessage() + " @ " + e.Location))); } + [Fact] + public void Emitted_choice_returning_an_interface_contract_id_compiles() + { + // Regression: a choice returning `ContractId I` for a Daml interface `I` made the + // Result projector emit `IFactory.TemplateId.ModuleName` — but generated + // interface markers expose no public TemplateId (it is an explicit IDamlType + // member), so the projector failed with CS0117. The projector must match an + // interface slot against the created contract's InterfaceIds instead. + var module = new DamlModule + { + Name = "Test.Module", + Templates = + [ + new DamlTemplate + { + Name = "Vault", + Fields = [new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))], + Choices = + [ + new DamlChoice + { + Name = "IssueHoldable", + Consuming = false, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = ContractIdOf("Holdable"), + }, + ], + }, + ], + DataTypes = + [ + new DamlDataType + { + Name = "Vault", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))]), + }, + // Interface marker `Holdable` also surfaces as a serializable placeholder + // record of the same name — this is what flags the type as an interface. + new DamlDataType + { + Name = "Holdable", + Definition = new DamlRecordDefinition([]), + }, + ], + Interfaces = [new DamlInterface { Name = "Holdable", Choices = [], ViewType = null }], + }; + + var package = new DamlPackage + { + PackageId = "test-package-id", + Name = "test-package", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [module], + DependencyReferences = [], + }; + + var dar = new DarModel { MainPackage = package, Dependencies = [] }; + var files = CreateGenerator().Generate(dar); + + var diagnostics = CompileEmittedFiles(files); + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + errors.Should().BeEmpty( + "a choice returning an interface-typed ContractId must match created contracts by InterfaceIds, not TemplateId, but got: {0}", + string.Join("\n", errors.Select(e => e.GetMessage() + " @ " + e.Location))); + } + + [Fact] + public void Emitted_choice_returning_a_local_placeholder_interface_contract_id_compiles_and_uses_literals() + { + var module = new DamlModule + { + Name = "Test.Module", + Templates = + [ + new DamlTemplate + { + Name = "Vault", + Fields = [new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))], + Choices = + [ + new DamlChoice + { + Name = "IssueHoldable", + Consuming = false, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = ContractIdOf("Holdable"), + }, + ], + }, + ], + DataTypes = + [ + new DamlDataType + { + Name = "Vault", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))]), + }, + new DamlDataType + { + Name = "Holdable", + Definition = new DamlRecordDefinition([]), + }, + ], + Interfaces = [new DamlInterface { Name = "Holdable", Choices = [], ViewType = null }], + }; + + var package = new DamlPackage + { + PackageId = "test-package-id", + Name = "test-package", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [module], + DependencyReferences = [], + }; + + var dar = new DarModel { MainPackage = package, Dependencies = [] }; + var files = CreateGenerator().Generate(dar).ToList(); + + var diagnostics = CompileEmittedFiles(files); + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + errors.Should().BeEmpty( + "a choice returning a local placeholder-interface-typed ContractId (backed by RecordEmitter's throwing ITemplate stub, with no InterfaceId member) must still compile, but got: {0}", + string.Join("\n", errors.Select(e => e.GetMessage() + " @ " + e.Location))); + + var code = files.First(f => f.RelativePath.EndsWith("Vault.cs", StringComparison.Ordinal)).Content; + code.Should().Contain("item.InterfaceIds.Any(interfaceId =>"); + code.Should().Contain("string.Equals(interfaceId.ModuleName, \"Test.Module\", StringComparison.Ordinal)"); + code.Should().Contain("string.Equals(interfaceId.EntityName, \"Holdable\", StringComparison.Ordinal)"); + code.Should().NotContain( + "IHoldable.InterfaceId", + "the local placeholder record backing `Holdable` exposes no InterfaceId member — the projector must not reference one"); + } + [Fact] public void Emitted_create_bearing_choice_with_static_controllers_compiles_both_contractid_and_contract_overloads() { diff --git a/tests/Daml.Codegen.CSharp.Tests/EnumEmitterTests.cs b/tests/Daml.Codegen.CSharp.Tests/EnumEmitterTests.cs index 1d1788b..ef3c600 100644 --- a/tests/Daml.Codegen.CSharp.Tests/EnumEmitterTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/EnumEmitterTests.cs @@ -93,6 +93,16 @@ public void emits_xml_docs_when_enabled() output.Should().Contain("/// Extension methods for Color serialization."); } + [Fact] + public void emits_xml_docs_for_every_enum_constructor_when_enabled() + { + var output = EmitEnum("Color", ["Red", "Green", "Blue"], generateXmlDocs: true); + + output.Should().Contain("/// Red enum constructor."); + output.Should().Contain("/// Green enum constructor."); + output.Should().Contain("/// Blue enum constructor."); + } + [Fact] public void omits_the_type_xml_docs_when_disabled() { @@ -102,6 +112,7 @@ public void omits_the_type_xml_docs_when_disabled() output.Should().NotContain("/// Extension methods for Color serialization."); output.Should().NotContain("Converts to a DamlEnum value"); output.Should().NotContain("Creates an instance from a DamlEnum value"); + output.Should().NotContain("enum constructor."); output.Should().Contain("public enum Color"); } } diff --git a/tests/Daml.Codegen.CSharp.Tests/FourPartPackageVersionTests.cs b/tests/Daml.Codegen.CSharp.Tests/FourPartPackageVersionTests.cs index 788e0b6..96c15a9 100644 --- a/tests/Daml.Codegen.CSharp.Tests/FourPartPackageVersionTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/FourPartPackageVersionTests.cs @@ -17,12 +17,12 @@ public void TryParse_round_trips_full_four_part_string_through_ToString() version.Major.Should().Be(1); version.Minor.Should().Be(2); version.Patch.Should().Be(3); - version.Revision.Should().Be(4); + version.Generation.Should().Be(4); version.ToString().Should().Be("1.2.3.4"); } [Fact] - public void TryParse_defaults_Revision_to_zero_when_only_three_segments_supplied() + public void TryParse_defaults_Generation_to_zero_when_only_three_segments_supplied() { FourPartPackageVersion.TryParse("0.1.17", out var version).Should().BeTrue(); @@ -33,7 +33,7 @@ public void TryParse_defaults_Revision_to_zero_when_only_three_segments_supplied [Fact] public void ToString_appends_prerelease_suffix_when_present() { - var version = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), revision: 1, prereleaseSuffix: "preview.2"); + var version = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), generation: 1, prereleaseSuffix: "preview.2"); version.ToString().Should().Be("0.1.6.1-preview.2"); } @@ -41,7 +41,7 @@ public void ToString_appends_prerelease_suffix_when_present() [Fact] public void ToString_omits_suffix_when_absent() { - var version = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), revision: 0); + var version = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), generation: 0); version.ToString().Should().Be("0.1.6.0"); } @@ -51,22 +51,22 @@ public void TryParse_accepts_four_part_core_with_prerelease_suffix_and_round_tri { FourPartPackageVersion.TryParse("0.1.6.1-preview.1", out var version).Should().BeTrue(); - version.Should().Be(FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), revision: 1, prereleaseSuffix: "preview.1")); + version.Should().Be(FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), generation: 1, prereleaseSuffix: "preview.1")); version.ToString().Should().Be("0.1.6.1-preview.1"); } [Fact] - public void TryParse_accepts_three_part_core_with_prerelease_suffix_and_defaults_revision_to_zero() + public void TryParse_accepts_three_part_core_with_prerelease_suffix_and_defaults_generation_to_zero() { FourPartPackageVersion.TryParse("0.1.6-preview.1", out var version).Should().BeTrue(); - version.Should().Be(FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), revision: 0, prereleaseSuffix: "preview.1")); + version.Should().Be(FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), generation: 0, prereleaseSuffix: "preview.1")); } [Fact] - public void FromIntrinsic_lifts_three_part_Version_with_given_revision() + public void FromIntrinsic_lifts_three_part_Version_with_given_generation() { - var lifted = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 17), revision: 3); + var lifted = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 17), generation: 3); lifted.Should().Be(new FourPartPackageVersion(0, 1, 17, 3)); } @@ -74,18 +74,18 @@ public void FromIntrinsic_lifts_three_part_Version_with_given_revision() [Fact] public void FromIntrinsic_normalises_unset_Build_segment_to_zero() { - var lifted = FourPartPackageVersion.FromIntrinsic(new Version(1, 2), revision: 0); + var lifted = FourPartPackageVersion.FromIntrinsic(new Version(1, 2), generation: 0); lifted.Should().Be(new FourPartPackageVersion(1, 2, 0, 0)); } [Fact] - public void FromIntrinsic_throws_ArgumentOutOfRangeException_when_revision_is_negative() + public void FromIntrinsic_throws_ArgumentOutOfRangeException_when_generation_is_negative() { - var act = () => FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 17), revision: -1); + var act = () => FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 17), generation: -1); act.Should().Throw() - .Which.ParamName.Should().Be("revision"); + .Which.ParamName.Should().Be("generation"); } [Theory] @@ -114,7 +114,7 @@ public void TryParse_returns_false_for_malformed_strings(string? raw) [InlineData(" ")] public void FromIntrinsic_collapses_blank_prerelease_suffix_to_no_suffix_in_ToString(string blankSuffix) { - var version = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), revision: 1, prereleaseSuffix: blankSuffix); + var version = FourPartPackageVersion.FromIntrinsic(new Version(0, 1, 6), generation: 1, prereleaseSuffix: blankSuffix); version.ToString().Should().Be("0.1.6.1"); } diff --git a/tests/Daml.Codegen.CSharp.Tests/IdentifiersTests.cs b/tests/Daml.Codegen.CSharp.Tests/IdentifiersTests.cs index d5b4a0f..1fd495b 100644 --- a/tests/Daml.Codegen.CSharp.Tests/IdentifiersTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/IdentifiersTests.cs @@ -35,4 +35,22 @@ public void MemberName_known_limitation_field_named_period_underscore_collides_w { Identifiers.MemberName("period_", "Period").Should().Be("Period_"); } + + [Fact] + public void interface_marker_name_prefixes_the_sanitized_name_with_i_when_unreserved() + { + Identifiers.InterfaceMarkerName("Holding", new HashSet()).Should().Be("IHolding"); + } + + [Fact] + public void interface_marker_name_appends_underscore_when_a_template_reserves_the_marker() + { + Identifiers.InterfaceMarkerName("Factory", new HashSet { "IFactory" }).Should().Be("IFactory_"); + } + + [Fact] + public void interface_marker_name_keeps_appending_underscores_until_the_marker_is_unreserved() + { + Identifiers.InterfaceMarkerName("Factory", new HashSet { "IFactory", "IFactory_" }).Should().Be("IFactory__"); + } } diff --git a/tests/Daml.Codegen.CSharp.Tests/InterfaceChoiceResultCs0117RegressionTests.cs b/tests/Daml.Codegen.CSharp.Tests/InterfaceChoiceResultCs0117RegressionTests.cs new file mode 100644 index 0000000..93b487c --- /dev/null +++ b/tests/Daml.Codegen.CSharp.Tests/InterfaceChoiceResultCs0117RegressionTests.cs @@ -0,0 +1,371 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using Daml.Codegen.CSharp.CodeGen; +using Daml.Codegen.CSharp.Model; +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Xunit; +using static Daml.Codegen.CSharp.Tests.EmittedCodeCompilesTestHelpers; +using static Daml.Codegen.CSharp.Tests.TestHelpers.GeneratorFactory; + +namespace Daml.Codegen.CSharp.Tests; + +/// +/// Compile-level regression coverage for the CS0117 reported in issue #472: +/// the two Daml.Finance interface families (daml-finance-interface-holding-v4, +/// daml-finance-interface-instrument-base-v4) failed to pack because a +/// Reference template choice returning ContractId Factory / +/// ContractId Instrument (a local Daml interface) made the generated +/// <Choice>Result.FromCreatedContracts projector read +/// IFactory.TemplateId — an interface marker exposes no TemplateId. +/// The projector matches interface-typed created slots by InterfaceIds; these +/// tests pin the fix against the exact reported shapes and the optional/list +/// cardinalities the projector must also handle. +/// +public class InterfaceChoiceResultCs0117RegressionTests +{ + private static readonly DamlPackage DamlPrim = new() + { + PackageId = "daml-prim", + Name = "daml-prim", + Version = new Version(0, 0, 0), + LfVersion = "2.1", + Modules = [], + DependencyReferences = [], + }; + + private static DamlType ContractIdOf(string module, string name) => + new DamlTypeApp(new DamlPrimitiveType(DamlPrimitive.ContractId), [new DamlTypeRef("", module, name)]); + + private static DamlType ListOf(DamlType inner) => + new DamlTypeApp(new DamlPrimitiveType(DamlPrimitive.List), [inner]); + + private static DarModel ReferenceHoldingLocalInterface( + string packageName, + string moduleName, + string interfaceName, + DamlType getCidReturnType) + { + var module = new DamlModule + { + Name = moduleName, + Templates = + [ + new DamlTemplate + { + Name = "Reference", + Fields = [new DamlFieldDefinition("cid", ContractIdOf(moduleName, interfaceName))], + Choices = + [ + new DamlChoice + { + Name = "GetCid", + Consuming = false, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = getCidReturnType, + }, + new DamlChoice + { + Name = "SetCid", + Consuming = true, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = ContractIdOf(moduleName, "Reference"), + }, + ], + }, + ], + DataTypes = + [ + new DamlDataType + { + Name = "Reference", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("cid", ContractIdOf(moduleName, interfaceName))]), + }, + new DamlDataType { Name = interfaceName, Definition = new DamlRecordDefinition([]) }, + new DamlDataType + { + Name = "View", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))]), + }, + ], + Interfaces = [new DamlInterface { Name = interfaceName, ViewType = new DamlTypeRef("", moduleName, "View"), Choices = [] }], + }; + + var package = new DamlPackage + { + PackageId = $"{packageName}-id", + Name = packageName, + Version = new Version(4, 0, 0), + LfVersion = "2.1", + Modules = [module], + DependencyReferences = [], + }; + + return new DarModel { MainPackage = package, Dependencies = [] }; + } + + private static void CompilesCleanly(DarModel dar, string because) + { + var files = CreateGenerator().Generate(dar); + var errors = CompileEmittedFiles(files).Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + errors.Should().BeEmpty(because + ", but got: {0}", string.Join("\n", errors.Select(e => e.GetMessage() + " @ " + e.Location))); + } + + [Fact] + public void finance_holding_reference_choice_returning_local_factory_interface_cid_compiles() + { + var dar = ReferenceHoldingLocalInterface( + "daml-finance-interface-holding-v4", + "Daml.Finance.Interface.Holding.V4.Factory", + "Factory", + ContractIdOf("Daml.Finance.Interface.Holding.V4.Factory", "Factory")); + + CompilesCleanly(dar, "a Reference/GetCid choice returning ContractId Factory (a local interface) must not project via IFactory.TemplateId"); + } + + [Fact] + public void finance_instrument_base_reference_choice_returning_local_instrument_interface_cid_compiles() + { + var dar = ReferenceHoldingLocalInterface( + "daml-finance-interface-instrument-base-v4", + "Daml.Finance.Interface.Instrument.Base.V4.Instrument", + "Instrument", + ContractIdOf("Daml.Finance.Interface.Instrument.Base.V4.Instrument", "Instrument")); + + CompilesCleanly(dar, "a Reference/GetCid choice returning ContractId Instrument (a local interface) must not project via IInstrument.TemplateId"); + } + + [Fact] + public void template_choice_returning_optional_local_interface_cid_compiles() + { + var module = "Daml.Finance.Interface.Holding.V4.Factory"; + var dar = ReferenceHoldingLocalInterface( + "daml-finance-interface-holding-v4", + module, + "Factory", + OptionalOf(ContractIdOf(module, "Factory"))); + + CompilesCleanly(dar, "an optional-cardinality interface-typed created slot must project via InterfaceIds"); + } + + [Fact] + public void template_choice_returning_list_of_local_interface_cid_compiles() + { + var module = "Daml.Finance.Interface.Holding.V4.Factory"; + var dar = ReferenceHoldingLocalInterface( + "daml-finance-interface-holding-v4", + module, + "Factory", + ListOf(ContractIdOf(module, "Factory"))); + + CompilesCleanly(dar, "a list-cardinality interface-typed created slot must project via InterfaceIds"); + } + + [Fact] + public void template_choice_returning_foreign_package_interface_cid_compiles() + { + const string foreignPackageId = "foreign-holding-pkg-id"; + var foreignModule = new DamlModule + { + Name = "Foreign.Holding", + Templates = [], + DataTypes = [new DamlDataType { Name = "Holding", Definition = new DamlRecordDefinition([]) }], + Interfaces = [new DamlInterface { Name = "Holding", ViewType = null, Choices = [] }], + }; + var foreignPackage = new DamlPackage + { + PackageId = foreignPackageId, + Name = "foreign-holding-pkg", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [foreignModule], + DependencyReferences = [], + }; + + var mainModule = new DamlModule + { + Name = "Test.Module", + Templates = + [ + new DamlTemplate + { + Name = "Vault", + Fields = [new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))], + Choices = + [ + new DamlChoice + { + Name = "GetHolding", + Consuming = false, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = new DamlTypeApp( + new DamlPrimitiveType(DamlPrimitive.ContractId), + [new DamlTypeRef(foreignPackageId, "Foreign.Holding", "Holding")]), + }, + ], + }, + ], + DataTypes = + [ + new DamlDataType + { + Name = "Vault", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))]), + }, + ], + Interfaces = [], + }; + var mainPackage = new DamlPackage + { + PackageId = "main-vault-pkg-id", + Name = "main-vault-pkg", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [mainModule], + DependencyReferences = [], + }; + + var dar = new DarModel { MainPackage = mainPackage, Dependencies = [foreignPackage] }; + var options = new CodeGenOptions + { + EnableNullableReferenceTypes = true, + UseFileScopedNamespaces = true, + UseRecordTypes = true, + UsePrimaryConstructors = true, + IncludeDependencies = true, + }; + var files = CreateGenerator(options).Generate(dar); + var errors = CompileEmittedFiles(files).Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + errors.Should().BeEmpty( + "a choice returning ContractId of a foreign-package interface must project via the foreign marker's generated InterfaceId symbol, but got: {0}", + string.Join("\n", errors.Select(e => e.GetMessage() + " @ " + e.Location))); + } + + [Fact] + public void template_choice_returning_template_and_interface_cid_tuple_compiles() + { + var module = new DamlModule + { + Name = "Test.Module", + Templates = + [ + new DamlTemplate + { + Name = "Widget", + Fields = [], + Choices = [], + }, + new DamlTemplate + { + Name = "Vault", + Fields = [new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))], + Choices = + [ + new DamlChoice + { + Name = "IssueBoth", + Consuming = false, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = TupleType(ContractIdOf("Test.Module", "Widget"), ContractIdOf("Test.Module", "Factory")), + }, + ], + }, + ], + DataTypes = + [ + new DamlDataType { Name = "Widget", Definition = new DamlRecordDefinition([]) }, + new DamlDataType + { + Name = "Vault", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))]), + }, + new DamlDataType { Name = "Factory", Definition = new DamlRecordDefinition([]) }, + ], + Interfaces = [new DamlInterface { Name = "Factory", ViewType = null, Choices = [] }], + }; + + var package = new DamlPackage + { + PackageId = "mixed-slot-pkg-id", + Name = "mixed-slot-pkg", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [module], + DependencyReferences = [], + }; + + var dar = new DarModel { MainPackage = package, Dependencies = [DamlPrim] }; + + CompilesCleanly(dar, "a choice returning a tuple of a same-named template cid and a local interface cid must compile both the TemplateId and InterfaceIds projector branches"); + } + + private static DarModel TemplateMarkerCollisionDar() + { + var module = new DamlModule + { + Name = "Test.Module", + Templates = + [ + new DamlTemplate + { + Name = "IFactory", + Fields = [], + Choices = [], + }, + new DamlTemplate + { + Name = "Vault", + Fields = [new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))], + Choices = + [ + new DamlChoice + { + Name = "IssueBoth", + Consuming = false, + ArgumentType = new DamlPrimitiveType(DamlPrimitive.Unit), + ReturnType = TupleType(ContractIdOf("Test.Module", "IFactory"), ContractIdOf("Test.Module", "Factory")), + }, + ], + }, + ], + DataTypes = + [ + new DamlDataType { Name = "IFactory", Definition = new DamlRecordDefinition([]) }, + new DamlDataType + { + Name = "Vault", + Definition = new DamlRecordDefinition([new DamlFieldDefinition("owner", new DamlPrimitiveType(DamlPrimitive.Party))]), + }, + new DamlDataType { Name = "Factory", Definition = new DamlRecordDefinition([]) }, + ], + Interfaces = [new DamlInterface { Name = "Factory", ViewType = null, Choices = [] }], + }; + + var package = new DamlPackage + { + PackageId = "template-marker-collision-pkg-id", + Name = "template-marker-collision-pkg", + Version = new Version(1, 0, 0), + LfVersion = "2.1", + Modules = [module], + DependencyReferences = [], + }; + + return new DarModel { MainPackage = package, Dependencies = [DamlPrim] }; + } + + [Fact] + public void disambiguates_template_colliding_with_interface_marker_name() + { + CompilesCleanly(TemplateMarkerCollisionDar(), "a template literally named IFactory and an interface Factory (whose generated marker is also IFactory) must not both declare a public IFactory type in the same namespace"); + } + + [Fact] + public void writes_the_disambiguated_marker_file_for_a_template_colliding_with_it() + { + var files = CreateGenerator().Generate(TemplateMarkerCollisionDar()).ToList(); + + files.Select(f => f.RelativePath).Should().Contain(p => p.EndsWith("IFactory_.cs")); + } +} diff --git a/tests/Daml.Codegen.CSharp.Tests/IntermediatePackageContentHashTests.cs b/tests/Daml.Codegen.CSharp.Tests/IntermediatePackageContentHashTests.cs deleted file mode 100644 index 4a959ea..0000000 --- a/tests/Daml.Codegen.CSharp.Tests/IntermediatePackageContentHashTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2026 Peaceful Studio OÜ -// SPDX-License-Identifier: Apache-2.0 - -using Daml.Codegen.CSharp.Versioning; -using Daml.Codegen.Intermediate; -using AwesomeAssertions; -using Xunit; - -namespace Daml.Codegen.CSharp.Tests; - -public class IntermediatePackageContentHashTests -{ - [Fact] - public void Compute_returns_the_same_hash_for_field_identical_packages() - { - var first = MakePackage("Splice.Amulet", "0.1.17"); - var second = MakePackage("Splice.Amulet", "0.1.17"); - - IntermediatePackageContentHash.Compute(first) - .Should().Be(IntermediatePackageContentHash.Compute(second)); - } - - [Fact] - public void Compute_returns_a_different_hash_when_any_field_changes() - { - var baseline = MakePackage("Splice.Amulet", "0.1.17"); - var renamed = MakePackage("Splice.Util", "0.1.17"); - var bumped = MakePackage("Splice.Amulet", "0.1.18"); - - var baselineHash = IntermediatePackageContentHash.Compute(baseline); - IntermediatePackageContentHash.Compute(renamed).Should().NotBe(baselineHash); - IntermediatePackageContentHash.Compute(bumped).Should().NotBe(baselineHash); - } - - private static IntermediatePackage MakePackage(string name, string version) => - new() - { - PackageId = "deadbeef", - PackageName = name, - PackageVersion = version, - LanguageVersion = "2.1", - }; -} diff --git a/tests/Daml.Codegen.CSharp.Tests/NuGetPackIntegrationTests.cs b/tests/Daml.Codegen.CSharp.Tests/NuGetPackIntegrationTests.cs index 6fc9e1d..d547b30 100644 --- a/tests/Daml.Codegen.CSharp.Tests/NuGetPackIntegrationTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/NuGetPackIntegrationTests.cs @@ -132,7 +132,7 @@ public async Task generated_csproj_packs_into_nupkg_for_splice_holding_v1_fixtur nuspecVersion.Should().MatchRegex(@"^\d+\.\d+\.\d+(\.\d+)?$", "the .nuspec must carry an M.m.p[.r] version (4th segment is normalized away by NuGet when r=0)"); nuspecVersion.Split('.').Length.Should().BeGreaterThanOrEqualTo(3, - "the M.m.p.r versioning scheme requires at least the 3-part DAR-intrinsic version in the manifest"); + "the M.m.p.g versioning scheme requires at least the 3-part DAR-intrinsic version in the manifest"); var nuspecLicense = ReadNuspecLicense(nupkg!); nuspecLicense.Should().Be("Apache-2.0", diff --git a/tests/Daml.Codegen.CSharp.Tests/NuGetVersionResolverTests.cs b/tests/Daml.Codegen.CSharp.Tests/NuGetVersionResolverTests.cs index 90cb047..4adf190 100644 --- a/tests/Daml.Codegen.CSharp.Tests/NuGetVersionResolverTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/NuGetVersionResolverTests.cs @@ -26,14 +26,13 @@ public void Dispose() } [Fact] - public void Compute_returns_FourPartPackageVersion_with_Revision_zero_on_first_emission() + public void compute_stamps_generation_ordinal_from_store() { var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); var version = NuGetVersionResolver.Compute( - packageName: "Splice.Amulet", intrinsicVersion: new Version(0, 1, 17), - contentHash: "deadbeef", + codegenVersion: "0.2.0-preview.3", counterStore: store); version.Should().Be(new FourPartPackageVersion(0, 1, 17, 0)); @@ -41,13 +40,25 @@ public void Compute_returns_FourPartPackageVersion_with_Revision_zero_on_first_e } [Fact] - public void Compute_bumps_Revision_when_contentHash_changes_under_same_intrinsicVersion() + public void compute_yields_same_ordinal_for_different_packages_under_one_codegen_version() { var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); - NuGetVersionResolver.Compute("Splice.Amulet", new Version(0, 1, 17), "hash-a", store) + var amulet = NuGetVersionResolver.Compute(new Version(0, 1, 17), "0.2.0-preview.3", store); + var holding = NuGetVersionResolver.Compute(new Version(3, 4, 5), "0.2.0-preview.3", store); + + amulet.Should().Be(new FourPartPackageVersion(0, 1, 17, 0)); + holding.Should().Be(new FourPartPackageVersion(3, 4, 5, 0)); + } + + [Fact] + public void compute_increments_ordinal_when_codegen_version_changes() + { + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + + NuGetVersionResolver.Compute(new Version(0, 1, 17), "0.2.0-preview.3", store) .Should().Be(new FourPartPackageVersion(0, 1, 17, 0)); - NuGetVersionResolver.Compute("Splice.Amulet", new Version(0, 1, 17), "hash-b", store) + NuGetVersionResolver.Compute(new Version(0, 1, 17), "0.2.0-preview.4", store) .Should().Be(new FourPartPackageVersion(0, 1, 17, 1)); } } diff --git a/tests/Daml.Codegen.CSharp.Tests/ProjectFileGeneratorTests.Versioning.cs b/tests/Daml.Codegen.CSharp.Tests/ProjectFileGeneratorTests.Versioning.cs index 9f942c3..918b0d7 100644 --- a/tests/Daml.Codegen.CSharp.Tests/ProjectFileGeneratorTests.Versioning.cs +++ b/tests/Daml.Codegen.CSharp.Tests/ProjectFileGeneratorTests.Versioning.cs @@ -53,7 +53,7 @@ public void GenerateProjectFile_should_throw_on_negative_EmitterCounter() var act = () => generator.GenerateProjectFile(package); act.Should().Throw() - .WithMessage("*EmitterCounter*M.m.p.r*"); + .WithMessage("*EmitterCounter*M.m.p.g*"); } [Fact] @@ -74,7 +74,7 @@ public void GenerateProjectFile_should_throw_on_two_part_dar_version() var act = () => generator.GenerateProjectFile(package); act.Should().Throw() - .WithMessage("*3-part*M.m.p.r*"); + .WithMessage("*3-part*M.m.p.g*"); } [Fact] diff --git a/tests/Daml.Codegen.CSharp.Tests/ReleaseCounterStoreTests.cs b/tests/Daml.Codegen.CSharp.Tests/ReleaseCounterStoreTests.cs index cac5684..7296fe0 100644 --- a/tests/Daml.Codegen.CSharp.Tests/ReleaseCounterStoreTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/ReleaseCounterStoreTests.cs @@ -26,136 +26,304 @@ public void Dispose() } [Fact] - public void ResolveRevision_returns_zero_and_persists_entry_for_unknown_pair() + public void resolve_generation_returns_zero_for_first_codegen_version_in_empty_store() { var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); - var revision = store.ResolveRevision( - packageName: "Splice.Amulet", - intrinsicVersion: new Version(0, 1, 17), - contentHash: "abc123"); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); + } - revision.Should().Be(0); + [Fact] + public void resolve_generation_holds_ordinal_steady_when_same_codegen_version_reresolved() + { + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); - var reopened = JsonReleaseCounterStore.OpenOrCreate(_storePath); - reopened.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "abc123") - .Should().Be(0); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); } [Fact] - public void ResolveRevision_holds_revision_steady_when_content_hash_matches() + public void resolve_generation_increments_ordinal_when_codegen_version_changes() { var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-a").Should().Be(0); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-a").Should().Be(0); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-a").Should().Be(0); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); + store.ResolveGeneration("0.2.0-preview.4").Should().Be(1); + store.ResolveGeneration("0.3.0").Should().Be(2); + store.ResolveGeneration("0.2.0-preview.4").Should().Be(1); } [Fact] - public void ResolveRevision_persists_bumps_across_OpenOrCreate_reopens() + public void resolve_generation_seeds_high_water_from_legacy_revision_entries() { - JsonReleaseCounterStore.OpenOrCreate(_storePath) - .ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-a") - .Should().Be(0); - JsonReleaseCounterStore.OpenOrCreate(_storePath) - .ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-b") - .Should().Be(1); + File.WriteAllText( + _storePath, + "{ \"Splice.Amulet@0.1.17\": { \"content_hash\": \"x\", \"revision\": 2 } }"); - JsonReleaseCounterStore.OpenOrCreate(_storePath) - .ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-b") - .Should().Be(1); + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + + store.ResolveGeneration("0.2.0-preview.3").Should().Be(3); } [Fact] - public void ResolveRevision_tracks_each_packageName_and_intrinsic_version_independently() + public void resolve_generation_seeds_high_water_from_the_highest_legacy_revision_across_entries() { + File.WriteAllText( + _storePath, + "{ \"Daml.Finance.Account@1.0.0\": { \"content_hash\": \"a\", \"revision\": 0 }, " + + "\"Daml.Finance.Holding@2.0.0\": { \"content_hash\": \"b\", \"revision\": 1 } }"); + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "amulet-hash").Should().Be(0); - store.ResolveRevision("Splice.Util", new Version(0, 1, 5), "util-hash").Should().Be(0); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 18), "amulet-next").Should().Be(0); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "amulet-rebuilt").Should().Be(1); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(2); + } - store.ResolveRevision("Splice.Util", new Version(0, 1, 5), "util-hash").Should().Be(0); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 18), "amulet-next").Should().Be(0); + [Fact] + public void resolve_generation_persists_across_open_or_create_reopens() + { + JsonReleaseCounterStore.OpenOrCreate(_storePath).ResolveGeneration("0.2.0-preview.3").Should().Be(0); + JsonReleaseCounterStore.OpenOrCreate(_storePath).ResolveGeneration("0.2.0-preview.4").Should().Be(1); + + JsonReleaseCounterStore.OpenOrCreate(_storePath).ResolveGeneration("0.2.0-preview.3").Should().Be(0); + JsonReleaseCounterStore.OpenOrCreate(_storePath).ResolveGeneration("0.2.0-preview.4").Should().Be(1); } [Fact] - public void OpenOrCreate_throws_InvalidDataException_when_an_entry_value_is_null() + public void resolve_generation_drops_legacy_entries_after_the_first_mint_rewrites_the_store() { - File.WriteAllText(_storePath, "{ \"Splice.Amulet@0.1.17\": null }"); + File.WriteAllText( + _storePath, + "{ \"Splice.Amulet@0.1.17\": { \"content_hash\": \"x\", \"revision\": 2 } }"); + + JsonReleaseCounterStore.OpenOrCreate(_storePath).ResolveGeneration("0.2.0-preview.3").Should().Be(3); + + using var document = JsonDocument.Parse(File.ReadAllText(_storePath)); + var names = document.RootElement.EnumerateObject().Select(p => p.Name).ToList(); + names.Should().HaveCount(2) + .And.Contain("store_version") + .And.Contain("codegen_generations"); + } + + [Fact] + public void persist_writes_codegen_generations_map_in_snake_case() + { + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); + + using var document = JsonDocument.Parse(File.ReadAllText(_storePath)); + var generations = document.RootElement.GetProperty("codegen_generations"); + generations.GetProperty("0.2.0-preview.3").GetInt32().Should().Be(0); + } + + [Fact] + public void persist_writes_a_store_version_marker_matching_the_current_schema_version() + { + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); + + using var document = JsonDocument.Parse(File.ReadAllText(_storePath)); + document.RootElement.GetProperty("store_version").GetInt32().Should().Be(1); + } + + [Fact] + public void persist_does_not_leave_a_dot_tmp_sibling_after_a_successful_write() + { + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); + + File.Exists(_storePath).Should().BeTrue(); + File.Exists(_storePath + ".tmp").Should().BeFalse(); + } + + [Fact] + public void open_or_create_throws_invalid_data_exception_naming_the_path_when_file_contains_malformed_json() + { + File.WriteAllText(_storePath, "{ this is not valid json"); var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); action.Should().Throw() - .Which.Message.Should().Contain("Splice.Amulet@0.1.17"); + .Which.Message.Should().Contain(_storePath); } [Fact] - public void OpenOrCreate_throws_InvalidDataException_when_an_entry_revision_is_negative() + public void open_or_create_throws_invalid_data_exception_when_a_generation_ordinal_is_negative() { - File.WriteAllText(_storePath, "{ \"Splice.Amulet@0.1.17\": { \"content_hash\": \"abc\", \"revision\": -5 } }"); + File.WriteAllText(_storePath, "{ \"codegen_generations\": { \"0.2.0-preview.3\": -5 } }"); var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); action.Should().Throw() - .Which.Message.Should().Contain("Splice.Amulet@0.1.17"); + .Which.Message.Should().Contain("0.2.0-preview.3"); } [Fact] - public void OpenOrCreate_throws_InvalidDataException_when_an_entry_content_hash_is_null() + public void resolve_generation_seeds_high_water_across_both_new_generations_and_legacy_entries() { - File.WriteAllText(_storePath, "{ \"Splice.Amulet@0.1.17\": { \"content_hash\": null, \"revision\": 1 } }"); + File.WriteAllText( + _storePath, + "{ \"store_version\": 1, \"codegen_generations\": { \"0.2.0-preview.3\": 1 }, " + + "\"Splice.Amulet@0.1.17\": { \"content_hash\": \"x\", \"revision\": 4 } }"); + + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + + store.ResolveGeneration("0.2.0-preview.3").Should().Be(1); + store.ResolveGeneration("0.9.9").Should().Be(5); + } + + [Fact] + public void open_or_create_resolves_a_new_shape_store_with_the_correct_store_version() + { + File.WriteAllText( + _storePath, + "{ \"store_version\": 1, \"codegen_generations\": { \"0.2.0-preview.3\": 4 } }"); + + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); + + store.ResolveGeneration("0.2.0-preview.3").Should().Be(4); + store.ResolveGeneration("0.9.9").Should().Be(5); + } + + [Fact] + public void open_or_create_throws_invalid_data_exception_when_codegen_generations_is_present_without_a_store_version_marker() + { + File.WriteAllText( + _storePath, + "{ \"codegen_generations\": { \"0.2.0-preview.3\": 4 } }"); var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); action.Should().Throw() - .Which.Message.Should().Contain("Splice.Amulet@0.1.17"); + .Which.Message.Should().Contain("store_version"); } [Fact] - public void OpenOrCreate_throws_InvalidDataException_naming_the_path_when_file_contains_malformed_json() + public void open_or_create_throws_invalid_data_exception_when_the_store_version_is_unrecognized() { - File.WriteAllText(_storePath, "{ this is not valid json"); + File.WriteAllText( + _storePath, + "{ \"store_version\": 99, \"codegen_generations\": { \"0.2.0-preview.3\": 4 } }"); var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); action.Should().Throw() - .Which.Message.Should().Contain(_storePath); + .Which.Message.Should().Contain("store_version"); } [Fact] - public void Persist_does_not_leave_a_dot_tmp_sibling_after_a_successful_write() + public void open_or_create_throws_invalid_data_exception_when_the_store_version_is_not_numeric() { - var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "deadbeef").Should().Be(0); + File.WriteAllText( + _storePath, + "{ \"store_version\": \"1\", \"codegen_generations\": { \"0.2.0-preview.3\": 4 } }"); - File.Exists(_storePath).Should().BeTrue(); - File.Exists(_storePath + ".tmp").Should().BeFalse(); + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); + + action.Should().Throw() + .Which.Message.Should().Contain("store_version"); } [Fact] - public void Persist_writes_packageName_at_intrinsicVersion_key_with_snake_case_entry_fields() + public void open_or_create_throws_invalid_data_exception_when_a_store_version_marker_is_present_without_codegen_generations() { + File.WriteAllText(_storePath, "{ \"store_version\": 1 }"); + + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); + + action.Should().Throw() + .Which.Message.Should().Contain("codegen_generations"); + } + + [Fact] + public void open_or_create_does_not_require_a_store_version_marker_for_a_legacy_only_store() + { + File.WriteAllText( + _storePath, + "{ \"Splice.Amulet@0.1.17\": { \"content_hash\": \"x\", \"revision\": 2 } }"); + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "deadbeef").Should().Be(0); - using var document = JsonDocument.Parse(File.ReadAllText(_storePath)); - var entry = document.RootElement.GetProperty("Splice.Amulet@0.1.17"); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(3); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\n\t ")] + public void open_or_create_throws_invalid_data_exception_when_the_file_is_empty_or_whitespace(string blankContent) + { + File.WriteAllText(_storePath, blankContent); + + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); - entry.GetProperty("content_hash").GetString().Should().Be("deadbeef"); - entry.GetProperty("revision").GetInt32().Should().Be(0); + action.Should().Throw() + .Which.Message.Should().Contain(_storePath); } [Fact] - public void ResolveRevision_bumps_monotonically_each_time_the_content_hash_changes() + public void open_or_create_resolves_an_empty_json_object_as_a_bootstrap_empty_store() { + File.WriteAllText(_storePath, "{}"); + var store = JsonReleaseCounterStore.OpenOrCreate(_storePath); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-a").Should().Be(0); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-b").Should().Be(1); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-c").Should().Be(2); - store.ResolveRevision("Splice.Amulet", new Version(0, 1, 17), "hash-c").Should().Be(2); + store.ResolveGeneration("0.2.0-preview.3").Should().Be(0); + } + + [Fact] + public void open_or_create_throws_invalid_data_exception_when_a_legacy_entry_value_is_null() + { + File.WriteAllText(_storePath, "{ \"Splice.Amulet@0.1.17\": null }"); + + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); + + action.Should().Throw() + .Which.Message.Should().Contain("Splice.Amulet@0.1.17"); + } + + [Fact] + public void open_or_create_throws_invalid_data_exception_when_a_legacy_entry_content_hash_is_null() + { + File.WriteAllText(_storePath, "{ \"Splice.Amulet@0.1.17\": { \"content_hash\": null, \"revision\": 1 } }"); + + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); + + action.Should().Throw() + .Which.Message.Should().Contain("Splice.Amulet@0.1.17"); + } + + [Fact] + public void open_or_create_throws_invalid_data_exception_when_a_legacy_entry_content_hash_is_missing() + { + File.WriteAllText(_storePath, "{ \"Splice.Amulet@0.1.17\": { \"revision\": 1 } }"); + + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); + + action.Should().Throw() + .Which.Message.Should().Contain("Splice.Amulet@0.1.17"); + } + + [Fact] + public void open_or_create_throws_invalid_data_exception_when_a_legacy_entry_revision_is_negative() + { + File.WriteAllText(_storePath, "{ \"Splice.Amulet@0.1.17\": { \"content_hash\": \"abc\", \"revision\": -5 } }"); + + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); + + action.Should().Throw() + .Which.Message.Should().Contain("Splice.Amulet@0.1.17"); + } + + [Fact] + public void open_or_create_throws_rather_than_minting_zero_when_all_legacy_entries_are_malformed() + { + File.WriteAllText( + _storePath, + "{ \"Splice.Amulet@0.1.17\": null, \"Splice.Util@0.1.5\": { \"revision\": -3 } }"); + + var action = () => JsonReleaseCounterStore.OpenOrCreate(_storePath); + + action.Should().Throw(); } } diff --git a/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-amulet-name-service/expected/Splice/Amulet/Name/Service/AmuletConversionRateFeed_ArchiveAsDsoResult.cs b/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-amulet-name-service/expected/Splice/Amulet/Name/Service/AmuletConversionRateFeed_ArchiveAsDsoResult.cs index 3ac3ee2..806276a 100644 --- a/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-amulet-name-service/expected/Splice/Amulet/Name/Service/AmuletConversionRateFeed_ArchiveAsDsoResult.cs +++ b/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-amulet-name-service/expected/Splice/Amulet/Name/Service/AmuletConversionRateFeed_ArchiveAsDsoResult.cs @@ -15,6 +15,7 @@ namespace Splice.Amulet.Name.Service; /// public enum AmuletConversionRateFeed_ArchiveAsDsoResult { + /// AmuletConversionRateFeed_ArchiveAsDsoResult enum constructor. AmuletConversionRateFeed_ArchiveAsDsoResult, } diff --git a/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-api-token-holding-v1/expected/Splice/Api/Token/Holding/V1/IHolding.cs b/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-api-token-holding-v1/expected/Splice/Api/Token/Holding/V1/IHolding.cs index 5461391..f88edd5 100644 --- a/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-api-token-holding-v1/expected/Splice/Api/Token/Holding/V1/IHolding.cs +++ b/tests/Daml.Codegen.CSharp.Tests/Snapshots/splice-api-token-holding-v1/expected/Splice/Api/Token/Holding/V1/IHolding.cs @@ -22,7 +22,10 @@ namespace Splice.Api.Token.Holding.V1; public interface IHolding : IDamlInterface, IHasView { /// Gets the interface identifier. - static Identifier IDamlInterface.InterfaceId => new("718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b", "Splice.Api.Token.HoldingV1", "Holding"); + static Identifier IDamlInterface.InterfaceId => InterfaceId; + + /// Gets the interface identifier. + public static new Identifier InterfaceId { get; } = new("718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b", "Splice.Api.Token.HoldingV1", "Holding"); /// Gets the package ID. static string IDamlInterface.PackageId => "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b"; diff --git a/tests/Daml.Codegen.CSharp.Tests/VersioningApiSurfaceTests.cs b/tests/Daml.Codegen.CSharp.Tests/VersioningApiSurfaceTests.cs index f232ba1..19c34d6 100644 --- a/tests/Daml.Codegen.CSharp.Tests/VersioningApiSurfaceTests.cs +++ b/tests/Daml.Codegen.CSharp.Tests/VersioningApiSurfaceTests.cs @@ -13,8 +13,6 @@ public class VersioningApiSurfaceTests public static TheoryData VersioningClusterTypes() => [ typeof(JsonReleaseCounterStore), - typeof(ReleaseCounterEntry), - typeof(IntermediatePackageContentHash), typeof(NuGetVersionResolver), typeof(FourPartPackageVersion), ]; diff --git a/tests/Daml.Codegen.Testing.Conformance.Tests/MarkerTests.cs b/tests/Daml.Codegen.Testing.Conformance.Tests/MarkerTests.cs index b25c4b4..1bf2431 100644 --- a/tests/Daml.Codegen.Testing.Conformance.Tests/MarkerTests.cs +++ b/tests/Daml.Codegen.Testing.Conformance.Tests/MarkerTests.cs @@ -11,7 +11,7 @@ namespace Daml.Codegen.Testing.Conformance.Tests; public class MarkerTests { - private const string PackageHash = "29997531c65a76719794e26591b1a3aa36accc050996752c640daff4e4d07bcb"; + private const string PackageHash = "22047ae2d2f5de6f0baaa0080343fe0c5d5e59507a5dfafc5c8ca141cfa40491"; [Fact] public void to_record_then_from_record_round_trips_the_owner() diff --git a/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordChoiceTests.cs b/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordChoiceTests.cs index 8b6c0e1..074f9be 100644 --- a/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordChoiceTests.cs +++ b/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordChoiceTests.cs @@ -55,6 +55,7 @@ public void contract_from_created_event_pairs_typed_id_with_decoded_payload() HoldingCids: new List>(), Profile: new Profile("n", 0), Outcome: new Outcome.Pending(), + Suit: Suit.Spades, Fee: 0m); var @event = new CreatedEvent( EventId: "ev-1", diff --git a/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordRoundTripTests.cs b/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordRoundTripTests.cs index d5521f2..1858688 100644 --- a/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordRoundTripTests.cs +++ b/tests/Daml.Codegen.Testing.Conformance.Tests/RichRecordRoundTripTests.cs @@ -32,6 +32,7 @@ public class RichRecordRoundTripTests }, Profile: new Profile("ace", 7), Outcome: new Outcome.Win(new Outcome_Win(Prize: 12.34m, Tier: "gold")), + Suit: Suit.Hearts, Fee: 1.5m); [Fact] @@ -112,6 +113,14 @@ public void to_record_wires_the_outcome_variant() win.GetRequiredField("tier").As().Value.Should().Be("gold"); } + [Fact] + public void to_record_wires_the_suit_enum() + { + var record = Sample(note: "hello").ToRecord(); + + record.GetRequiredField("suit").As().Constructor.Should().Be("Hearts"); + } + [Fact] public void non_default_scale_fee_serializes_unpadded() { @@ -131,6 +140,7 @@ public void non_default_scale_fee_serializes_unpadded() HoldingCids: new List>(), Profile: new Profile("n", 0), Outcome: new Outcome.Pending(), + Suit: Suit.Clubs, Fee: 1.5m).ToRecord(); var json = DamlJsonSerializer.Serialize(record.GetRequiredField("fee").As()); diff --git a/tests/Daml.Codegen.Testing.Conformance.Tests/SubmissionExtensionsTests.cs b/tests/Daml.Codegen.Testing.Conformance.Tests/SubmissionExtensionsTests.cs index 4c456db..3e30afd 100644 --- a/tests/Daml.Codegen.Testing.Conformance.Tests/SubmissionExtensionsTests.cs +++ b/tests/Daml.Codegen.Testing.Conformance.Tests/SubmissionExtensionsTests.cs @@ -56,6 +56,7 @@ public async Task rich_record_create_async_projects_a_created_contract_id() HoldingCids: new List>(), Profile: new Profile("n", 0), Outcome: new Outcome.Pending(), + Suit: Suit.Diamonds, Fee: 0m); var outcome = await client.CreateAsync(payload, new Party("alice"), TestContext.Current.CancellationToken); diff --git a/tests/Daml.Codegen.Testing.Conformance.Tests/SuitRoundTripTests.cs b/tests/Daml.Codegen.Testing.Conformance.Tests/SuitRoundTripTests.cs new file mode 100644 index 0000000..856288c --- /dev/null +++ b/tests/Daml.Codegen.Testing.Conformance.Tests/SuitRoundTripTests.cs @@ -0,0 +1,64 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using System; +using Daml.Runtime.Data; +using Daml.Runtime.Serialization; +using AwesomeAssertions; +using Daml.Codegen.Testing.Conformance.Richtypes; +using Xunit; + +namespace Daml.Codegen.Testing.Conformance.Tests; + +public class SuitRoundTripTests +{ + [Theory] + [InlineData(Suit.Clubs)] + [InlineData(Suit.Diamonds)] + [InlineData(Suit.Hearts)] + [InlineData(Suit.Spades)] + public void every_constructor_round_trips_through_DamlEnum(Suit original) + { + var restored = SuitExtensions.FromDamlEnum(original.ToDamlEnum()); + + restored.Should().Be(original); + } + + [Fact] + public void clubs_serializes_to_bare_string_wire_shape() + { + var json = DamlJsonSerializer.Serialize(Suit.Clubs.ToDamlEnum()); + + json.Should().Be("\"Clubs\""); + } + + [Fact] + public void spades_round_trips_through_json_deserialization_given_the_wire_type_back() + { + Suit original = Suit.Spades; + + var json = DamlJsonSerializer.Serialize(original.ToDamlEnum()); + var constructor = DamlJsonSerializer.Deserialize(json).As().Value; + var restored = SuitExtensions.FromDamlEnum(DamlEnum.Create(constructor)); + + restored.Should().Be(original); + } + + [Fact] + public void from_daml_enum_throws_for_an_unrecognized_constructor() + { + var act = () => SuitExtensions.FromDamlEnum(DamlEnum.Create("Joker")); + + act.Should().Throw(); + } + + [Fact] + public void to_daml_enum_throws_for_a_value_outside_the_declared_range() + { + var invalid = (Suit)99; + + var act = () => invalid.ToDamlEnum(); + + act.Should().Throw(); + } +} diff --git a/tests/Daml.Runtime.Tests/CaughtExceptionTests.cs b/tests/Daml.Runtime.Tests/CaughtExceptionTests.cs new file mode 100644 index 0000000..c42d529 --- /dev/null +++ b/tests/Daml.Runtime.Tests/CaughtExceptionTests.cs @@ -0,0 +1,62 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using Daml.Runtime.Contracts; +using Daml.Runtime.Data; +using AwesomeAssertions; +using Xunit; +using RuntimeIdentifier = Daml.Runtime.Data.Identifier; + +namespace Daml.Runtime.Tests; + +public class CaughtExceptionTests +{ + [Fact] + public void CaughtExceptions_defaults_to_empty_when_not_set() + { + var exercised = MakeExercisedEvent(); + + exercised.CaughtExceptions.Should().NotBeNull(); + exercised.CaughtExceptions.Should().BeEmpty(); + } + + [Fact] + public void CaughtExceptions_round_trips_a_single_caught_exception() + { + var caught = new CaughtException( + ErrorId: "Acme.Errors:InsufficientFunds", + Message: "not enough funds", + Metadata: new Dictionary { ["required"] = "100" }); + + var exercised = MakeExercisedEvent() with { CaughtExceptions = [caught] }; + + exercised.CaughtExceptions.Should().ContainSingle().Which.Should().Be(caught); + exercised.CaughtExceptions[0].ErrorId.Should().Be("Acme.Errors:InsufficientFunds"); + exercised.CaughtExceptions[0].Message.Should().Be("not enough funds"); + exercised.CaughtExceptions[0].Metadata.Should().ContainKey("required").WhoseValue.Should().Be("100"); + } + + [Fact] + public void CaughtExceptions_round_trips_multiple_caught_exceptions() + { + var first = new CaughtException("Acme.Errors:InsufficientFunds", "not enough funds", new Dictionary()); + var second = new CaughtException("Acme.Errors:Expired", "offer expired", new Dictionary()); + + var exercised = MakeExercisedEvent() with { CaughtExceptions = [first, second] }; + + exercised.CaughtExceptions.Should().HaveCount(2); + exercised.CaughtExceptions[0].Should().Be(first); + exercised.CaughtExceptions[1].Should().Be(second); + } + + private static ExercisedEvent MakeExercisedEvent() => new( + ContractId: "00alice", + TemplateId: new RuntimeIdentifier("test-pkg", "Acme.Foo", "FooBar"), + InterfaceId: null, + ChoiceName: "DoThing", + ChoiceArgument: DamlUnit.Instance, + ExerciseResult: DamlUnit.Instance, + Consuming: false, + ActingParties: [new Party("alice")], + WitnessParties: [new Party("alice")]); +} diff --git a/tests/Daml.Runtime.Tests/CommandTypesTests.cs b/tests/Daml.Runtime.Tests/CommandTypesTests.cs index 97c4696..10f6cd6 100644 --- a/tests/Daml.Runtime.Tests/CommandTypesTests.cs +++ b/tests/Daml.Runtime.Tests/CommandTypesTests.cs @@ -280,6 +280,8 @@ public void CommandsSubmission_should_leave_optional_ids_null_when_omitted() submission.WorkflowId.Should().BeNull(); submission.CommandId.Should().BeNull(); + submission.SynchronizerId.Should().BeNull(); + submission.DisclosedContracts.Should().BeNull(); } [Fact] @@ -315,6 +317,20 @@ public void CommandsSubmission_WithCommandId_should_set_command_id() result.CommandId.Should().Be(new CommandId("cmd-456")); } + [Fact] + public void CommandsSubmission_WithSynchronizerId_should_set_synchronizer_id() + { + var command = new CreateCommand( + new Identifier("pkg", "Module", "Template"), + DamlRecord.Create()); + var submission = CommandsSubmission.Single(command); + + var result = submission.WithSynchronizerId(new SynchronizerId("global_sync::abc")); + + result.SynchronizerId.Should().Be(new SynchronizerId("global_sync::abc")); + result.Commands.Should().BeEquivalentTo(submission.Commands); + } + [Fact] public void CommandsSubmission_WithActAs_should_set_parties() { @@ -347,6 +363,104 @@ public void CommandsSubmission_WithReadAs_should_set_parties() result.ReadAs.Should().BeEquivalentTo(new[] { new Party("Charlie"), new Party("Diana") }); } + [Fact] + public void CommandsSubmission_WithDisclosedContracts_should_set_disclosed_contracts() + { + // Arrange + var command = new CreateCommand( + new Identifier("pkg", "Module", "Template"), + DamlRecord.Create()); + var submission = CommandsSubmission.Single(command); + var disclosedContract = new DisclosedContract( + "contract-id-1", + new Identifier("pkg", "Module", "Template"), + "created-event-blob"u8.ToArray()); + + // Act + var result = submission.WithDisclosedContracts(disclosedContract); + + // Assert + result.DisclosedContracts.Should().ContainSingle().Which.Should().Be(disclosedContract); + result.Commands.Should().BeEquivalentTo(submission.Commands); + } + + [Fact] + public void CommandsSubmission_WithDisclosedContracts_with_no_args_should_leave_disclosed_contracts_null() + { + var command = new CreateCommand( + new Identifier("pkg", "Module", "Template"), + DamlRecord.Create()); + var submission = CommandsSubmission.Single(command); + + var result = submission.WithDisclosedContracts(); + + result.DisclosedContracts.Should().BeNull(); + } + + [Fact] + public void CommandsSubmission_WithDisclosedContracts_with_null_array_should_leave_disclosed_contracts_null() + { + var command = new CreateCommand( + new Identifier("pkg", "Module", "Template"), + DamlRecord.Create()); + var submission = CommandsSubmission.Single(command); + + var result = submission.WithDisclosedContracts(null); + + result.DisclosedContracts.Should().BeNull(); + } + + [Fact] + public void DisclosedContract_equality_should_compare_blob_content_across_allocations() + { + var left = new DisclosedContract( + "contract-id-1", + new Identifier("pkg", "Module", "Template"), + "created-event-blob"u8.ToArray()); + var right = new DisclosedContract( + "contract-id-1", + new Identifier("pkg", "Module", "Template"), + "created-event-blob"u8.ToArray()); + + left.Should().Be(right); + (left == right).Should().BeTrue(); + left.GetHashCode().Should().Be(right.GetHashCode()); + } + + [Fact] + public void DisclosedContract_equality_should_distinguish_different_blob_content() + { + var identifier = new Identifier("pkg", "Module", "Template"); + var left = new DisclosedContract( + "contract-id-1", identifier, "created-event-blob"u8.ToArray()); + var right = new DisclosedContract( + "contract-id-1", identifier, "other-event-blob"u8.ToArray()); + + left.Should().NotBe(right); + (left != right).Should().BeTrue(); + } + + [Fact] + public void CommandsSubmission_WithSubmitter_should_preserve_disclosed_contracts() + { + // Arrange + var command = new CreateCommand( + new Identifier("pkg", "Module", "Template"), + DamlRecord.Create()); + var disclosedContract = new DisclosedContract( + "contract-id-1", + new Identifier("pkg", "Module", "Template"), + "created-event-blob"u8.ToArray()); + var submission = CommandsSubmission.Single(command) + .WithDisclosedContracts(disclosedContract); + + // Act + var result = submission.WithSubmitter(new Party("Alice")); + + // Assert + result.DisclosedContracts.Should().ContainSingle().Which.Should().Be(disclosedContract); + } + [Fact] public void CommandsSubmission_should_chain_fluent_methods() { @@ -354,19 +468,25 @@ public void CommandsSubmission_should_chain_fluent_methods() var command = new CreateCommand( new Identifier("pkg", "Module", "Template"), DamlRecord.Create()); + var disclosedContract = new DisclosedContract( + "contract-id-1", + new Identifier("pkg", "Module", "Template"), + "created-event-blob"u8.ToArray()); // Act var submission = CommandsSubmission.Single(command) .WithWorkflowId(new WorkflowId("workflow-1")) .WithCommandId(new CommandId("cmd-1")) .WithActAs(new Party("Alice")) - .WithReadAs(new Party("Bob")); + .WithReadAs(new Party("Bob")) + .WithDisclosedContracts(disclosedContract); // Assert submission.WorkflowId.Should().Be(new WorkflowId("workflow-1")); submission.CommandId.Should().Be(new CommandId("cmd-1")); submission.ActAs.Should().ContainSingle().Which.Should().Be(new Party("Alice")); submission.ReadAs.Should().ContainSingle().Which.Should().Be(new Party("Bob")); + submission.DisclosedContracts.Should().ContainSingle().Which.Should().Be(disclosedContract); } [Fact] diff --git a/tests/Daml.Runtime.Tests/ContractStreamEventTests.cs b/tests/Daml.Runtime.Tests/ContractStreamEventTests.cs index a777698..172ddf8 100644 --- a/tests/Daml.Runtime.Tests/ContractStreamEventTests.cs +++ b/tests/Daml.Runtime.Tests/ContractStreamEventTests.cs @@ -16,13 +16,14 @@ public void Variants_should_be_distinguishable_via_pattern_match() { ContractStreamEvent[] events = [ - new ContractStreamEvent.Created(new ContractId("c1"), DamlRecord.Create(), 1L, [new Party("alice")]), - new ContractStreamEvent.Archived(new ContractId("c1"), 2L, [new Party("alice")]), - new ContractStreamEvent.Exercised(new ContractId("c1"), "Accept", DamlUnit.Instance, DamlUnit.Instance, true, 3L, [new Party("alice")]), + new ContractStreamEvent.Created(new ContractId("c1"), DamlRecord.Create(), 1L, new SynchronizerId("sync"), [new Party("alice")]), + new ContractStreamEvent.Archived(new ContractId("c1"), 2L, new SynchronizerId("sync"), [new Party("alice")]), + new ContractStreamEvent.Exercised(new ContractId("c1"), "Accept", DamlUnit.Instance, DamlUnit.Instance, true, 3L, new SynchronizerId("sync"), [new Party("alice")]), new ContractStreamEvent.Assigned(new ContractId("c1"), DamlRecord.Create(), 4L, new SynchronizerId("src"), new SynchronizerId("tgt"), [new Party("alice")]), new ContractStreamEvent.Unassigned(new ContractId("c1"), 5L, new SynchronizerId("src"), new SynchronizerId("tgt"), [new Party("alice")]), new ContractStreamEvent.Checkpoint(6L), new ContractStreamEvent.StreamError(14, "unavailable"), + new ContractStreamEvent.Unclassified(7L, "TopologyEvent"), ]; var seen = events.Select(e => e switch @@ -34,10 +35,11 @@ public void Variants_should_be_distinguishable_via_pattern_match() ContractStreamEvent.Unassigned => "unassigned", ContractStreamEvent.Checkpoint => "checkpoint", ContractStreamEvent.StreamError => "error", + ContractStreamEvent.Unclassified => "unclassified", _ => "other", }).ToList(); - seen.Should().Equal("created", "archived", "exercised", "assigned", "unassigned", "checkpoint", "error"); + seen.Should().Equal("created", "archived", "exercised", "assigned", "unassigned", "checkpoint", "error", "unclassified"); } [Fact] @@ -48,6 +50,14 @@ public void Variants_with_same_payload_should_be_value_equal() a.Should().Be(b); } + [Fact] + public void Unclassified_with_same_payload_should_be_value_equal() + { + var a = new ContractStreamEvent.Unclassified(7L, "TopologyEvent"); + var b = new ContractStreamEvent.Unclassified(7L, "TopologyEvent"); + a.Should().Be(b); + } + [Fact] public void StreamError_StatusCode_is_int_so_no_transport_dep_leaks() { @@ -58,6 +68,15 @@ public void StreamError_StatusCode_is_int_so_no_transport_dep_leaks() err.StatusCode.Should().Be(14); } + [Fact] + public void Unclassified_should_expose_offset_and_kind() + { + var unclassified = new ContractStreamEvent.Unclassified(7L, "TopologyEvent"); + + unclassified.Offset.Should().Be(7L); + unclassified.Kind.Should().Be("TopologyEvent"); + } + private sealed record TestTemplate(string Owner) : ITemplate { public static Identifier TemplateId { get; } = new("pkg", "M", "TestTemplate"); diff --git a/tests/Daml.Runtime.Tests/SynchronizerIdFieldTypingTests.cs b/tests/Daml.Runtime.Tests/SynchronizerIdFieldTypingTests.cs index d4b1a7f..13a888a 100644 --- a/tests/Daml.Runtime.Tests/SynchronizerIdFieldTypingTests.cs +++ b/tests/Daml.Runtime.Tests/SynchronizerIdFieldTypingTests.cs @@ -13,8 +13,50 @@ public class SynchronizerIdFieldTypingTests { private static readonly SynchronizerId Source = new("global_sync::abc::35-0"); private static readonly SynchronizerId Target = new("local_sync::def::35-0"); + private static readonly SynchronizerId Domain = new("event_sync::xyz::35-0"); private static readonly Party Alice = new("alice"); + [Fact] + public void Created_SynchronizerId_should_be_typed_as_SynchronizerId() + { + var ev = new ContractStreamEvent.Created( + new ContractId("c1"), + DamlRecord.Create(), + 1L, + Domain, + [Alice]); + + ev.SynchronizerId.Should().BeOfType().And.Be(Domain); + } + + [Fact] + public void Archived_SynchronizerId_should_be_typed_as_SynchronizerId() + { + var ev = new ContractStreamEvent.Archived( + new ContractId("c1"), + 2L, + Domain, + [Alice]); + + ev.SynchronizerId.Should().BeOfType().And.Be(Domain); + } + + [Fact] + public void Exercised_SynchronizerId_should_be_typed_as_SynchronizerId() + { + var ev = new ContractStreamEvent.Exercised( + new ContractId("c1"), + "Accept", + DamlUnit.Instance, + DamlUnit.Instance, + true, + 3L, + Domain, + [Alice]); + + ev.SynchronizerId.Should().BeOfType().And.Be(Domain); + } + [Fact] public void Assigned_Source_should_be_typed_as_SynchronizerId() { diff --git a/tests/Daml.Runtime.Tests/TransactionTreeTests.cs b/tests/Daml.Runtime.Tests/TransactionTreeTests.cs new file mode 100644 index 0000000..a0ff0bc --- /dev/null +++ b/tests/Daml.Runtime.Tests/TransactionTreeTests.cs @@ -0,0 +1,187 @@ +// Copyright 2026 Peaceful Studio OÜ +// SPDX-License-Identifier: Apache-2.0 + +using Daml.Runtime.Contracts; +using Daml.Runtime.Data; +using AwesomeAssertions; +using Xunit; +using RuntimeIdentifier = Daml.Runtime.Data.Identifier; + +namespace Daml.Runtime.Tests; + +public class TransactionTreeTests +{ + private static readonly RuntimeIdentifier FooTemplateId = new("test-pkg", "Acme.Foo", "FooBar"); + + [Fact] + public void root_events_preserve_transaction_order() + { + var first = MakeCreated("00first"); + var second = MakeCreated("00second"); + + var tree = new TransactionTree("u1", 1L, [first, second]); + + tree.RootEvents.Should().HaveCount(2); + tree.RootEvents[0].Should().BeSameAs(first); + tree.RootEvents[1].Should().BeSameAs(second); + } + + [Fact] + public void child_events_are_reachable_from_exercised_node() + { + var child = MakeCreated("00child"); + var exercise = MakeExercised("00parent", children: [child]); + + exercise.ChildEvents.Should().ContainSingle().Which.Should().BeSameAs(child); + } + + [Fact] + public void descendant_events_is_empty_for_created_event() + { + var created = MakeCreated("00solo"); + + created.DescendantEvents().Should().BeEmpty(); + } + + [Fact] + public void descendant_events_is_empty_for_exercised_event_without_children() + { + var exercise = MakeExercised("00leaf", children: []); + + exercise.DescendantEvents().Should().BeEmpty(); + } + + [Fact] + public void descendant_events_enumerates_nested_children_depth_first() + { + var grandchild = MakeCreated("00grandchild"); + var innerExercise = MakeExercised("00inner", children: [grandchild]); + var outerCreated = MakeCreated("00sibling"); + var outerExercise = MakeExercised("00outer", children: [innerExercise, outerCreated]); + + var descendants = outerExercise.DescendantEvents().ToList(); + + descendants.Should().HaveCount(3); + descendants[0].Should().BeSameAs(innerExercise); + descendants[1].Should().BeSameAs(grandchild); + descendants[2].Should().BeSameAs(outerCreated); + } + + [Fact] + public void all_events_enumerates_roots_and_descendants_in_pre_order() + { + var child = MakeCreated("00child"); + var rootExercise = MakeExercised("00root-exercise", children: [child]); + var rootCreated = MakeCreated("00root-created"); + var tree = new TransactionTree("u1", 1L, [rootExercise, rootCreated]); + + var all = tree.AllEvents().ToList(); + + all.Should().HaveCount(3); + all[0].Should().BeSameAs(rootExercise); + all[1].Should().BeSameAs(child); + all[2].Should().BeSameAs(rootCreated); + } + + [Fact] + public void all_events_throws_when_tree_is_null() + { + TransactionTree tree = null!; + + Action act = () => tree.AllEvents(); + + act.Should().Throw(); + } + + [Fact] + public void to_transaction_result_flattens_created_events_with_serialized_payload() + { + var created = MakeCreated("00alice"); + var tree = new TransactionTree("u1", 5L, [created]); + + var result = tree.ToTransactionResult(); + + result.UpdateId.Should().Be("u1"); + result.CompletionOffset.Should().Be(5L); + result.CreatedContracts.Should().ContainSingle(); + result.CreatedContracts[0].ContractId.Should().Be("00alice"); + result.CreatedContracts[0].TemplateId.Should().Be(FooTemplateId); + result.CreatedContracts[0].Payload.Should().Contain("alice"); + } + + [Fact] + public void to_transaction_result_preserves_interface_ids_on_created_contracts() + { + var interfaceId = new RuntimeIdentifier("test-pkg", "Acme.Foo", "IAsset"); + var created = MakeCreated("00iface") with { InterfaceIds = [interfaceId] }; + var tree = new TransactionTree("u1", 1L, [created]); + + var result = tree.ToTransactionResult(); + + result.CreatedContracts.Should().ContainSingle() + .Which.InterfaceIds.Should().ContainSingle().Which.Should().Be(interfaceId); + } + + [Fact] + public void to_transaction_result_flattens_nested_exercised_events_in_pre_order() + { + var childCreate = MakeCreated("00child"); + var innerExercise = MakeExercised("00inner", children: [childCreate], choiceName: "Inner"); + var tree = new TransactionTree("u1", 1L, [innerExercise]); + + var result = tree.ToTransactionResult(); + + result.ExercisedEvents.Should().ContainSingle().Which.ChoiceName.Should().Be("Inner"); + result.CreatedContracts.Should().ContainSingle().Which.ContractId.Should().Be("00child"); + } + + [Fact] + public void to_transaction_result_collects_archived_contract_ids_from_consuming_exercises() + { + var consuming = MakeExercised("00consumed", children: [], consuming: true); + var nonConsuming = MakeExercised("00untouched", children: [], consuming: false); + var tree = new TransactionTree("u1", 1L, [consuming, nonConsuming]); + + var result = tree.ToTransactionResult(); + + result.ArchivedContractIds.Should().ContainSingle().Which.Should().Be("00consumed"); + } + + [Fact] + public void to_transaction_result_throws_when_tree_is_null() + { + TransactionTree tree = null!; + + Action act = () => tree.ToTransactionResult(); + + act.Should().Throw(); + } + + private static TreeEvent.Created MakeCreated(string contractId) => + new( + EventId: $"evt-{contractId}", + ContractId: contractId, + TemplateId: FooTemplateId, + CreateArguments: DamlRecord.Create(DamlField.Create("owner", new DamlParty("alice"))), + WitnessParties: [new Party("alice")], + Signatories: [new Party("alice")], + Observers: []); + + private static TreeEvent.Exercised MakeExercised( + string contractId, + IReadOnlyList children, + string choiceName = "DoThing", + bool consuming = false) => + new( + EventId: $"evt-{contractId}", + ContractId: contractId, + TemplateId: FooTemplateId, + InterfaceId: null, + ChoiceName: choiceName, + ChoiceArgument: DamlUnit.Instance, + ExerciseResult: DamlUnit.Instance, + Consuming: consuming, + ActingParties: [new Party("alice")], + WitnessParties: [new Party("alice")], + ChildEvents: children); +} diff --git a/tests/Daml.Runtime.Tests/WitnessPartiesTypedTests.cs b/tests/Daml.Runtime.Tests/WitnessPartiesTypedTests.cs index 3569913..dfc136b 100644 --- a/tests/Daml.Runtime.Tests/WitnessPartiesTypedTests.cs +++ b/tests/Daml.Runtime.Tests/WitnessPartiesTypedTests.cs @@ -21,6 +21,7 @@ public void Created_WitnessParties_should_be_Party_list() new ContractId("c1"), DamlRecord.Create(), 1L, + new SynchronizerId("sync"), [Alice, Bob]); ev.WitnessParties.Should().BeAssignableTo>(); @@ -33,6 +34,7 @@ public void Archived_WitnessParties_should_be_Party_list() var ev = new ContractStreamEvent.Archived( new ContractId("c1"), 2L, + new SynchronizerId("sync"), [Alice]); ev.WitnessParties.Should().BeAssignableTo>(); @@ -49,6 +51,7 @@ public void Exercised_WitnessParties_should_be_Party_list() DamlUnit.Instance, Consuming: true, Offset: 3L, + SynchronizerId: new SynchronizerId("sync"), WitnessParties: [Alice, Bob]); ev.WitnessParties.Should().BeAssignableTo>(); From a469d33df476ab33c3923def294e563441da3298 Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Thu, 2 Jul 2026 16:02:39 +0200 Subject: [PATCH 2/2] docs: remove internal issue-tracker references from 0.2.0-preview.2 changelog --- CHANGELOG.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96320e9..e5c93b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ because they are versioned in lockstep: `null` so existing submissions are unaffected; calling it with no arguments, `null`, or an empty array clears the field back to `null`. Record equality compares `CreatedEventBlob` by content, not by memory reference. This repo only carries the value — mapping it onto the gRPC - `DisclosedContract` message lives in the ledger-client repo. (#482) + `DisclosedContract` message lives in the ledger-client repo. - `Daml.Runtime.Commands.CommandsSubmission` gains an optional trailing `SynchronizerId? SynchronizerId` parameter and a `WithSynchronizerId(SynchronizerId)` fluent method, mirroring `WithWorkflowId`/`WithCommandId`, so callers can carry a @@ -63,7 +63,7 @@ because they are versioned in lockstep: consumers can tell whether a successful exercise recovered from a Daml `try`/`catch`. Additive — existing positional `ExercisedEvent` constructions stay source-compatible. Populating `CaughtExceptions` from the ledger wire - format is client-side and not yet implemented (#483). + format is client-side and not yet implemented. - `Daml.Runtime.Contracts.TransactionTree` and `TreeEvent` (`Created`/`Exercised` cases) — a transport-neutral, tree-shaped sibling of `TransactionResult` that preserves the parent/child hierarchy of a transaction's events (which creates @@ -71,13 +71,13 @@ because they are versioned in lockstep: payloads consistent with `ExercisedEvent`. `TreeEvent.DescendantEvents()` and `TransactionTreeExtensions.AllEvents`/`ToTransactionResult` give depth-first traversal and compat-flattening to the existing `TransactionResult` shape. - Additive — `TransactionResult` is unchanged. (#481) + Additive — `TransactionResult` is unchanged. - `Daml.Codegen.Testing.Conformance.Richtypes.Suit` — a new pure nullary-constructor Daml `enum` type in the `richtypes` conformance corpus, plus a `SuitExtensions` class (`ToDamlEnum()`/`FromDamlEnum()`) and a new `Suit Suit` field on `RichRecord` (positional constructor argument added after `Outcome`). Closes the enum coverage gap flagged as a follow-up in - the bundle-level determinism gate (#485). (#487) + the bundle-level determinism gate. ### Changed @@ -99,7 +99,7 @@ because they are versioned in lockstep: restore); and because all packages in a release now share one ordinal, co-produced sibling `` floors can no longer diverge from the versions actually published together. The first post-upgrade ordinal is seeded above every published - revision (Splice → 3, Daml.Finance → 2). (#474) + revision (Splice → 3, Daml.Finance → 2). ### Fixed @@ -108,7 +108,7 @@ because they are versioned in lockstep: generated member. Previously the emitter produced undocumented constructors, which built fine only because no pure nullary-constructor `enum` had ever been generated; the first one (`Richtypes.Suit`, added in this release) failed - the build with `CS1591` under `TreatWarningsAsErrors`. (#487) + the build with `CS1591` under `TreatWarningsAsErrors`. - A Daml template whose name equals another interface's generated `I`-prefixed marker name (e.g. template `IFactory` alongside interface `Factory`) no longer collides with it. A package's generated types all share one flat C# namespace, @@ -116,7 +116,7 @@ because they are versioned in lockstep: and the generated set failed to compile with `CS0101`. The interface marker name now appends a trailing `_` until it no longer collides with a template in its own package, consistently wherever the marker is referenced (declaration, - file name, and every in-package or cross-package type reference to it). (#488) + file name, and every in-package or cross-package type reference to it). - The `Result` projector (`FromCreatedContracts`) now matches an interface-typed created slot against the created contract's `InterfaceIds` @@ -126,7 +126,7 @@ because they are versioned in lockstep: `IDamlType` member), so the projector failed to compile with `CS0117`. Slots to a concrete template are unchanged. Surfaced by the full Splice/Daml.Finance release build (`daml-finance-interface-holding-v4`, - `daml-finance-interface-instrument-base-v4`). (#473) + `daml-finance-interface-instrument-base-v4`). - Generated interface markers now expose a plain `public static Identifier InterfaceId { get; }` alongside the existing explicit `IDamlInterface.InterfaceId` implementation. For a `ContractId I` choice-result slot targeting a foreign @@ -138,7 +138,7 @@ because they are versioned in lockstep: matching via string literals baked at codegen time: the LF-mandated record RecordEmitter always emits alongside a local `interface I where ...` declaration is a throwing `ITemplate` placeholder stub with no `InterfaceId` member, so those - slots cannot safely reference a generated symbol. (#473) + slots cannot safely reference a generated symbol. ## [0.2.0-preview.1] — 2026-06-30