From e4a50852c709355cd65bf7597780f7b750ee616d Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 12:54:40 +0000 Subject: [PATCH 01/21] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/link-cli/issues/96 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..b55727e --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-08-18T12:54:40.512Z for PR creation at branch issue-96-df5aa0703ffa for issue https://github.com/link-foundation/link-cli/issues/96 \ No newline at end of file From a88b162915175921b5f24e8fa9169aed13496f81 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:09:27 +0000 Subject: [PATCH 02/21] fix(csharp): release memory-mapped handles and fix CS1570 doc-comment warnings NamedLinksDecorator and SimpleLinksDecorator opened memory-mapped file handles for both the data and the names database but were not disposable, so callers had no way to release them. On POSIX an unlink of a still-mapped file succeeds, which hid the leak; on Windows the same delete fails with IOException. Both decorators now implement IDisposable. The reflection-based facade-chain disposal previously living inside NamedTypesDecorator is extracted into the shared LinksFacadeDisposer so all three decorators release handles identically. Also escapes Link as Link{uint} in the ChangesSimplifier doc comment, which removes the only two CS1570 warnings in the build. --- .../ChangesSimplifier.cs | 2 +- .../LinksFacadeDisposer.cs | 77 +++++++++++++++++++ .../NamedLinksDecorator.cs | 16 +++- .../NamedTypesDecorator.cs | 42 +--------- .../SimpleLinksDecorator.cs | 16 +++- 5 files changed, 110 insertions(+), 43 deletions(-) create mode 100644 csharp/Foundation.Data.Doublets.Cli.Library/LinksFacadeDisposer.cs diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs b/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs index 9786183..7024d18 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs @@ -184,7 +184,7 @@ public static class ChangesSimplifier } /// - /// An equality comparer for Link that checks Index/Source/Target. + /// An equality comparer for Link{uint} that checks Index/Source/Target. /// private class LinkEqualityComparer : IEqualityComparer> { diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/LinksFacadeDisposer.cs b/csharp/Foundation.Data.Doublets.Cli.Library/LinksFacadeDisposer.cs new file mode 100644 index 0000000..dd6203b --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/LinksFacadeDisposer.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli +{ + /// + /// Releases every links facade reachable from a decorator chain. + /// + /// + /// Links decorators wrap each other and ultimately own memory-mapped file handles. On POSIX a + /// mapped file can still be unlinked, so leaking those handles goes unnoticed; Windows uses + /// mandatory locking and fails the delete with . Disposing the + /// whole chain keeps behaviour identical on every platform. + /// + public static class LinksFacadeDisposer + { + /// + /// Disposes and every inner links facade it references, innermost first. + /// + /// The outermost facade, or . + public static void Dispose(object? facade) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + Dispose(facade, visited); + } + + private static void Dispose(object? facade, HashSet visited) + { + if (facade is null || !visited.Add(facade)) + { + return; + } + + foreach (var inner in EnumerateInnerLinks(facade)) + { + Dispose(inner, visited); + } + + if (facade is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private static IEnumerable EnumerateInnerLinks(object facade) + { + for (var type = facade.GetType(); type is not null; type = type.BaseType) + { + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (IsLinksFacade(field.FieldType)) + { + yield return field.GetValue(facade); + } + } + } + } + + private static bool IsLinksFacade(Type type) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ILinks<>)) + { + return true; + } + foreach (var @interface in type.GetInterfaces()) + { + if (@interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(ILinks<>)) + { + return true; + } + } + return false; + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs index 3eed47e..0e47e76 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs @@ -12,7 +12,7 @@ namespace Foundation.Data.Doublets.Cli { - public class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks + public class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -24,6 +24,8 @@ public class NamedLinksDecorator : LinksDecoratorBase NamedLinks; public readonly string NamedLinksDatabaseFileName; + private readonly ILinks _namedLinksFacade; + private bool _disposed; public static ILinks MakeLinks(string databaseFilename) { @@ -47,6 +49,7 @@ public NamedLinksDecorator(ILinks links, string namesDatabaseFilen var namesMemory = new FileMappedResizableDirectMemory(namesDatabaseFilename, UnitedMemoryLinks.DefaultLinksSizeStep); var namesLinks = new UnitedMemoryLinks(namesMemory, UnitedMemoryLinks.DefaultLinksSizeStep, namesConstants, IndexTreeType.Default); var decoratedNamesLinks = namesLinks.DecorateWithAutomaticUniquenessAndUsagesResolution(); + _namedLinksFacade = decoratedNamesLinks; NamedLinks = new UnicodeStringStorage(decoratedNamesLinks).NamedLinks; NamedLinksDatabaseFileName = namesDatabaseFilename; } @@ -56,6 +59,17 @@ public NamedLinksDecorator(string databaseFilename, bool tracingEnabled = false) { } + /// + /// Releases the memory-mapped file handles of both the data and the names databases. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + LinksFacadeDisposer.Dispose(_namedLinksFacade); + LinksFacadeDisposer.Dispose(_links); + } + /// /// Gets the name associated with the specified link address. /// diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs index 663d864..ee97eec 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs @@ -70,46 +70,8 @@ public void Dispose() { if (_disposed) return; _disposed = true; - DisposeLinksFacade(_namedLinksFacade); - DisposeLinksFacade(PinnedTypesDecorator); - } - - private static void DisposeLinksFacade(object? facade) - { - var visited = new HashSet(ReferenceEqualityComparer.Instance); - DisposeLinksFacade(facade, visited); - } - - private static void DisposeLinksFacade(object? facade, HashSet visited) - { - if (facade is null || !visited.Add(facade)) - { - return; - } - - foreach (var inner in EnumerateInnerLinks(facade)) - { - DisposeLinksFacade(inner, visited); - } - - if (facade is IDisposable disposable) - { - disposable.Dispose(); - } - } - - private static IEnumerable EnumerateInnerLinks(object facade) - { - for (var type = facade.GetType(); type is not null; type = type.BaseType) - { - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) - { - if (typeof(ILinks).IsAssignableFrom(field.FieldType)) - { - yield return field.GetValue(facade); - } - } - } + LinksFacadeDisposer.Dispose(_namedLinksFacade); + LinksFacadeDisposer.Dispose(PinnedTypesDecorator); } public IEnumerator GetEnumerator() diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs index fd7a427..e047e91 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs @@ -10,7 +10,7 @@ namespace Foundation.Data.Doublets.Cli { - public class SimpleLinksDecorator : LinksDecoratorBase + public class SimpleLinksDecorator : LinksDecoratorBase, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -22,6 +22,8 @@ public class SimpleLinksDecorator : LinksDecoratorBase NamedLinks; public readonly string NamedLinksDatabaseFileName; + private readonly ILinks _namedLinksFacade; + private bool _disposed; public static ILinks MakeLinks(string databaseFilename) { @@ -45,6 +47,7 @@ public SimpleLinksDecorator(ILinks links, string namesDatabaseFile var namesMemory = new FileMappedResizableDirectMemory(namesDatabaseFilename, UnitedMemoryLinks.DefaultLinksSizeStep); var namesLinks = new UnitedMemoryLinks(namesMemory, UnitedMemoryLinks.DefaultLinksSizeStep, namesConstants, IndexTreeType.Default); var decoratedNamesLinks = namesLinks.DecorateWithAutomaticUniquenessAndUsagesResolution(); + _namedLinksFacade = decoratedNamesLinks; NamedLinks = new UnicodeStringStorage(decoratedNamesLinks).NamedLinks; NamedLinksDatabaseFileName = namesDatabaseFilename; } @@ -54,6 +57,17 @@ public SimpleLinksDecorator(string databaseFilename, bool tracingEnabled = false { } + /// + /// Releases the memory-mapped file handles of both the data and the names databases. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + LinksFacadeDisposer.Dispose(_namedLinksFacade); + LinksFacadeDisposer.Dispose(_links); + } + public override TLinkAddress Delete(IList? restriction, WriteHandler? handler) { var constants = _links.Constants; From e49ed769ab7db626b6736c34ba7791cc9aef6c18 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:16:23 +0000 Subject: [PATCH 03/21] test(csharp): dispose link decorators before deleting their databases Reproduces and fixes the 114 Windows test failures (226 IOExceptions at System.IO.FileSystem.DeleteFile) that csharp.yml was hiding behind continue-on-error. Every affected helper now scopes its decorator with `using` so the memory-mapped handles are released before the finally block deletes the backing files: AdvancedMixedQueryProcessor, NamedTypesDecoratorTests, NamedLinksDecoratorTests, LinoDatabaseOutputTests, SimpleLinksDecoratorTests, LinoDatabaseInputTests, PersistentTransformationDecoratorTests and Issue62ReviewCoverageTests -- the exact eight classes in the failure list. DecoratorDisposalTests pins the contract: each decorator is IDisposable, its databases can be deleted after Dispose, and Dispose is idempotent. Two further fixes in the same area: - MakeNamesDatabaseFilename_CorrectlyGeneratesFilename hard-coded '/' in its expectations while the implementation builds paths with Path.Combine, so it failed on Windows only. It now compares the file name and the directory separately, and a new theory asserts all three decorators agree. - RunTestWithLinks capped every test body at one second of wall-clock time, which made a loaded macOS runner fail SwapSourceAndTargetForAllLinksUsing- VariablesTest with TimeoutException. The guard is now 60 seconds and overridable via LINK_CLI_TEST_TIMEOUT_SECONDS. Measurable on Linux too: a full run used to leave 104 orphaned *.names.links files in /tmp, now it leaves none. --- .../AdvancedMixedQueryProcessor.cs | 47 ++++++-- .../DecoratorDisposalTests.cs | 104 ++++++++++++++++++ .../Issue62ReviewCoverageTests.cs | 10 +- .../LinoDatabaseInputTests.cs | 2 +- .../LinoDatabaseOutputTests.cs | 2 +- .../NamedLinksDecoratorTests.cs | 68 ++++++++---- .../NamedTypesDecoratorTests.cs | 26 ++--- .../PersistentTransformationDecoratorTests.cs | 20 ++-- .../SimpleLinksDecoratorTests.cs | 17 ++- .../UnicodeStringStorageTests.cs | 2 +- 10 files changed, 232 insertions(+), 66 deletions(-) create mode 100644 csharp/Foundation.Data.Doublets.Cli.Tests/DecoratorDisposalTests.cs diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs index b16440b..627f7f6 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Platform.Data; using Platform.Data.Doublets; using Platform.Data.Doublets.Memory.United.Generic; @@ -1575,15 +1576,42 @@ public void EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException } // Helper methods + + /// + /// Wall-clock budget for a single test body. Every test here is expected to finish in + /// milliseconds, so this is a deadlock guard, not a performance assertion: the previous value of + /// one second was tight enough that a loaded CI runner failed tests that were perfectly correct. + /// Override with the LINK_CLI_TEST_TIMEOUT_SECONDS environment variable. + /// + private static TimeSpan TestTimeout + { + get + { + const int defaultTimeoutSeconds = 60; + var configured = Environment.GetEnvironmentVariable("LINK_CLI_TEST_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(configured) + && int.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) + && seconds > 0) + { + return TimeSpan.FromSeconds(seconds); + } + return TimeSpan.FromSeconds(defaultTimeoutSeconds); + } + } + private static void RunTestWithLinks(Action> testAction, bool enableTracing = false) { string tempDbFile = Path.GetTempFileName(); - NamedTypesDecorator? decoratedLinks = null; + var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); + // Disposed at the end of the try block, before the finally deletes the backing files. The + // decorator owns memory-mapped handles for both databases; POSIX tolerates unlinking a file + // that is still mapped, Windows fails the delete with IOException. + using var decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var timeout = TestTimeout; + using var cts = new CancellationTokenSource(timeout); var task = Task.Run(() => { testAction(decoratedLinks); @@ -1595,17 +1623,20 @@ private static void RunTestWithLinks(Action> testActio } catch (OperationCanceledException) { - Console.WriteLine("[Test] Test was cancelled after 1 seconds timeout"); - throw new TimeoutException("Test exceeded 1 seconds timeout"); + Console.WriteLine($"[Test] Test was cancelled after {timeout.TotalSeconds} seconds timeout"); + throw new TimeoutException($"Test exceeded {timeout.TotalSeconds} seconds timeout"); } } finally { - if (decoratedLinks != null && File.Exists(decoratedLinks.NamedLinksDatabaseFileName)) + if (File.Exists(namesDbFile)) + { + File.Delete(namesDbFile); + } + if (File.Exists(tempDbFile)) { - File.Delete(decoratedLinks.NamedLinksDatabaseFileName); + File.Delete(tempDbFile); } - File.Delete(tempDbFile); } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/DecoratorDisposalTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/DecoratorDisposalTests.cs new file mode 100644 index 0000000..c3617cc --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/DecoratorDisposalTests.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using Xunit; +using Foundation.Data.Doublets.Cli; +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli.Tests +{ + /// + /// Regression tests for the Windows CI failures tracked by issue #96. + /// + /// + /// Every decorator opens memory-mapped files for the data database and for the names database. + /// POSIX lets a still-mapped file be unlinked, so a leaked handle is invisible on Linux and macOS; + /// Windows uses mandatory locking and fails the delete with . That is what + /// produced 226 IOExceptions across 114 failing tests on windows-latest, which the CI workflow then + /// hid behind `continue-on-error`. These tests pin the contract that makes the delete safe + /// everywhere: the decorators are disposable, and disposing them releases both databases. + /// + public class DecoratorDisposalTests + { + [Fact] + public void NamedLinksDecorator_IsDisposable() => Assert.True(typeof(IDisposable).IsAssignableFrom(typeof(NamedLinksDecorator))); + + [Fact] + public void NamedTypesDecorator_IsDisposable() => Assert.True(typeof(IDisposable).IsAssignableFrom(typeof(NamedTypesDecorator))); + + [Fact] + public void SimpleLinksDecorator_IsDisposable() => Assert.True(typeof(IDisposable).IsAssignableFrom(typeof(SimpleLinksDecorator))); + + [Fact] + public void NamedLinksDecorator_DatabasesCanBeDeletedAfterDispose() + => AssertDatabasesCanBeDeletedAfterDispose( + NamedLinksDecorator.MakeNamesDatabaseFilename, + databaseFilename => new NamedLinksDecorator(databaseFilename)); + + [Fact] + public void NamedTypesDecorator_DatabasesCanBeDeletedAfterDispose() + => AssertDatabasesCanBeDeletedAfterDispose( + NamedTypesDecorator.MakeNamesDatabaseFilename, + databaseFilename => new NamedTypesDecorator(databaseFilename)); + + [Fact] + public void SimpleLinksDecorator_DatabasesCanBeDeletedAfterDispose() + => AssertDatabasesCanBeDeletedAfterDispose( + SimpleLinksDecorator.MakeNamesDatabaseFilename, + databaseFilename => new SimpleLinksDecorator(databaseFilename)); + + [Fact] + public void Dispose_IsIdempotent() + { + var databaseFilename = Path.GetTempFileName(); + var namesDatabaseFilename = NamedTypesDecorator.MakeNamesDatabaseFilename(databaseFilename); + try + { + var decorator = new NamedTypesDecorator(databaseFilename); + decorator.GetOrCreate(1u, 1u); + + decorator.Dispose(); + decorator.Dispose(); + } + finally + { + Delete(databaseFilename); + Delete(namesDatabaseFilename); + } + } + + private static void AssertDatabasesCanBeDeletedAfterDispose( + Func makeNamesDatabaseFilename, + Func> createDecorator) + { + var databaseFilename = Path.GetTempFileName(); + var namesDatabaseFilename = makeNamesDatabaseFilename(databaseFilename); + try + { + var decorator = createDecorator(databaseFilename); + // Force both databases to be materialised before the handles are released. + decorator.GetOrCreate(1u, 1u); + ((IDisposable)decorator).Dispose(); + + // Before the fix this threw IOException on Windows because the handles were still open. + File.Delete(databaseFilename); + File.Delete(namesDatabaseFilename); + + Assert.False(File.Exists(databaseFilename)); + Assert.False(File.Exists(namesDatabaseFilename)); + } + finally + { + Delete(databaseFilename); + Delete(namesDatabaseFilename); + } + } + + private static void Delete(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs index e4087fe..df0430d 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs @@ -48,17 +48,19 @@ public void NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping() private static void RunTestWithLinks(Action> testAction) { var tempDbFile = Path.GetTempFileName(); - NamedTypesDecorator? links = null; + var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - links = new NamedTypesDecorator(tempDbFile); + // Disposed at the end of the try block, before the finally deletes the backing files: + // Windows refuses to delete a file that is still memory-mapped. + using var links = new NamedTypesDecorator(tempDbFile); testAction(links); } finally { - if (links != null && File.Exists(links.NamedLinksDatabaseFileName)) + if (File.Exists(namesDbFile)) { - File.Delete(links.NamedLinksDatabaseFileName); + File.Delete(namesDbFile); } if (File.Exists(tempDbFile)) { diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs index ec4132c..8da96b9 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs @@ -98,7 +98,7 @@ private static void WithNamedLinks(Action> test) try { - var links = new NamedTypesDecorator(dbPath, false); + using var links = new NamedTypesDecorator(dbPath, false); test(links); } finally diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs index 9e06bec..c7b39e9 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs @@ -152,7 +152,7 @@ private static void WithNamedLinks(Action> test) try { - var links = new NamedTypesDecorator(dbPath, false); + using var links = new NamedTypesDecorator(dbPath, false); test(links); } finally diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs index 5390cda..04e3bd0 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs @@ -16,32 +16,57 @@ public void CanConstructNamedLinksDecorator() // Arrange var tempDbFile = Path.GetTempFileName(); - // Act - var decorator = new NamedLinksDecorator(tempDbFile, true); var namesDatabaseFilename = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); - - // Assert - Assert.NotNull(decorator); - - // Clean up - if (File.Exists(tempDbFile)) + try { - File.Delete(tempDbFile); + // Act + using var decorator = new NamedLinksDecorator(tempDbFile, true); + + // Assert + Assert.NotNull(decorator); } - if (File.Exists(namesDatabaseFilename)) + finally { - File.Delete(namesDatabaseFilename); + // Clean up: the decorator is disposed by the `using` above, so the memory-mapped + // files are unlocked and can be deleted on Windows as well. + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + if (File.Exists(namesDatabaseFilename)) + { + File.Delete(namesDatabaseFilename); + } } } + // Asserted as "directory of the input" + "expected file name" rather than as one hard-coded + // string: the implementation builds the result with Path.Combine, which emits '\\' on Windows, + // so an expectation such as "/tmp/test.names.links" passes on Linux and macOS but fails on + // Windows for a purely cosmetic reason. [Theory] - [InlineData("/tmp/test.db", "/tmp/test.names.links")] + [InlineData("/tmp/test.db", "test.names.links")] [InlineData("test.db", "test.names.links")] [InlineData("a.b.c", "a.b.names.links")] - public void MakeNamesDatabaseFilename_CorrectlyGeneratesFilename(string dbFilename, string expected) + public void MakeNamesDatabaseFilename_CorrectlyGeneratesFilename(string dbFilename, string expectedFileName) { var result = NamedLinksDecorator.MakeNamesDatabaseFilename(dbFilename); - Assert.Equal(expected, result); + + Assert.Equal(expectedFileName, Path.GetFileName(result)); + Assert.Equal(Path.GetDirectoryName(dbFilename), Path.GetDirectoryName(result)); + } + + // All three decorators duplicate MakeNamesDatabaseFilename; they must agree on every platform. + [Theory] + [InlineData("/tmp/test.db")] + [InlineData("test.db")] + [InlineData("a.b.c")] + public void MakeNamesDatabaseFilename_IsConsistentAcrossDecorators(string dbFilename) + { + var expected = NamedLinksDecorator.MakeNamesDatabaseFilename(dbFilename); + + Assert.Equal(expected, NamedTypesDecorator.MakeNamesDatabaseFilename(dbFilename)); + Assert.Equal(expected, SimpleLinksDecorator.MakeNamesDatabaseFilename(dbFilename)); } [Fact] @@ -51,7 +76,7 @@ public void SetNameAndGetName_ShouldReturnSameName() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(10u, 20u); string name = "testName"; decorator.SetName(link, name); @@ -72,7 +97,7 @@ public void SetName_OverwriteOldName() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(1u, 2u); string firstName = "first"; string secondName = "second"; @@ -95,7 +120,7 @@ public void RemoveName_ShouldReturnNullAfterRemoval() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(5u, 6u); string name = "name"; decorator.SetName(link, name); @@ -117,7 +142,7 @@ public void RemoveName_NonExistent_DoesNotThrow() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(7u, 8u); decorator.RemoveName(link); Assert.Null(decorator.GetName(link)); @@ -136,7 +161,7 @@ public void AfterCreation_SetNameAndGetName_ShouldWork() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(10u, 20u); string name = "myLinkName"; decorator.SetName(link, name); @@ -153,9 +178,10 @@ public void AfterCreation_SetNameAndGetName_ShouldWork() public void DeleteLink_RemovesNameAutomatically() { var tempDbFile = Path.GetTempFileName(); - var decorator = new NamedLinksDecorator(tempDbFile, false); + var namesDatabaseFilename = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { + using var decorator = new NamedLinksDecorator(tempDbFile, false); var source = 1u; var target = 1u; var link = decorator.GetOrCreate(source, target); @@ -169,7 +195,7 @@ public void DeleteLink_RemovesNameAutomatically() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); - if (File.Exists(decorator.NamedLinksDatabaseFileName)) File.Delete(decorator.NamedLinksDatabaseFileName); + if (File.Exists(namesDatabaseFilename)) File.Delete(namesDatabaseFilename); } } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs index a4ca5a2..45c9ef5 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs @@ -49,7 +49,7 @@ public void NamedTypesDecorator_ImplementsILinks() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); Assert.True(decorator is ILinks); }); @@ -60,7 +60,7 @@ public void NamedTypesDecorator_ImplementsINamedTypes() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); Assert.True(decorator is INamedTypes); }); @@ -71,7 +71,7 @@ public void NamedTypesDecorator_ImplementsIPinnedTypes() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); Assert.True(decorator is IPinnedTypes); }); @@ -83,7 +83,7 @@ public void NamedTypesDecorator_UsesProvidedPinnedTypesDecorator() RunTestWithLinks(links => { var pinnedTypesDecorator = new PinnedTypesDecorator(links); - var decorator = new NamedTypesDecorator(pinnedTypesDecorator, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(pinnedTypesDecorator, _tempNamesDbPath); Assert.Same(pinnedTypesDecorator, decorator.PinnedTypesDecorator); Assert.True(decorator is ILinks); @@ -97,7 +97,7 @@ public void NamedTypesDecorator_CanEnumeratePinnedTypes() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var types = decorator.Take(3).ToArray(); var (type1, type2, type3) = decorator; @@ -114,7 +114,7 @@ public void NamedTypesDecorator_CanSetAndGetNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link1 = decorator.GetOrCreate(10u, 20u); var link2 = decorator.GetOrCreate(30u, 40u); @@ -138,7 +138,7 @@ public void NamedTypesDecorator_CanGetLinkByName() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(50u, 60u); decorator.SetName(link, "UniqueTestName"); @@ -154,7 +154,7 @@ public void NamedTypesDecorator_CanRemoveNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(70u, 80u); decorator.SetName(link, "TemporaryName"); @@ -177,7 +177,7 @@ public void NamedTypesDecorator_CanOverwriteNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(90u, 100u); decorator.SetName(link, "FirstName"); @@ -203,7 +203,7 @@ public void NamedTypesDecorator_ReassigningExistingNameMovesNameToNewLink() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var firstLink = decorator.GetOrCreate(10u, 11u); var secondLink = decorator.GetOrCreate(20u, 21u); @@ -222,7 +222,7 @@ public void NamedTypesDecorator_DeleteRemovesAssociatedNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(110u, 120u); decorator.SetName(link, "LinkToDelete"); @@ -245,7 +245,7 @@ public void NamedTypesDecorator_CanConstructFromDatabaseFilename() try { - var decorator = new NamedTypesDecorator(tempDbFile); + using var decorator = new NamedTypesDecorator(tempDbFile); var link = decorator.GetOrCreate(1u, 2u); decorator.SetName(link, "FromFile"); @@ -265,7 +265,7 @@ public void NamedTypesDecorator_HandlesNonexistentNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var linkByNonexistentName = decorator.GetByName("NonexistentName"); Assert.Equal(links.Constants.Null, linkByNonexistentName); diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs index 10319dc..3f88edb 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs @@ -72,12 +72,14 @@ private static void RunWithPersistentLinks(Action? dataLinks = null; - NamedTypesDecorator? triggerLinks = null; + var dataNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile); + var triggerNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(triggerFile); try { - dataLinks = new NamedTypesDecorator(dataFile); - triggerLinks = new NamedTypesDecorator(triggerFile); + // Both decorators are disposed at the end of the try block, before the finally deletes the + // backing files: Windows refuses to delete a file that is still memory-mapped. + using var dataLinks = new NamedTypesDecorator(dataFile); + using var triggerLinks = new NamedTypesDecorator(triggerFile); var links = new PersistentTransformationDecorator(dataLinks, triggerLinks) { AutoCreateMissingReferences = true @@ -89,14 +91,8 @@ private static void RunWithPersistentLinks(Action.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new SimpleLinksDecorator(tempDbFile); + using var decorator = new SimpleLinksDecorator(tempDbFile); Assert.NotNull(decorator); } finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } @@ -28,9 +30,10 @@ public void CanConstructSimpleLinksDecorator() public void Delete_WithRestriction_DoesNotThrow() { var tempDbFile = Path.GetTempFileName(); + var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new SimpleLinksDecorator(tempDbFile); + using var decorator = new SimpleLinksDecorator(tempDbFile); var source = 1u; var target = 2u; // create a link so there is something to delete @@ -46,6 +49,7 @@ public void Delete_WithRestriction_DoesNotThrow() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } @@ -53,9 +57,10 @@ public void Delete_WithRestriction_DoesNotThrow() public void DeleteAfterGetOrCreate_DoesNotThrow() { var tempDbFile = Path.GetTempFileName(); + var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new SimpleLinksDecorator(tempDbFile); + using var decorator = new SimpleLinksDecorator(tempDbFile); var source = 1u; var target = 1u; var link = decorator.GetOrCreate(source, target); @@ -65,6 +70,7 @@ public void DeleteAfterGetOrCreate_DoesNotThrow() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } @@ -72,8 +78,9 @@ public void DeleteAfterGetOrCreate_DoesNotThrow() public void DeleteAfterGetOrCreate_DoesNotThrow_WithTracing() { var tempDbFile = Path.GetTempFileName(); - var decorator = new SimpleLinksDecorator(tempDbFile, true); + var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { + using var decorator = new SimpleLinksDecorator(tempDbFile, true); var source = 1u; var target = 1u; var link = decorator.GetOrCreate(source, target); @@ -83,7 +90,7 @@ public void DeleteAfterGetOrCreate_DoesNotThrow_WithTracing() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); - if (File.Exists(decorator.NamedLinksDatabaseFileName)) File.Delete(decorator.NamedLinksDatabaseFileName); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs index 254f499..b2793ff 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs @@ -245,7 +245,7 @@ private static void RunTestWithLinks(Action> testAction) try { var constants = new LinksConstants(enableExternalReferencesSupport: true); - var memory = new FileMappedResizableDirectMemory(tempDbFile, UnitedMemoryLinks.DefaultLinksSizeStep); + using var memory = new FileMappedResizableDirectMemory(tempDbFile, UnitedMemoryLinks.DefaultLinksSizeStep); using var links = new UnitedMemoryLinks(memory, UnitedMemoryLinks.DefaultLinksSizeStep, constants, Platform.Data.Doublets.Memory.IndexTreeType.Default); var decoratedLinks = links.DecorateWithAutomaticUniquenessAndUsagesResolution(); testAction(decoratedLinks); From a0a2aa63d2b9c079a21f8d20b882156406bcdfd2 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:26:43 +0000 Subject: [PATCH 04/21] fix(issue-96): fail the build on warnings and enforce disposal analyzers Adds csharp/Directory.Build.props with TreatWarningsAsErrors and .NET analyzers enabled, plus csharp/.editorconfig promoting the four disposal-safety rules (CA1001, CA1063, CA1816, CA2000) to errors. Fixes everything they surfaced: - seals the three link decorators (CA1063/CA1816), - documents the deliberate ownership transfers in MakeLinks and in the constructors with justified CA2000 suppressions, - makes TransactionsDecorator and VersionControlDecorator disposable (CA1001); Dispose delegates to the existing Shutdown and rolls back a still-open transaction respectively, - disposes the decorators created in the transactions/version-control tests instead of leaking their background workers. Without this, warnings accumulated silently and CI still reported success - the false-negative class of issue #96. --- csharp/.editorconfig | 20 +++++++++++++++++++ csharp/Directory.Build.props | 18 +++++++++++++++++ .../NamedLinksDecorator.cs | 8 +++++++- .../NamedTypesDecorator.cs | 8 +++++++- .../SimpleLinksDecorator.cs | 8 +++++++- .../TransactionsDecorator.cs | 8 +++++++- .../VersionControlDecorator.cs | 18 ++++++++++++++++- .../NamedTypesDecoratorTests.cs | 3 ++- .../TransactionsDecoratorTests.cs | 2 +- .../VersionControlDecoratorTests.cs | 14 ++++++------- 10 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 csharp/.editorconfig create mode 100644 csharp/Directory.Build.props diff --git a/csharp/.editorconfig b/csharp/.editorconfig new file mode 100644 index 0000000..3a87ac2 --- /dev/null +++ b/csharp/.editorconfig @@ -0,0 +1,20 @@ +# C# analyzer configuration for link-cli. +# +# The repository builds with the default (recommended) .NET analyzer set and +# TreatWarningsAsErrors, so no warning can reach a green CI run again (issue #96). +# +# On top of that, the resource-lifetime rules are promoted to errors. Issue #96 was +# caused precisely by a leaked memory-mapped file handle, which is invisible on POSIX +# and fatal on Windows, so these rules are worth failing the build over. + +root = true + +[*.cs] +# CA1001: types that own disposable fields must be disposable. +dotnet_diagnostic.CA1001.severity = error +# CA1063: implement IDisposable correctly. +dotnet_diagnostic.CA1063.severity = error +# CA1816: Dispose should call GC.SuppressFinalize. +dotnet_diagnostic.CA1816.severity = error +# CA2000: dispose objects before losing scope. +dotnet_diagnostic.CA2000.severity = error diff --git a/csharp/Directory.Build.props b/csharp/Directory.Build.props new file mode 100644 index 0000000..6c2c516 --- /dev/null +++ b/csharp/Directory.Build.props @@ -0,0 +1,18 @@ + + + + + net8 + enable + enable + latest + true + true + true + + + diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs index 0e47e76..c7e7e17 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs @@ -10,9 +10,11 @@ using System.Linq; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + namespace Foundation.Data.Doublets.Cli { - public class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks, IDisposable + public sealed class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -27,6 +29,8 @@ public class NamedLinksDecorator : LinksDecoratorBase _namedLinksFacade; private bool _disposed; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the links facade is transferred to the caller, which releases it through " + nameof(Dispose) + ".")] public static ILinks MakeLinks(string databaseFilename) { var links = new UnitedMemoryLinks(databaseFilename); @@ -41,6 +45,8 @@ public static string MakeNamesDatabaseFilename(string databaseFilename) return namesDatabaseFilename; } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The names database memory and links are owned by this instance and released by " + nameof(Dispose) + ".")] public NamedLinksDecorator(ILinks links, string namesDatabaseFilename, bool tracingEnabled = false) : base(links) { _tracingEnabled = tracingEnabled; diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs index ee97eec..8619b8a 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs @@ -11,9 +11,11 @@ using Platform.Data.Doublets.Memory; using Platform.Data.Doublets.Memory.United.Generic; +using System.Diagnostics.CodeAnalysis; + namespace Foundation.Data.Doublets.Cli { - public class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes, IDisposable + public sealed class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -28,6 +30,8 @@ public class NamedTypesDecorator : LinksDecoratorBase _namedLinksFacade; private bool _disposed; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the links facade is transferred to the caller, which releases it through " + nameof(Dispose) + ".")] public static ILinks MakeLinks(string databaseFilename) { var links = new UnitedMemoryLinks(databaseFilename); @@ -47,6 +51,8 @@ public NamedTypesDecorator(ILinks links, string namesDatabaseFilen { } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The names database memory and links are owned by this instance and released by " + nameof(Dispose) + ".")] public NamedTypesDecorator(PinnedTypesDecorator pinnedTypesDecorator, string namesDatabaseFilename, bool tracingEnabled = false) : base(pinnedTypesDecorator) { _tracingEnabled = tracingEnabled; diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs index e047e91..e7aaed8 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs @@ -8,9 +8,11 @@ using Platform.Memory; using Platform.Data.Doublets.Memory; +using System.Diagnostics.CodeAnalysis; + namespace Foundation.Data.Doublets.Cli { - public class SimpleLinksDecorator : LinksDecoratorBase, IDisposable + public sealed class SimpleLinksDecorator : LinksDecoratorBase, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -25,6 +27,8 @@ public class SimpleLinksDecorator : LinksDecoratorBase _namedLinksFacade; private bool _disposed; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the links facade is transferred to the caller, which releases it through " + nameof(Dispose) + ".")] public static ILinks MakeLinks(string databaseFilename) { var links = new UnitedMemoryLinks(databaseFilename); @@ -39,6 +43,8 @@ public static string MakeNamesDatabaseFilename(string databaseFilename) return namesDatabaseFilename; } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The names database memory and links are owned by this instance and released by " + nameof(Dispose) + ".")] public SimpleLinksDecorator(ILinks links, string namesDatabaseFilename, bool tracingEnabled = false) : base(links) { _tracingEnabled = tracingEnabled; diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs index 8f2b46c..5bf8b90 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -194,7 +194,7 @@ public interface ITransactionsLinks : INamedTypesLinks /// retention policies, and crash recovery. Optional — no behavioural /// change if not opted in (R8). /// -public sealed class TransactionsDecorator : LinksDecoratorBase, ITransactionsLinks +public sealed class TransactionsDecorator : LinksDecoratorBase, ITransactionsLinks, IDisposable { internal const string CommitMarkerPrefix = "__transactions:commit:"; internal const string RollbackMarkerPrefix = "__transactions:rollback:"; @@ -520,6 +520,12 @@ public void Recover() /// Stops the background worker. The wrapped data store and log store /// are not disposed here; callers are expected to own those. /// + public void Dispose() => Shutdown(); + + /// + /// Stops the background worker. Kept as a named method for backwards + /// compatibility; delegates to it. + /// public void Shutdown() { if (_disposed) return; diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs index bf9bba9..be43302 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs @@ -38,7 +38,7 @@ public interface IVersionControlLinks : INamedTypesLinks /// () over the transitions log. Optional — when not /// instantiated the underlying transactions decorator behaves identically. /// -public sealed class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks +public sealed class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks, IDisposable { /// Default name of the initial branch (analogous to git's main). public const string DefaultBranchName = "main"; @@ -64,6 +64,22 @@ public sealed class VersionControlDecorator : LinksDecoratorBase, IVersion private VersionControlTransaction? _activeTransaction; private readonly bool _trace; + /// + /// Rolls back and releases the transaction that is still open, if any. + /// The wrapped transactions decorator and branches store are owned by + /// the caller and are deliberately left untouched. + /// + public void Dispose() + { + VersionControlTransaction? active; + lock (_lock) + { + active = _activeTransaction; + _activeTransaction = null; + } + active?.Dispose(); + } + public VersionControlDecorator( TransactionsDecorator transactions, INamedTypesLinks branchesStore, diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs index 45c9ef5..ec167ef 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs @@ -11,7 +11,7 @@ namespace Foundation.Data.Doublets.Cli.Tests { - public class NamedTypesDecoratorTests : IDisposable + public sealed class NamedTypesDecoratorTests : IDisposable { private readonly string _tempDbPath; private readonly string _tempNamesDbPath; @@ -26,6 +26,7 @@ public void Dispose() { if (File.Exists(_tempDbPath)) File.Delete(_tempDbPath); if (File.Exists(_tempNamesDbPath)) File.Delete(_tempNamesDbPath); + GC.SuppressFinalize(this); } private static void RunTestWithLinks(Action> testAction) diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs index ed05558..ef214e9 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs @@ -254,7 +254,7 @@ private static void RunWithTransactions(Action(logFile); vcLinks = new NamedTypesDecorator(vcFile); tx = new TransactionsDecorator(dataLinks, logLinks); - var vc = new VersionControlDecorator(tx, vcLinks); + using var vc = new VersionControlDecorator(tx, vcLinks); using (var transaction = vc.BeginTransaction()) { @@ -281,7 +281,7 @@ public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() Assert.Equal(b, restored.Source); Assert.Equal(b, restored.Target); - tx.Shutdown(); + tx.Dispose(); tx = null; dataLinks.Dispose(); dataLinks = null; @@ -294,7 +294,7 @@ public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() reopenedLogLinks = new NamedTypesDecorator(logFile); reopenedVcLinks = new NamedTypesDecorator(vcFile); reopenedTx = new TransactionsDecorator(reopenedDataLinks, reopenedLogLinks); - var reopened = new VersionControlDecorator(reopenedTx, reopenedVcLinks); + using var reopened = new VersionControlDecorator(reopenedTx, reopenedVcLinks); Assert.True(reopened.TryGetTag("acid-commit", out var tagSequence)); Assert.Equal(committedSequence, tagSequence); @@ -309,8 +309,8 @@ public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() } finally { - tx?.Shutdown(); - reopenedTx?.Shutdown(); + tx?.Dispose(); + reopenedTx?.Dispose(); dataLinks?.Dispose(); logLinks?.Dispose(); vcLinks?.Dispose(); @@ -341,12 +341,12 @@ private static void RunWithVc(Action(logFile); vcLinks = new NamedTypesDecorator(vcFile); tx = new TransactionsDecorator(dataLinks, logLinks); - var vc = new VersionControlDecorator(tx, vcLinks); + using var vc = new VersionControlDecorator(tx, vcLinks); action(vc, tx, dataLinks); } finally { - tx?.Shutdown(); + tx?.Dispose(); dataLinks?.Dispose(); logLinks?.Dispose(); vcLinks?.Dispose(); From f8ca5469b5bb57f0e57cced880e5a96dcf6f0953 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:32:52 +0000 Subject: [PATCH 05/21] style(issue-96): normalize C# formatting with dotnet format Mechanical, whitespace-only pass (verified with 'git diff -w', which shows nothing but brace placement) so that 'dotnet format --verify-no-changes' can be enforced in CI as the csharp template does. Build and all 222 tests are unchanged. --- .../AdvancedMixedQueryProcessor.cs | 2950 +++++++------- .../BasicQueryProcessor.cs | 184 +- .../ChangesSimplifier.cs | 342 +- .../LinksExtensions.cs | 96 +- .../MixedQueryProcessor.cs | 588 +-- .../PersistentTransformationDecorator.cs | 708 ++-- .../SimpleLinksDecorator.cs | 8 +- .../TransactionsDecorator.cs | 1508 ++++---- .../VersionControlDecorator.cs | 1114 +++--- .../AdvancedMixedQueryProcessor.cs | 3420 ++++++++--------- .../ChangesSimplifier.cs | 1012 ++--- .../Issue62ReviewCoverageTests.cs | 142 +- .../PersistentTransformationDecoratorTests.cs | 190 +- .../PinnedTypesTests.cs | 10 +- .../SimpleLinksDecoratorTests.cs | 5 +- .../TransactionsDecoratorTests.cs | 448 +-- .../VersionControlDecoratorTests.cs | 696 ++-- .../Foundation.Data.Doublets.Cli/Program.cs | 852 ++-- 18 files changed, 7138 insertions(+), 7135 deletions(-) diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs index 21c5c80..c515815 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs @@ -8,1670 +8,1670 @@ namespace Foundation.Data.Doublets.Cli { - public static class AdvancedMixedQueryProcessor - { - public class Options + public static class AdvancedMixedQueryProcessor { - public string? Query { get; set; } - public WriteHandler? ChangesHandler { get; set; } - - /// - /// Enables extra console tracing of internal steps if true. - /// - public bool Trace { get; set; } = false; + public class Options + { + public string? Query { get; set; } + public WriteHandler? ChangesHandler { get; set; } - /// - /// Creates missing numeric and named references as self-referential point links instead of failing validation. - /// - public bool AutoCreateMissingReferences { get; set; } = false; + /// + /// Enables extra console tracing of internal steps if true. + /// + public bool Trace { get; set; } = false; - public static implicit operator Options(string query) => new Options { Query = query }; - } + /// + /// Creates missing numeric and named references as self-referential point links instead of failing validation. + /// + public bool AutoCreateMissingReferences { get; set; } = false; - public static void ProcessQuery(INamedTypesLinks links, Options options) - { - ArgumentNullException.ThrowIfNull(links); - ArgumentNullException.ThrowIfNull(options); - - var query = options.Query; - TraceIfEnabled(options, $"[ProcessQuery] Query: \"{query}\""); - - if (string.IsNullOrEmpty(query)) - { - TraceIfEnabled(options, "[ProcessQuery] Query is empty, returning."); - return; - } - - var parser = new Parser(); - var parsedLinks = parser.Parse(query); - - TraceIfEnabled(options, $"[ProcessQuery] Parser returned {parsedLinks.Count} top-level link(s)."); - if (parsedLinks.Count == 0) - { - TraceIfEnabled(options, "[ProcessQuery] No top-level parsed links found, returning."); - return; - } - - // We expect something like (( restriction ) ( substitution )) - var outerLink = parsedLinks[0]; - var outerLinkValues = outerLink.Values; - if (outerLinkValues == null || outerLinkValues.Count < 2) - { - TraceIfEnabled(options, "[ProcessQuery] Outer link has fewer than 2 sub-links, returning."); - return; - } - - var restrictionLink = outerLinkValues[0]; - var substitutionLink = outerLinkValues[1]; - - TraceIfEnabled(options, $"[ProcessQuery] Restriction link => Id=\"{restrictionLink.Id}\" Values.Count={restrictionLink.Values?.Count ?? 0}"); - TraceIfEnabled(options, $"[ProcessQuery] Substitution link => Id=\"{substitutionLink.Id}\" Values.Count={substitutionLink.Values?.Count ?? 0}"); - - // If both restriction and substitution are empty, do nothing - if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) - { - TraceIfEnabled(options, "[ProcessQuery] Restriction & substitution both empty => no operation, returning."); - return; - } - - // Creation scenario: no restriction, only substitution - if (restrictionLink.Values?.Count == 0 && (substitutionLink.Values?.Count ?? 0) > 0) - { - TraceIfEnabled(options, "[ProcessQuery] No restriction, but substitution is non-empty => creation scenario."); - - // VALIDATION: Validate that all references in creation scenario are valid - try - { - var emptyRestrictionPatterns = new List(); - ValidateLinksExistOrWillBeCreated(links, emptyRestrictionPatterns, substitutionLink.Values ?? new List(), options); - } - catch (Exception ex) - { - TraceIfEnabled(options, $"[ProcessQuery] Creation validation failed: {ex.Message}"); - throw; + public static implicit operator Options(string query) => new Options { Query = query }; } - foreach (var linkToCreate in substitutionLink.Values ?? new List()) + public static void ProcessQuery(INamedTypesLinks links, Options options) { - var createdId = EnsureNestedLinkCreatedRecursively(links, linkToCreate, options); - TraceIfEnabled(options, $"[ProcessQuery] Created link ID #{createdId} from substitution pattern."); - } - return; - } - - // Build pattern lists from the sub-links - var restrictionPatterns = restrictionLink.Values ?? new List(); - var substitutionPatterns = substitutionLink.Values ?? new List(); - - TraceIfEnabled(options, $"[ProcessQuery] Restriction patterns to parse: {restrictionPatterns.Count}"); - TraceIfEnabled(options, $"[ProcessQuery] Substitution patterns to parse: {substitutionPatterns.Count}"); - - // VALIDATION: Check that all referenced links exist or will be created - try - { - ValidateLinksExistOrWillBeCreated(links, restrictionPatterns, substitutionPatterns, options); - } - catch (Exception ex) - { - TraceIfEnabled(options, $"[ProcessQuery] Validation failed: {ex.Message}"); - throw; - } - - var restrictionInternalPatterns = restrictionPatterns - .Select(l => CreatePatternFromLino(l)) - .ToList(); - - var substitutionInternalPatterns = substitutionPatterns - .Select(l => CreatePatternFromLino(l)) - .ToList(); - - // ---------------------------------------------------------------- - // FIX: If we see restrictionLink with exactly 1 sub-link => that sub-link has 2 sub-values => interpret as a single composite pattern - // This handles patterns like ((() (1 2))) where the outer restriction has a single composite child - if ( - string.IsNullOrEmpty(restrictionLink.Id) && - restrictionLink.Values?.Count == 1 - ) - { - var single = restrictionLink.Values[0]; - // Check if this is a composite (has 2 sub-values) and doesn't have a numeric/wildcard ID - if ( - single.Values?.Count == 2 && - (string.IsNullOrEmpty(single.Id) || !IsNumericOrStar(single.Id)) - ) - { - // Create a single composite pattern from ((1 *) (* 2)) - var topLevelPattern = CreatePatternFromLino(single); - - // If it doesn't have an explicit index or if it's "*", force a variable ID, so we don't unify with #1/#2 - if (string.IsNullOrEmpty(topLevelPattern.Index) || topLevelPattern.Index == "*") - { - topLevelPattern.Index = "$top_" + Guid.NewGuid().ToString("N"); - TraceIfEnabled(options, $"[ProcessQuery] Assigned a variable index => {topLevelPattern.Index}"); - } - - // Clear out the multiple sub-pattern expansions and replace with our single composite pattern - restrictionInternalPatterns.Clear(); - restrictionInternalPatterns.Add(topLevelPattern); - - TraceIfEnabled(options, - "[ProcessQuery] Detected single sub-link with 2 sub-values => replaced with one composite restriction pattern."); - } - } - // ---------------------------------------------------------------- - - // If restrictionLink.Id is not empty => treat it as an extra pattern - if (!string.IsNullOrEmpty(restrictionLink.Id)) - { - TraceIfEnabled(options, "[ProcessQuery] Restriction link has non-empty Id => adding extra pattern for it."); - var extraRestrictionPattern = CreatePatternFromLino(restrictionLink); - restrictionInternalPatterns.Insert(0, extraRestrictionPattern); - } - - // If substitutionLink.Id is not empty => treat it as an extra pattern - if (!string.IsNullOrEmpty(substitutionLink.Id)) - { - TraceIfEnabled(options, "[ProcessQuery] Substitution link has non-empty Id => adding extra pattern for it."); - var extraSubstitutionPattern = CreatePatternFromLino(substitutionLink); - substitutionInternalPatterns.Insert(0, extraSubstitutionPattern); - } - - TraceIfEnabled(options, "[ProcessQuery] Converting restriction patterns => done."); - TraceIfEnabled(options, "[ProcessQuery] Converting substitution patterns => done."); - - TraceIfEnabled(options, "[ProcessQuery] Finding solutions for restriction patterns..."); - var solutions = FindAllSolutions(links, restrictionInternalPatterns); - - TraceIfEnabled(options, $"[ProcessQuery] Found {solutions.Count} total solution(s) matching restriction patterns."); - if (solutions.Count == 0) - { - TraceIfEnabled(options, "[ProcessQuery] No solutions found => returning."); - return; - } - - // Decide if all solutions would lead to a no-op - bool allSolutionsNoOperation = solutions.All(solution => - DetermineIfSolutionIsNoOperation(solution, restrictionInternalPatterns, substitutionInternalPatterns, links)); - - TraceIfEnabled(options, "[ProcessQuery] allSolutionsNoOperation=" + allSolutionsNoOperation); - - var allPlannedOperations = new List<(DoubletLink before, DoubletLink after)>(); - if (allSolutionsNoOperation) - { - TraceIfEnabled(options, "[ProcessQuery] All solutions produce no differences => we'll track them as no-op changes."); - foreach (var solution in solutions) - { - var matchedLinks = ExtractMatchedLinks(links, solution, restrictionInternalPatterns); - TraceIfEnabled(options, $"[ProcessQuery] One solution => matched {matchedLinks.Count} link(s)."); - foreach (var link in matchedLinks) - { - allPlannedOperations.Add((link, link)); - } - } - } - else - { - TraceIfEnabled(options, "[ProcessQuery] Some solutions lead to actual changes => building operations."); - foreach (var solution in solutions) - { - var substitutionLinks = ApplySolutionToPatterns(links, solution, substitutionInternalPatterns, isSubstitution: true); - var restrictionLinks = ApplySolutionToPatterns(links, solution, restrictionInternalPatterns, isSubstitution: false); + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(options); - TraceIfEnabled(options, - "[ProcessQuery] For a solution => " + - $"substitution links count={substitutionLinks.Count}, restriction links count={restrictionLinks.Count}."); + var query = options.Query; + TraceIfEnabled(options, $"[ProcessQuery] Query: \"{query}\""); - var operations = DetermineOperationsFromPatterns(restrictionLinks, substitutionLinks, links); - TraceIfEnabled(options, $"[ProcessQuery] => {operations.Count} operation(s) derived from these patterns."); - allPlannedOperations.AddRange(operations); - } - } + if (string.IsNullOrEmpty(query)) + { + TraceIfEnabled(options, "[ProcessQuery] Query is empty, returning."); + return; + } - TraceIfEnabled(options, "[ProcessQuery] All planned operations => " + allPlannedOperations.Count); + var parser = new Parser(); + var parsedLinks = parser.Parse(query); - if (allSolutionsNoOperation) - { - TraceIfEnabled(options, "[ProcessQuery] Since they're all no-ops, just calling ChangesHandler with (before, before)."); - foreach (var (before, after) in allPlannedOperations) - { - options.ChangesHandler?.Invoke(before, after); - } - } - else - { - var intendedFinalStates = new Dictionary(); - foreach (var (before, after) in allPlannedOperations) - { - if (after.Index != 0) - { - intendedFinalStates[after.Index] = after; - } - else if (before.Index != 0 && after.Index == 0) - { - intendedFinalStates[before.Index] = default(DoubletLink); - } - } + TraceIfEnabled(options, $"[ProcessQuery] Parser returned {parsedLinks.Count} top-level link(s)."); + if (parsedLinks.Count == 0) + { + TraceIfEnabled(options, "[ProcessQuery] No top-level parsed links found, returning."); + return; + } - var unexpectedDeletions = new List(); - var originalHandler = options.ChangesHandler; + // We expect something like (( restriction ) ( substitution )) + var outerLink = parsedLinks[0]; + var outerLinkValues = outerLink.Values; + if (outerLinkValues == null || outerLinkValues.Count < 2) + { + TraceIfEnabled(options, "[ProcessQuery] Outer link has fewer than 2 sub-links, returning."); + return; + } - try - { - options.ChangesHandler = (before, after) => - { - var beforeLink = new DoubletLink(before); - var afterLink = new DoubletLink(after); - if (beforeLink.Index != 0 && afterLink.Index == 0) - { - bool isExpected = allPlannedOperations.Any(op => op.before.Index == beforeLink.Index && op.after.Index == 0); - if (!isExpected) - { - unexpectedDeletions.Add(new DoubletLink(beforeLink)); - TraceIfEnabled(options, $"[ProcessQuery] Detected unexpected deletion of link #{beforeLink.Index} => will restore later."); - } - } - return originalHandler?.Invoke(before, after) ?? links.Constants.Continue; - }; - - TraceIfEnabled(options, "[ProcessQuery] Applying all planned operations..."); - ApplyAllPlannedOperations(links, allPlannedOperations, options); - } - finally - { - options.ChangesHandler = originalHandler; - } + var restrictionLink = outerLinkValues[0]; + var substitutionLink = outerLinkValues[1]; - TraceIfEnabled(options, "[ProcessQuery] Restoring unexpected deletions if any..."); - RestoreUnexpectedLinkDeletions(links, unexpectedDeletions, intendedFinalStates, options); - } + TraceIfEnabled(options, $"[ProcessQuery] Restriction link => Id=\"{restrictionLink.Id}\" Values.Count={restrictionLink.Values?.Count ?? 0}"); + TraceIfEnabled(options, $"[ProcessQuery] Substitution link => Id=\"{substitutionLink.Id}\" Values.Count={substitutionLink.Values?.Count ?? 0}"); - TraceIfEnabled(options, "[ProcessQuery] Finished processing query."); - } + // If both restriction and substitution are empty, do nothing + if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) + { + TraceIfEnabled(options, "[ProcessQuery] Restriction & substitution both empty => no operation, returning."); + return; + } - /// - /// Recursively ensures that a LinoLink (potentially nested) is created. - /// Returns the numeric ID or ANY if leaf/unparseable. - /// - private static uint EnsureNestedLinkCreatedRecursively(INamedTypesLinks links, LinoLink pattern, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - // Handle string-based two-child composites - if (TryGetTwoChildCompositePattern(pattern, out var name, out var left, out var right) && !IsNumericOrStar(name)) - { - return HandleStringComposite(name, left, right, links, options); - } - - if (pattern.Values == null || pattern.Values.Count == 0) - { - return ResolveLeaf(pattern, links, options); - } - - // If 2 Values => interpret as a composite link - if (pattern.Values.Count == 2) - { - var sourceId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[0], options); - var targetId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[1], options); - - // Generic composite creation for numeric or non-matching patterns - return CreateCompositeLink(pattern.Id, sourceId, targetId, links, options); - } - - // If more than 2 => do nothing special => ANY - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] More than 2 sub-values => returning ANY."); - return anyConstant; - } + // Creation scenario: no restriction, only substitution + if (restrictionLink.Values?.Count == 0 && (substitutionLink.Values?.Count ?? 0) > 0) + { + TraceIfEnabled(options, "[ProcessQuery] No restriction, but substitution is non-empty => creation scenario."); + + // VALIDATION: Validate that all references in creation scenario are valid + try + { + var emptyRestrictionPatterns = new List(); + ValidateLinksExistOrWillBeCreated(links, emptyRestrictionPatterns, substitutionLink.Values ?? new List(), options); + } + catch (Exception ex) + { + TraceIfEnabled(options, $"[ProcessQuery] Creation validation failed: {ex.Message}"); + throw; + } + + foreach (var linkToCreate in substitutionLink.Values ?? new List()) + { + var createdId = EnsureNestedLinkCreatedRecursively(links, linkToCreate, options); + TraceIfEnabled(options, $"[ProcessQuery] Created link ID #{createdId} from substitution pattern."); + } + return; + } - private static void RestoreUnexpectedLinkDeletions( - INamedTypesLinks links, - List unexpectedDeletions, - Dictionary finalIntendedStates, - Options options) - { - if (unexpectedDeletions.Count > 0) - { - TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] We have {unexpectedDeletions.Count} unexpected deletion(s)."); - foreach (var deletedLink in unexpectedDeletions) - { - if (finalIntendedStates.TryGetValue(deletedLink.Index, out var intendedLink)) - { - if (intendedLink.Index == 0) + // Build pattern lists from the sub-links + var restrictionPatterns = restrictionLink.Values ?? new List(); + var substitutionPatterns = substitutionLink.Values ?? new List(); + + TraceIfEnabled(options, $"[ProcessQuery] Restriction patterns to parse: {restrictionPatterns.Count}"); + TraceIfEnabled(options, $"[ProcessQuery] Substitution patterns to parse: {substitutionPatterns.Count}"); + + // VALIDATION: Check that all referenced links exist or will be created + try { - TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Link #{deletedLink.Index} was intended-deletion => skip restore."); - continue; + ValidateLinksExistOrWillBeCreated(links, restrictionPatterns, substitutionPatterns, options); } - if (!links.Exists(intendedLink.Index)) + catch (Exception ex) { - TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Recreating link #{deletedLink.Index} => was unexpected deletion."); - CreateOrUpdateLink(links, intendedLink, options); + TraceIfEnabled(options, $"[ProcessQuery] Validation failed: {ex.Message}"); + throw; } - } - } - } - else - { - TraceIfEnabled(options, "[RestoreUnexpectedLinkDeletions] No unexpected deletions found."); - } - } - private static List<(DoubletLink before, DoubletLink after)> DetermineOperationsFromPatterns( - List restrictions, - List substitutions, - INamedTypesLinks links) - { - var anyOrZero = new HashSet { 0, links.Constants.Any }; + var restrictionInternalPatterns = restrictionPatterns + .Select(l => CreatePatternFromLino(l)) + .ToList(); + + var substitutionInternalPatterns = substitutionPatterns + .Select(l => CreatePatternFromLino(l)) + .ToList(); + + // ---------------------------------------------------------------- + // FIX: If we see restrictionLink with exactly 1 sub-link => that sub-link has 2 sub-values => interpret as a single composite pattern + // This handles patterns like ((() (1 2))) where the outer restriction has a single composite child + if ( + string.IsNullOrEmpty(restrictionLink.Id) && + restrictionLink.Values?.Count == 1 + ) + { + var single = restrictionLink.Values[0]; + // Check if this is a composite (has 2 sub-values) and doesn't have a numeric/wildcard ID + if ( + single.Values?.Count == 2 && + (string.IsNullOrEmpty(single.Id) || !IsNumericOrStar(single.Id)) + ) + { + // Create a single composite pattern from ((1 *) (* 2)) + var topLevelPattern = CreatePatternFromLino(single); + + // If it doesn't have an explicit index or if it's "*", force a variable ID, so we don't unify with #1/#2 + if (string.IsNullOrEmpty(topLevelPattern.Index) || topLevelPattern.Index == "*") + { + topLevelPattern.Index = "$top_" + Guid.NewGuid().ToString("N"); + TraceIfEnabled(options, $"[ProcessQuery] Assigned a variable index => {topLevelPattern.Index}"); + } + + // Clear out the multiple sub-pattern expansions and replace with our single composite pattern + restrictionInternalPatterns.Clear(); + restrictionInternalPatterns.Add(topLevelPattern); + + TraceIfEnabled(options, + "[ProcessQuery] Detected single sub-link with 2 sub-values => replaced with one composite restriction pattern."); + } + } + // ---------------------------------------------------------------- - var normalRestrictions = restrictions.Where(r => !anyOrZero.Contains(r.Index)).ToList(); - var wildcardRestrictions = restrictions.Where(r => anyOrZero.Contains(r.Index)).ToList(); + // If restrictionLink.Id is not empty => treat it as an extra pattern + if (!string.IsNullOrEmpty(restrictionLink.Id)) + { + TraceIfEnabled(options, "[ProcessQuery] Restriction link has non-empty Id => adding extra pattern for it."); + var extraRestrictionPattern = CreatePatternFromLino(restrictionLink); + restrictionInternalPatterns.Insert(0, extraRestrictionPattern); + } - var normalSubstitutions = substitutions.Where(s => !anyOrZero.Contains(s.Index)).ToList(); - var wildcardSubstitutions = substitutions.Where(s => anyOrZero.Contains(s.Index)).ToList(); + // If substitutionLink.Id is not empty => treat it as an extra pattern + if (!string.IsNullOrEmpty(substitutionLink.Id)) + { + TraceIfEnabled(options, "[ProcessQuery] Substitution link has non-empty Id => adding extra pattern for it."); + var extraSubstitutionPattern = CreatePatternFromLino(substitutionLink); + substitutionInternalPatterns.Insert(0, extraSubstitutionPattern); + } - var restrictionByIndex = normalRestrictions.ToDictionary(r => r.Index, r => r); - var substitutionByIndex = normalSubstitutions.ToDictionary(s => s.Index, s => s); + TraceIfEnabled(options, "[ProcessQuery] Converting restriction patterns => done."); + TraceIfEnabled(options, "[ProcessQuery] Converting substitution patterns => done."); - var operations = new List<(DoubletLink before, DoubletLink after)>(); - var allIndices = restrictionByIndex.Keys.Union(substitutionByIndex.Keys).ToList(); + TraceIfEnabled(options, "[ProcessQuery] Finding solutions for restriction patterns..."); + var solutions = FindAllSolutions(links, restrictionInternalPatterns); - // Step 1) For each distinct index in normal restrictions & substitutions - foreach (var linkIndex in allIndices) - { - bool hasRestriction = restrictionByIndex.TryGetValue(linkIndex, out var restrictionLink); - bool hasSubstitution = substitutionByIndex.TryGetValue(linkIndex, out var substitutionLink); + TraceIfEnabled(options, $"[ProcessQuery] Found {solutions.Count} total solution(s) matching restriction patterns."); + if (solutions.Count == 0) + { + TraceIfEnabled(options, "[ProcessQuery] No solutions found => returning."); + return; + } - if (hasRestriction && hasSubstitution) - { - if (restrictionLink.Source != substitutionLink.Source || restrictionLink.Target != substitutionLink.Target) - { - operations.Add((restrictionLink, substitutionLink)); - } - else - { - operations.Add((restrictionLink, restrictionLink)); - } - } - else if (hasRestriction && !hasSubstitution) - { - // Deletion - operations.Add((restrictionLink, default(DoubletLink))); - } - else if (!hasRestriction && hasSubstitution) - { - // Creation - operations.Add((default(DoubletLink), substitutionLink)); - } - } + // Decide if all solutions would lead to a no-op + bool allSolutionsNoOperation = solutions.All(solution => + DetermineIfSolutionIsNoOperation(solution, restrictionInternalPatterns, substitutionInternalPatterns, links)); - // Step 2) Wildcard restrictions => each is a separate "delete" - foreach (var restrictionLink in wildcardRestrictions) - { - operations.Add((restrictionLink, default(DoubletLink))); - } + TraceIfEnabled(options, "[ProcessQuery] allSolutionsNoOperation=" + allSolutionsNoOperation); - // Step 3) Wildcard substitutions => each is a separate "create" - foreach (var substitutionLink in wildcardSubstitutions) - { - operations.Add((default(DoubletLink), substitutionLink)); - } + var allPlannedOperations = new List<(DoubletLink before, DoubletLink after)>(); + if (allSolutionsNoOperation) + { + TraceIfEnabled(options, "[ProcessQuery] All solutions produce no differences => we'll track them as no-op changes."); + foreach (var solution in solutions) + { + var matchedLinks = ExtractMatchedLinks(links, solution, restrictionInternalPatterns); + TraceIfEnabled(options, $"[ProcessQuery] One solution => matched {matchedLinks.Count} link(s)."); + foreach (var link in matchedLinks) + { + allPlannedOperations.Add((link, link)); + } + } + } + else + { + TraceIfEnabled(options, "[ProcessQuery] Some solutions lead to actual changes => building operations."); + foreach (var solution in solutions) + { + var substitutionLinks = ApplySolutionToPatterns(links, solution, substitutionInternalPatterns, isSubstitution: true); + var restrictionLinks = ApplySolutionToPatterns(links, solution, restrictionInternalPatterns, isSubstitution: false); + + TraceIfEnabled(options, + "[ProcessQuery] For a solution => " + + $"substitution links count={substitutionLinks.Count}, restriction links count={restrictionLinks.Count}."); + + var operations = DetermineOperationsFromPatterns(restrictionLinks, substitutionLinks, links); + TraceIfEnabled(options, $"[ProcessQuery] => {operations.Count} operation(s) derived from these patterns."); + allPlannedOperations.AddRange(operations); + } + } - return operations; - } + TraceIfEnabled(options, "[ProcessQuery] All planned operations => " + allPlannedOperations.Count); - private static void ApplyAllPlannedOperations( - INamedTypesLinks links, - List<(DoubletLink before, DoubletLink after)> operations, - Options options) - { - foreach (var (before, after) in operations) - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Operation: before=({before.Index}:{before.Source}->{before.Target}), after=({after.Index}:{after.Source}->{after.Target})"); - if (before.Index != 0) - { - var beforeName = links.GetName(before.Index); - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for before.Index {before.Index} = '{beforeName}'"); - } - if (after.Index != 0) - { - var afterNamePre = links.GetName(after.Index); - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePre}' (pre-op)"); - } - if (before.Index != 0 && after.Index == 0) - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Deleting link => ID={before.Index}, S={before.Source}, T={before.Target}"); - RemoveLinks(links, before, options); + if (allSolutionsNoOperation) + { + TraceIfEnabled(options, "[ProcessQuery] Since they're all no-ops, just calling ChangesHandler with (before, before)."); + foreach (var (before, after) in allPlannedOperations) + { + options.ChangesHandler?.Invoke(before, after); + } + } + else + { + var intendedFinalStates = new Dictionary(); + foreach (var (before, after) in allPlannedOperations) + { + if (after.Index != 0) + { + intendedFinalStates[after.Index] = after; + } + else if (before.Index != 0 && after.Index == 0) + { + intendedFinalStates[before.Index] = default(DoubletLink); + } + } + + var unexpectedDeletions = new List(); + var originalHandler = options.ChangesHandler; + + try + { + options.ChangesHandler = (before, after) => + { + var beforeLink = new DoubletLink(before); + var afterLink = new DoubletLink(after); + if (beforeLink.Index != 0 && afterLink.Index == 0) + { + bool isExpected = allPlannedOperations.Any(op => op.before.Index == beforeLink.Index && op.after.Index == 0); + if (!isExpected) + { + unexpectedDeletions.Add(new DoubletLink(beforeLink)); + TraceIfEnabled(options, $"[ProcessQuery] Detected unexpected deletion of link #{beforeLink.Index} => will restore later."); + } + } + return originalHandler?.Invoke(before, after) ?? links.Constants.Continue; + }; + + TraceIfEnabled(options, "[ProcessQuery] Applying all planned operations..."); + ApplyAllPlannedOperations(links, allPlannedOperations, options); + } + finally + { + options.ChangesHandler = originalHandler; + } + + TraceIfEnabled(options, "[ProcessQuery] Restoring unexpected deletions if any..."); + RestoreUnexpectedLinkDeletions(links, unexpectedDeletions, intendedFinalStates, options); + } + + TraceIfEnabled(options, "[ProcessQuery] Finished processing query."); } - else if (before.Index == 0 && (after.Index != 0 || after.Source != 0 || after.Target != 0)) + + /// + /// Recursively ensures that a LinoLink (potentially nested) is created. + /// Returns the numeric ID or ANY if leaf/unparseable. + /// + private static uint EnsureNestedLinkCreatedRecursively(INamedTypesLinks links, LinoLink pattern, Options options) { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Creating link => ID={after.Index}, S={after.Source}, T={after.Target}"); - CreateOrUpdateLink(links, after, options); + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + // Handle string-based two-child composites + if (TryGetTwoChildCompositePattern(pattern, out var name, out var left, out var right) && !IsNumericOrStar(name)) + { + return HandleStringComposite(name, left, right, links, options); + } + + if (pattern.Values == null || pattern.Values.Count == 0) + { + return ResolveLeaf(pattern, links, options); + } + + // If 2 Values => interpret as a composite link + if (pattern.Values.Count == 2) + { + var sourceId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[0], options); + var targetId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[1], options); + + // Generic composite creation for numeric or non-matching patterns + return CreateCompositeLink(pattern.Id, sourceId, targetId, links, options); + } + + // If more than 2 => do nothing special => ANY + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] More than 2 sub-values => returning ANY."); + return anyConstant; } - else if (before.Index != 0 && after.Index != 0) + + private static void RestoreUnexpectedLinkDeletions( + INamedTypesLinks links, + List unexpectedDeletions, + Dictionary finalIntendedStates, + Options options) { - if (before.Source != after.Source || before.Target != after.Target) - { - if (before.Index == after.Index) - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Updating link in-place => ID={before.Index}"); - if (!links.Exists(after.Index)) - { - LinksExtensions.EnsureCreated(links, after.Index); - } - links.Update(before, after, (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); + if (unexpectedDeletions.Count > 0) + { + TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] We have {unexpectedDeletions.Count} unexpected deletion(s)."); + foreach (var deletedLink in unexpectedDeletions) + { + if (finalIntendedStates.TryGetValue(deletedLink.Index, out var intendedLink)) + { + if (intendedLink.Index == 0) + { + TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Link #{deletedLink.Index} was intended-deletion => skip restore."); + continue; + } + if (!links.Exists(intendedLink.Index)) + { + TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Recreating link #{deletedLink.Index} => was unexpected deletion."); + CreateOrUpdateLink(links, intendedLink, options); + } + } + } } else { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Removing old link => ID={before.Index} then creating new => ID={after.Index}."); - RemoveLinks(links, before, options); - CreateOrUpdateLink(links, after, options); + TraceIfEnabled(options, "[RestoreUnexpectedLinkDeletions] No unexpected deletions found."); } - } - else - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] No changes for link => ID={before.Index} => no-op."); - options.ChangesHandler?.Invoke(before, before); - } } - if (after.Index != 0) + + private static List<(DoubletLink before, DoubletLink after)> DetermineOperationsFromPatterns( + List restrictions, + List substitutions, + INamedTypesLinks links) { - var afterNamePost = links.GetName(after.Index); - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePost}' (post-op)"); - } - } - } + var anyOrZero = new HashSet { 0, links.Constants.Any }; - private static List> FindAllSolutions(INamedTypesLinks links, List patterns) - { - var partialSolutions = new List> { new Dictionary() }; + var normalRestrictions = restrictions.Where(r => !anyOrZero.Contains(r.Index)).ToList(); + var wildcardRestrictions = restrictions.Where(r => anyOrZero.Contains(r.Index)).ToList(); - for (int i = 0; i < patterns.Count; i++) - { - var pattern = patterns[i]; - var newSolutions = new List>(); - foreach (var solution in partialSolutions) - { - var matches = MatchPattern(links, pattern, solution).ToList(); - foreach (var match in matches) - { - if (AreSolutionsCompatible(solution, match)) - { - var combinedSolution = new Dictionary(solution); - foreach (var assignment in match) - { - combinedSolution[assignment.Key] = assignment.Value; - } - newSolutions.Add(combinedSolution); - } - } - } - partialSolutions = newSolutions; - if (partialSolutions.Count == 0) break; - } + var normalSubstitutions = substitutions.Where(s => !anyOrZero.Contains(s.Index)).ToList(); + var wildcardSubstitutions = substitutions.Where(s => anyOrZero.Contains(s.Index)).ToList(); - return partialSolutions; - } + var restrictionByIndex = normalRestrictions.ToDictionary(r => r.Index, r => r); + var substitutionByIndex = normalSubstitutions.ToDictionary(s => s.Index, s => s); - private static bool AreSolutionsCompatible( - Dictionary existingSolution, - Dictionary newAssignments) - { - foreach (var assignment in newAssignments) - { - if (existingSolution.TryGetValue(assignment.Key, out var existingValue)) - { - if (existingValue != assignment.Value) - { - return false; - } - } - } - return true; - } + var operations = new List<(DoubletLink before, DoubletLink after)>(); + var allIndices = restrictionByIndex.Keys.Union(substitutionByIndex.Keys).ToList(); - private static IEnumerable> MatchPattern( - INamedTypesLinks links, - Pattern pattern, - Dictionary currentSolution) - { - var anyConstant = links.Constants.Any; - if (pattern.IsLeaf) - { - uint leafIndex = ResolveId(links, pattern.Index, currentSolution); - var candidates = links.All(new DoubletLink(leafIndex, anyConstant, anyConstant)); - foreach (var link in candidates) - { - var candidateLink = new DoubletLink(link); - var assignments = new Dictionary(); - AssignVariableIfNeeded(pattern.Index, candidateLink.Index, assignments); - yield return assignments; + // Step 1) For each distinct index in normal restrictions & substitutions + foreach (var linkIndex in allIndices) + { + bool hasRestriction = restrictionByIndex.TryGetValue(linkIndex, out var restrictionLink); + bool hasSubstitution = substitutionByIndex.TryGetValue(linkIndex, out var substitutionLink); + + if (hasRestriction && hasSubstitution) + { + if (restrictionLink.Source != substitutionLink.Source || restrictionLink.Target != substitutionLink.Target) + { + operations.Add((restrictionLink, substitutionLink)); + } + else + { + operations.Add((restrictionLink, restrictionLink)); + } + } + else if (hasRestriction && !hasSubstitution) + { + // Deletion + operations.Add((restrictionLink, default(DoubletLink))); + } + else if (!hasRestriction && hasSubstitution) + { + // Creation + operations.Add((default(DoubletLink), substitutionLink)); + } + } + + // Step 2) Wildcard restrictions => each is a separate "delete" + foreach (var restrictionLink in wildcardRestrictions) + { + operations.Add((restrictionLink, default(DoubletLink))); + } + + // Step 3) Wildcard substitutions => each is a separate "create" + foreach (var substitutionLink in wildcardSubstitutions) + { + operations.Add((default(DoubletLink), substitutionLink)); + } + + return operations; } - yield break; - } - - bool indexIsVariable = IsVariable(pattern.Index); - bool indexIsAny = pattern.Index == "*"; - uint resolvedIndex = ResolveId(links, pattern.Index, currentSolution); - - // If idxResolved is a known link => skip enumerating everything - if (!indexIsVariable && !indexIsAny && resolvedIndex != anyConstant && resolvedIndex != 0 && links.Exists(resolvedIndex)) - { - var link = new DoubletLink(links.GetLink(resolvedIndex)); - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) + + private static void ApplyAllPlannedOperations( + INamedTypesLinks links, + List<(DoubletLink before, DoubletLink after)> operations, + Options options) { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); - foreach (var targetSolution in targetMatches) - { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, resolvedIndex, combined); - yield return combined; - } + foreach (var (before, after) in operations) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Operation: before=({before.Index}:{before.Source}->{before.Target}), after=({after.Index}:{after.Source}->{after.Target})"); + if (before.Index != 0) + { + var beforeName = links.GetName(before.Index); + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for before.Index {before.Index} = '{beforeName}'"); + } + if (after.Index != 0) + { + var afterNamePre = links.GetName(after.Index); + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePre}' (pre-op)"); + } + if (before.Index != 0 && after.Index == 0) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Deleting link => ID={before.Index}, S={before.Source}, T={before.Target}"); + RemoveLinks(links, before, options); + } + else if (before.Index == 0 && (after.Index != 0 || after.Source != 0 || after.Target != 0)) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Creating link => ID={after.Index}, S={after.Source}, T={after.Target}"); + CreateOrUpdateLink(links, after, options); + } + else if (before.Index != 0 && after.Index != 0) + { + if (before.Source != after.Source || before.Target != after.Target) + { + if (before.Index == after.Index) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Updating link in-place => ID={before.Index}"); + if (!links.Exists(after.Index)) + { + LinksExtensions.EnsureCreated(links, after.Index); + } + links.Update(before, after, (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); + } + else + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Removing old link => ID={before.Index} then creating new => ID={after.Index}."); + RemoveLinks(links, before, options); + CreateOrUpdateLink(links, after, options); + } + } + else + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] No changes for link => ID={before.Index} => no-op."); + options.ChangesHandler?.Invoke(before, before); + } + } + if (after.Index != 0) + { + var afterNamePost = links.GetName(after.Index); + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePost}' (post-op)"); + } + } } - } - else - { - // Otherwise we iterate over all links - var allLinks = links.All(new DoubletLink(anyConstant, anyConstant, anyConstant)); - foreach (var raw in allLinks) + + private static List> FindAllSolutions(INamedTypesLinks links, List patterns) { - var candidateLink = new DoubletLink(raw); - if (!CheckIdMatch(links, pattern.Index, candidateLink.Index, currentSolution)) - continue; + var partialSolutions = new List> { new Dictionary() }; - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, candidateLink.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, candidateLink.Target, sourceSolution); - foreach (var targetSolution in targetMatches) + for (int i = 0; i < patterns.Count; i++) { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, candidateLink.Index, combined); - yield return combined; + var pattern = patterns[i]; + var newSolutions = new List>(); + foreach (var solution in partialSolutions) + { + var matches = MatchPattern(links, pattern, solution).ToList(); + foreach (var match in matches) + { + if (AreSolutionsCompatible(solution, match)) + { + var combinedSolution = new Dictionary(solution); + foreach (var assignment in match) + { + combinedSolution[assignment.Key] = assignment.Value; + } + newSolutions.Add(combinedSolution); + } + } + } + partialSolutions = newSolutions; + if (partialSolutions.Count == 0) break; } - } + + return partialSolutions; } - } - } - private static IEnumerable> RecursiveMatchSubPattern( - INamedTypesLinks links, - Pattern? pattern, - uint linkId, - Dictionary currentSolution) - { - if (pattern == null) - { - yield return currentSolution; - yield break; - } - - if (pattern.IsLeaf) - { - if (CheckIdMatch(links, pattern.Index, linkId, currentSolution)) + private static bool AreSolutionsCompatible( + Dictionary existingSolution, + Dictionary newAssignments) { - var newSolution = new Dictionary(currentSolution); - AssignVariableIfNeeded(pattern.Index, linkId, newSolution); - yield return newSolution; + foreach (var assignment in newAssignments) + { + if (existingSolution.TryGetValue(assignment.Key, out var existingValue)) + { + if (existingValue != assignment.Value) + { + return false; + } + } + } + return true; } - yield break; - } - - if (!links.Exists(linkId)) yield break; - - var link = new DoubletLink(links.GetLink(linkId)); - if (!CheckIdMatch(links, pattern.Index, link.Index, currentSolution)) - { - yield break; - } - - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); - foreach (var targetSolution in targetMatches) + + private static IEnumerable> MatchPattern( + INamedTypesLinks links, + Pattern pattern, + Dictionary currentSolution) { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, link.Index, combined); - yield return combined; - } - } - } + var anyConstant = links.Constants.Any; + if (pattern.IsLeaf) + { + uint leafIndex = ResolveId(links, pattern.Index, currentSolution); + var candidates = links.All(new DoubletLink(leafIndex, anyConstant, anyConstant)); + foreach (var link in candidates) + { + var candidateLink = new DoubletLink(link); + var assignments = new Dictionary(); + AssignVariableIfNeeded(pattern.Index, candidateLink.Index, assignments); + yield return assignments; + } + yield break; + } - private static bool CheckIdMatch( - INamedTypesLinks links, - string patternId, - uint candidateId, - Dictionary currentSolution) - { - if (string.IsNullOrEmpty(patternId)) return true; - if (patternId == "*") return true; + bool indexIsVariable = IsVariable(pattern.Index); + bool indexIsAny = pattern.Index == "*"; + uint resolvedIndex = ResolveId(links, pattern.Index, currentSolution); - if (IsVariable(patternId)) - { - if (currentSolution.TryGetValue(patternId, out var existingVal)) - { - return existingVal == candidateId; + // If idxResolved is a known link => skip enumerating everything + if (!indexIsVariable && !indexIsAny && resolvedIndex != anyConstant && resolvedIndex != 0 && links.Exists(resolvedIndex)) + { + var link = new DoubletLink(links.GetLink(resolvedIndex)); + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, resolvedIndex, combined); + yield return combined; + } + } + } + else + { + // Otherwise we iterate over all links + var allLinks = links.All(new DoubletLink(anyConstant, anyConstant, anyConstant)); + foreach (var raw in allLinks) + { + var candidateLink = new DoubletLink(raw); + if (!CheckIdMatch(links, pattern.Index, candidateLink.Index, currentSolution)) + continue; + + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, candidateLink.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, candidateLink.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, candidateLink.Index, combined); + yield return combined; + } + } + } + } } - return true; - } - - uint parsed = links.Constants.Any; - if (TryParseLinkId(patternId, links, ref parsed)) - { - if (parsed == links.Constants.Any) return true; - return parsed == candidateId; - } - return true; - } - - private static void AssignVariableIfNeeded(string id, uint value, Dictionary assignments) - { - if (IsVariable(id)) - { - assignments[id] = value; - } - } - private static bool IsVariable(string identifier) - { - return !string.IsNullOrEmpty(identifier) && identifier.StartsWith("$"); - } + private static IEnumerable> RecursiveMatchSubPattern( + INamedTypesLinks links, + Pattern? pattern, + uint linkId, + Dictionary currentSolution) + { + if (pattern == null) + { + yield return currentSolution; + yield break; + } - private static uint ResolveId( - INamedTypesLinks links, - string identifier, - Dictionary currentSolution) - { - var anyConstant = links.Constants.Any; - if (string.IsNullOrEmpty(identifier)) return anyConstant; - if (currentSolution.TryGetValue(identifier, out var value)) - { - return value; - } - if (IsVariable(identifier)) - { - return anyConstant; - } - uint parsedValue = anyConstant; - if (TryParseLinkId(identifier, links, ref parsedValue)) - { - return parsedValue; - } - return anyConstant; - } + if (pattern.IsLeaf) + { + if (CheckIdMatch(links, pattern.Index, linkId, currentSolution)) + { + var newSolution = new Dictionary(currentSolution); + AssignVariableIfNeeded(pattern.Index, linkId, newSolution); + yield return newSolution; + } + yield break; + } - private static bool DetermineIfSolutionIsNoOperation( - Dictionary solution, - List restrictions, - List substitutions, - INamedTypesLinks links) - { - var substitutedRestrictions = restrictions - .Select(r => ApplySolutionToPattern(links, solution, r, isSubstitution: false)) - .Where(link => link != null) - .Select(link => new DoubletLink(link!)) - .ToList(); + if (!links.Exists(linkId)) yield break; - var substitutedSubstitutions = ApplySolutionToPatterns(links, solution, substitutions, isSubstitution: true); + var link = new DoubletLink(links.GetLink(linkId)); + if (!CheckIdMatch(links, pattern.Index, link.Index, currentSolution)) + { + yield break; + } - substitutedRestrictions.Sort((a, b) => a.Index.CompareTo(b.Index)); - substitutedSubstitutions.Sort((a, b) => a.Index.CompareTo(b.Index)); + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, link.Index, combined); + yield return combined; + } + } + } - if (substitutedRestrictions.Count != substitutedSubstitutions.Count) return false; - for (int i = 0; i < substitutedRestrictions.Count; i++) - { - if (!substitutedRestrictions[i].Equals(substitutedSubstitutions[i])) + private static bool CheckIdMatch( + INamedTypesLinks links, + string patternId, + uint candidateId, + Dictionary currentSolution) { - return false; + if (string.IsNullOrEmpty(patternId)) return true; + if (patternId == "*") return true; + + if (IsVariable(patternId)) + { + if (currentSolution.TryGetValue(patternId, out var existingVal)) + { + return existingVal == candidateId; + } + return true; + } + + uint parsed = links.Constants.Any; + if (TryParseLinkId(patternId, links, ref parsed)) + { + if (parsed == links.Constants.Any) return true; + return parsed == candidateId; + } + return true; } - } - return true; - } - private static List ExtractMatchedLinks( - INamedTypesLinks links, - Dictionary solution, - List patterns) - { - var matchedLinks = new List(); - foreach (var pattern in patterns) - { - var applied = ApplySolutionToPattern(links, solution, pattern); - if (applied != null) + private static void AssignVariableIfNeeded(string id, uint value, Dictionary assignments) { - var matches = links.All(applied); - foreach (var match in matches) - { - matchedLinks.Add(new DoubletLink(match)); - } + if (IsVariable(id)) + { + assignments[id] = value; + } } - } - return matchedLinks.Distinct().ToList(); - } - - private static DoubletLink? ApplySolutionToPattern( - INamedTypesLinks links, - Dictionary solution, - Pattern? pattern, - bool isSubstitution = false, - HashSet? visitedIndexes = null) - { - if (pattern == null) return null; - visitedIndexes ??= new HashSet(); - // Retrieve the ANY constant once for both leaf and composite cases - var anyConstant = links.Constants.Any; + private static bool IsVariable(string identifier) + { + return !string.IsNullOrEmpty(identifier) && identifier.StartsWith("$"); + } - if (pattern.IsLeaf) - { - uint resolvedIndex = ResolveId(links, pattern.Index, solution); - return new DoubletLink(resolvedIndex, anyConstant, anyConstant); - } - else - { - uint resolvedIndex = ResolvePatternIndex(links, pattern.Index, solution, isSubstitution); - var sourceLink = ApplySolutionToPattern(links, solution, pattern.Source, isSubstitution, visitedIndexes); - var targetLink = ApplySolutionToPattern(links, solution, pattern.Target, isSubstitution, visitedIndexes); + private static uint ResolveId( + INamedTypesLinks links, + string identifier, + Dictionary currentSolution) + { + var anyConstant = links.Constants.Any; + if (string.IsNullOrEmpty(identifier)) return anyConstant; + if (currentSolution.TryGetValue(identifier, out var value)) + { + return value; + } + if (IsVariable(identifier)) + { + return anyConstant; + } + uint parsedValue = anyConstant; + if (TryParseLinkId(identifier, links, ref parsedValue)) + { + return parsedValue; + } + return anyConstant; + } - uint resolvedSource = sourceLink?.Index ?? anyConstant; - uint resolvedTarget = targetLink?.Index ?? anyConstant; + private static bool DetermineIfSolutionIsNoOperation( + Dictionary solution, + List restrictions, + List substitutions, + INamedTypesLinks links) + { + var substitutedRestrictions = restrictions + .Select(r => ApplySolutionToPattern(links, solution, r, isSubstitution: false)) + .Where(link => link != null) + .Select(link => new DoubletLink(link!)) + .ToList(); - PreserveExistingSubstitutionParts(links, solution, pattern, resolvedIndex, ref resolvedSource, ref resolvedTarget, isSubstitution, visitedIndexes); + var substitutedSubstitutions = ApplySolutionToPatterns(links, solution, substitutions, isSubstitution: true); - if (resolvedSource == 0) resolvedSource = anyConstant; - if (resolvedTarget == 0) resolvedTarget = anyConstant; + substitutedRestrictions.Sort((a, b) => a.Index.CompareTo(b.Index)); + substitutedSubstitutions.Sort((a, b) => a.Index.CompareTo(b.Index)); - return new DoubletLink(resolvedIndex, resolvedSource, resolvedTarget); - } - } + if (substitutedRestrictions.Count != substitutedSubstitutions.Count) return false; + for (int i = 0; i < substitutedRestrictions.Count; i++) + { + if (!substitutedRestrictions[i].Equals(substitutedSubstitutions[i])) + { + return false; + } + } + return true; + } - private static uint ResolvePatternIndex( - INamedTypesLinks links, - string identifier, - Dictionary solution, - bool isSubstitution) - { - if (isSubstitution && string.IsNullOrEmpty(identifier)) - { - return links.Constants.Null; - } + private static List ExtractMatchedLinks( + INamedTypesLinks links, + Dictionary solution, + List patterns) + { + var matchedLinks = new List(); + foreach (var pattern in patterns) + { + var applied = ApplySolutionToPattern(links, solution, pattern); + if (applied != null) + { + var matches = links.All(applied); + foreach (var match in matches) + { + matchedLinks.Add(new DoubletLink(match)); + } + } + } + return matchedLinks.Distinct().ToList(); + } - if (isSubstitution && IsVariable(identifier) && !solution.ContainsKey(identifier)) - { - return links.Constants.Null; - } + private static DoubletLink? ApplySolutionToPattern( + INamedTypesLinks links, + Dictionary solution, + Pattern? pattern, + bool isSubstitution = false, + HashSet? visitedIndexes = null) + { + if (pattern == null) return null; + visitedIndexes ??= new HashSet(); - return ResolveId(links, identifier, solution); - } + // Retrieve the ANY constant once for both leaf and composite cases + var anyConstant = links.Constants.Any; - private static List ApplySolutionToPatterns( - INamedTypesLinks links, - Dictionary solution, - List patterns, - bool isSubstitution) - { - var workingSolution = isSubstitution ? new Dictionary(solution) : solution; - return patterns - .Select(pattern => ApplySolutionToPattern(links, workingSolution, pattern, isSubstitution)) - .Where(link => link != null) - .Select(link => new DoubletLink(link!)) - .ToList(); - } + if (pattern.IsLeaf) + { + uint resolvedIndex = ResolveId(links, pattern.Index, solution); + return new DoubletLink(resolvedIndex, anyConstant, anyConstant); + } + else + { + uint resolvedIndex = ResolvePatternIndex(links, pattern.Index, solution, isSubstitution); + var sourceLink = ApplySolutionToPattern(links, solution, pattern.Source, isSubstitution, visitedIndexes); + var targetLink = ApplySolutionToPattern(links, solution, pattern.Target, isSubstitution, visitedIndexes); - private static void PreserveExistingSubstitutionParts( - INamedTypesLinks links, - Dictionary solution, - Pattern pattern, - uint resolvedIndex, - ref uint resolvedSource, - ref uint resolvedTarget, - bool isSubstitution, - HashSet visitedIndexes) - { - if (!isSubstitution || resolvedIndex == links.Constants.Null || resolvedIndex == links.Constants.Any || !links.Exists(resolvedIndex)) - { - return; - } + uint resolvedSource = sourceLink?.Index ?? anyConstant; + uint resolvedTarget = targetLink?.Index ?? anyConstant; - if (!visitedIndexes.Add(resolvedIndex)) - { - return; - } + PreserveExistingSubstitutionParts(links, solution, pattern, resolvedIndex, ref resolvedSource, ref resolvedTarget, isSubstitution, visitedIndexes); - try - { - var existingLink = new DoubletLink(links.GetLink(resolvedIndex)); + if (resolvedSource == 0) resolvedSource = anyConstant; + if (resolvedTarget == 0) resolvedTarget = anyConstant; - if (ShouldPreserveExistingPart(pattern.Source, solution) && CanPreserveExistingPart(existingLink, existingLink.Source, visitedIndexes)) - { - resolvedSource = existingLink.Source; - AssignVariableIfNeeded(pattern.Source!.Index, resolvedSource, solution); - } - else if (TryResolveVariablePart(pattern.Source, solution, out var boundSource)) - { - resolvedSource = boundSource; + return new DoubletLink(resolvedIndex, resolvedSource, resolvedTarget); + } } - if (ShouldPreserveExistingPart(pattern.Target, solution) && CanPreserveExistingPart(existingLink, existingLink.Target, visitedIndexes)) + private static uint ResolvePatternIndex( + INamedTypesLinks links, + string identifier, + Dictionary solution, + bool isSubstitution) { - resolvedTarget = existingLink.Target; - AssignVariableIfNeeded(pattern.Target!.Index, resolvedTarget, solution); + if (isSubstitution && string.IsNullOrEmpty(identifier)) + { + return links.Constants.Null; + } + + if (isSubstitution && IsVariable(identifier) && !solution.ContainsKey(identifier)) + { + return links.Constants.Null; + } + + return ResolveId(links, identifier, solution); } - else if (TryResolveVariablePart(pattern.Target, solution, out var boundTarget)) + + private static List ApplySolutionToPatterns( + INamedTypesLinks links, + Dictionary solution, + List patterns, + bool isSubstitution) { - resolvedTarget = boundTarget; + var workingSolution = isSubstitution ? new Dictionary(solution) : solution; + return patterns + .Select(pattern => ApplySolutionToPattern(links, workingSolution, pattern, isSubstitution)) + .Where(link => link != null) + .Select(link => new DoubletLink(link!)) + .ToList(); } - } - finally - { - visitedIndexes.Remove(resolvedIndex); - } - } - - private static bool ShouldPreserveExistingPart(Pattern? partPattern, Dictionary solution) - { - return partPattern?.IsLeaf == true - && IsVariable(partPattern.Index) - && !solution.ContainsKey(partPattern.Index); - } - private static bool TryResolveVariablePart(Pattern? partPattern, Dictionary solution, out uint value) - { - value = default; - return partPattern?.IsLeaf == true - && IsVariable(partPattern.Index) - && solution.TryGetValue(partPattern.Index, out value); - } + private static void PreserveExistingSubstitutionParts( + INamedTypesLinks links, + Dictionary solution, + Pattern pattern, + uint resolvedIndex, + ref uint resolvedSource, + ref uint resolvedTarget, + bool isSubstitution, + HashSet visitedIndexes) + { + if (!isSubstitution || resolvedIndex == links.Constants.Null || resolvedIndex == links.Constants.Any || !links.Exists(resolvedIndex)) + { + return; + } - private static bool CanPreserveExistingPart(DoubletLink existingLink, uint part, HashSet visitedIndexes) - { - return existingLink.IsFullPoint() - || existingLink.IsPartialPoint() - || !visitedIndexes.Contains(part); - } + if (!visitedIndexes.Add(resolvedIndex)) + { + return; + } - private static void CreateOrUpdateLink(INamedTypesLinks links, DoubletLink linkDefinition, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - // Wildcard substitution rename: delegate to nested creation with proper naming - if (linkDefinition.Index == anyConstant) - { - TraceIfEnabled(options, "[CreateOrUpdateLink] Detected wildcard substitution => nested create & name."); - var parsed = new Parser().Parse(options.Query ?? string.Empty); - if (parsed.Count > 0) - { - var outer = parsed[0]; - if (outer.Values != null && outer.Values.Count > 1) - { - var substitutionLinoLink = outer.Values[1]; - if (substitutionLinoLink.Values != null) - { - foreach (var composite in substitutionLinoLink.Values) - { - EnsureNestedLinkCreatedRecursively(links, composite, options); - } - } - } + try + { + var existingLink = new DoubletLink(links.GetLink(resolvedIndex)); + + if (ShouldPreserveExistingPart(pattern.Source, solution) && CanPreserveExistingPart(existingLink, existingLink.Source, visitedIndexes)) + { + resolvedSource = existingLink.Source; + AssignVariableIfNeeded(pattern.Source!.Index, resolvedSource, solution); + } + else if (TryResolveVariablePart(pattern.Source, solution, out var boundSource)) + { + resolvedSource = boundSource; + } + + if (ShouldPreserveExistingPart(pattern.Target, solution) && CanPreserveExistingPart(existingLink, existingLink.Target, visitedIndexes)) + { + resolvedTarget = existingLink.Target; + AssignVariableIfNeeded(pattern.Target!.Index, resolvedTarget, solution); + } + else if (TryResolveVariablePart(pattern.Target, solution, out var boundTarget)) + { + resolvedTarget = boundTarget; + } + } + finally + { + visitedIndexes.Remove(resolvedIndex); + } } - return; - } - if (linkDefinition.Index != nullConstant) - { - // update existing link - if (!links.Exists(linkDefinition.Index)) + private static bool ShouldPreserveExistingPart(Pattern? partPattern, Dictionary solution) { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} doesn't exist => ensuring creation."); - LinksExtensions.EnsureCreated(links, linkDefinition.Index); + return partPattern?.IsLeaf == true + && IsVariable(partPattern.Index) + && !solution.ContainsKey(partPattern.Index); } - var existingLinkRecord = links.GetLink(linkDefinition.Index); - var existingDoublet = new DoubletLink(existingLinkRecord); - if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) + private static bool TryResolveVariablePart(Pattern? partPattern, Dictionary solution, out uint value) { - TraceIfEnabled(options, - $"[CreateOrUpdateLink] Updating link #{linkDefinition.Index}: {existingDoublet.Source}->{linkDefinition.Source}, {existingDoublet.Target}->{linkDefinition.Target}."); - LinksExtensions.EnsureCreated(links, linkDefinition.Index); - links.Update( - new DoubletLink(linkDefinition.Index, anyConstant, anyConstant), - linkDefinition, - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); + value = default; + return partPattern?.IsLeaf == true + && IsVariable(partPattern.Index) + && solution.TryGetValue(partPattern.Index, out value); } - else + + private static bool CanPreserveExistingPart(DoubletLink existingLink, uint part, HashSet visitedIndexes) { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} is already S={linkDefinition.Source}, T={linkDefinition.Target} => no change."); - options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); + return existingLink.IsFullPoint() + || existingLink.IsPartialPoint() + || !visitedIndexes.Contains(part); } - } - else - { - // create new link - var existingLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); - if (existingLinkIndex == default) + + private static void CreateOrUpdateLink(INamedTypesLinks links, DoubletLink linkDefinition, Options options) { - uint newLinkIndex = 0; - TraceIfEnabled(options, - $"[CreateOrUpdateLink] Creating new link => (S={linkDefinition.Source},T={linkDefinition.Target})."); - links.CreateAndUpdate(linkDefinition.Source, linkDefinition.Target, (beforeState, afterState) => - { - var afterLinkRecord = new DoubletLink(afterState); - if (newLinkIndex == 0 && afterLinkRecord.Index != 0 && afterLinkRecord.Index != anyConstant) - { - newLinkIndex = afterLinkRecord.Index; - TraceIfEnabled(options, $"[CreateOrUpdateLink] => assigned new ID={newLinkIndex}"); - } - return options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue; - }); - - if (newLinkIndex == 0 || newLinkIndex == anyConstant) - { - newLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); - } + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + // Wildcard substitution rename: delegate to nested creation with proper naming + if (linkDefinition.Index == anyConstant) + { + TraceIfEnabled(options, "[CreateOrUpdateLink] Detected wildcard substitution => nested create & name."); + var parsed = new Parser().Parse(options.Query ?? string.Empty); + if (parsed.Count > 0) + { + var outer = parsed[0]; + if (outer.Values != null && outer.Values.Count > 1) + { + var substitutionLinoLink = outer.Values[1]; + if (substitutionLinoLink.Values != null) + { + foreach (var composite in substitutionLinoLink.Values) + { + EnsureNestedLinkCreatedRecursively(links, composite, options); + } + } + } + } + return; + } + + if (linkDefinition.Index != nullConstant) + { + // update existing link + if (!links.Exists(linkDefinition.Index)) + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} doesn't exist => ensuring creation."); + LinksExtensions.EnsureCreated(links, linkDefinition.Index); + } + var existingLinkRecord = links.GetLink(linkDefinition.Index); + var existingDoublet = new DoubletLink(existingLinkRecord); + + if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) + { + TraceIfEnabled(options, + $"[CreateOrUpdateLink] Updating link #{linkDefinition.Index}: {existingDoublet.Source}->{linkDefinition.Source}, {existingDoublet.Target}->{linkDefinition.Target}."); + LinksExtensions.EnsureCreated(links, linkDefinition.Index); + links.Update( + new DoubletLink(linkDefinition.Index, anyConstant, anyConstant), + linkDefinition, + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + else + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} is already S={linkDefinition.Source}, T={linkDefinition.Target} => no change."); + options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); + } + } + else + { + // create new link + var existingLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); + if (existingLinkIndex == default) + { + uint newLinkIndex = 0; + TraceIfEnabled(options, + $"[CreateOrUpdateLink] Creating new link => (S={linkDefinition.Source},T={linkDefinition.Target})."); + links.CreateAndUpdate(linkDefinition.Source, linkDefinition.Target, (beforeState, afterState) => + { + var afterLinkRecord = new DoubletLink(afterState); + if (newLinkIndex == 0 && afterLinkRecord.Index != 0 && afterLinkRecord.Index != anyConstant) + { + newLinkIndex = afterLinkRecord.Index; + TraceIfEnabled(options, $"[CreateOrUpdateLink] => assigned new ID={newLinkIndex}"); + } + return options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue; + }); + + if (newLinkIndex == 0 || newLinkIndex == anyConstant) + { + newLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); + } + } + else + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link already found => ID={existingLinkIndex}, no changes."); + var existingLink = new DoubletLink(existingLinkIndex, linkDefinition.Source, linkDefinition.Target); + options.ChangesHandler?.Invoke(existingLink, existingLink); + } + } } - else + + private static void RemoveLinks( + INamedTypesLinks links, + DoubletLink restriction, + Options options) { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link already found => ID={existingLinkIndex}, no changes."); - var existingLink = new DoubletLink(existingLinkIndex, linkDefinition.Source, linkDefinition.Target); - options.ChangesHandler?.Invoke(existingLink, existingLink); - } - } - } + var linksToRemove = links.All(restriction) + .Where(l => l != null) + .Select(l => new DoubletLink(l)) + .ToList(); - private static void RemoveLinks( - INamedTypesLinks links, - DoubletLink restriction, - Options options) - { - var linksToRemove = links.All(restriction) - .Where(l => l != null) - .Select(l => new DoubletLink(l)) - .ToList(); + TraceIfEnabled(options, + $"[RemoveLinks] Found {linksToRemove.Count} link(s) matching (ID={restriction.Index}, S={restriction.Source}, T={restriction.Target})."); - TraceIfEnabled(options, - $"[RemoveLinks] Found {linksToRemove.Count} link(s) matching (ID={restriction.Index}, S={restriction.Source}, T={restriction.Target})."); + foreach (var link in linksToRemove) + { + if (links.Exists(link.Index)) + { + // Remove the name before deleting + links.RemoveName(link.Index); + TraceIfEnabled(options, $"[RemoveLinks] Deleting link => ID={link.Index}, S={link.Source}, T={link.Target}"); + links.Delete(link, (before, after) => + options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + } + } + } - foreach (var link in linksToRemove) - { - if (links.Exists(link.Index)) + private static DoubletLink ConvertToDoubletLink(INamedTypesLinks links, LinoLink linoLink, uint defaultValue) { - // Remove the name before deleting - links.RemoveName(link.Index); - TraceIfEnabled(options, $"[RemoveLinks] Deleting link => ID={link.Index}, S={link.Source}, T={link.Target}"); - links.Delete(link, (before, after) => - options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + uint index = defaultValue; + uint source = defaultValue; + uint target = defaultValue; + TryParseLinkId(linoLink.Id, links, ref index); + if (linoLink.Values?.Count == 2) + { + var sourceLink = linoLink.Values[0]; + TryParseLinkId(sourceLink.Id, links, ref source); + var targetLink = linoLink.Values[1]; + TryParseLinkId(targetLink.Id, links, ref target); + } + return new DoubletLink(index, source, target); } - } - } - - private static DoubletLink ConvertToDoubletLink(INamedTypesLinks links, LinoLink linoLink, uint defaultValue) - { - uint index = defaultValue; - uint source = defaultValue; - uint target = defaultValue; - TryParseLinkId(linoLink.Id, links, ref index); - if (linoLink.Values?.Count == 2) - { - var sourceLink = linoLink.Values[0]; - TryParseLinkId(sourceLink.Id, links, ref source); - var targetLink = linoLink.Values[1]; - TryParseLinkId(targetLink.Id, links, ref target); - } - return new DoubletLink(index, source, target); - } - private static bool TryParseLinkId(string? id, INamedTypesLinks links, ref uint parsedValue) - { - if (string.IsNullOrEmpty(id)) return false; - if (id == "*") - { - parsedValue = links.Constants.Any; - return true; - } - else if (id.EndsWith(":")) - { - var trimmed = id.TrimEnd(':'); - if (uint.TryParse(trimmed, out uint linkId)) + private static bool TryParseLinkId(string? id, INamedTypesLinks links, ref uint parsedValue) { - parsedValue = linkId; - return true; + if (string.IsNullOrEmpty(id)) return false; + if (id == "*") + { + parsedValue = links.Constants.Any; + return true; + } + else if (id.EndsWith(":")) + { + var trimmed = id.TrimEnd(':'); + if (uint.TryParse(trimmed, out uint linkId)) + { + parsedValue = linkId; + return true; + } + // Try to resolve as string alias + var aliasId = links.GetByName(trimmed); + if (aliasId != links.Constants.Null) + { + parsedValue = aliasId; + return true; + } + } + else if (uint.TryParse(id, out uint linkVal)) + { + parsedValue = linkVal; + return true; + } + else + { + // Try to resolve as string alias + var aliasId = links.GetByName(id); + if (aliasId != links.Constants.Null) + { + parsedValue = aliasId; + return true; + } + } + return false; } - // Try to resolve as string alias - var aliasId = links.GetByName(trimmed); - if (aliasId != links.Constants.Null) + + public class Pattern { - parsedValue = aliasId; - return true; + public string Index; + public Pattern? Source; + public Pattern? Target; + + public Pattern(string index, Pattern? source = null, Pattern? target = null) + { + Index = index ?? ""; + Source = source; + Target = target; + } + + public bool IsLeaf => Source == null && Target == null; } - } - else if (uint.TryParse(id, out uint linkVal)) - { - parsedValue = linkVal; - return true; - } - else - { - // Try to resolve as string alias - var aliasId = links.GetByName(id); - if (aliasId != links.Constants.Null) + + private static Pattern CreatePatternFromLino(LinoLink linkNode) { - parsedValue = aliasId; - return true; - } - } - return false; - } + if (linkNode.Values == null || linkNode.Values.Count == 0) + { + return new Pattern(linkNode.Id ?? ""); + } - public class Pattern - { - public string Index; - public Pattern? Source; - public Pattern? Target; - - public Pattern(string index, Pattern? source = null, Pattern? target = null) - { - Index = index ?? ""; - Source = source; - Target = target; - } - - public bool IsLeaf => Source == null && Target == null; - } + if (linkNode.Values.Count == 2) + { + var sourcePattern = CreatePatternFromLino(linkNode.Values[0]); + var targetPattern = CreatePatternFromLino(linkNode.Values[1]); + return new Pattern(linkNode.Id ?? "", sourcePattern, targetPattern); + } - private static Pattern CreatePatternFromLino(LinoLink linkNode) - { - if (linkNode.Values == null || linkNode.Values.Count == 0) - { - return new Pattern(linkNode.Id ?? ""); - } - - if (linkNode.Values.Count == 2) - { - var sourcePattern = CreatePatternFromLino(linkNode.Values[0]); - var targetPattern = CreatePatternFromLino(linkNode.Values[1]); - return new Pattern(linkNode.Id ?? "", sourcePattern, targetPattern); - } - - // If more than 2 => treat similarly to leaf with ID - return new Pattern(linkNode.Id ?? ""); - } + // If more than 2 => treat similarly to leaf with ID + return new Pattern(linkNode.Id ?? ""); + } - private static uint EnsureLinkCreated(INamedTypesLinks links, DoubletLink link, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - if (link.Index == nullConstant) - { - // If no index => search or create - var existingIndex = links.SearchOrDefault(link.Source, link.Target); - if (existingIndex == default) + private static uint EnsureLinkCreated(INamedTypesLinks links, DoubletLink link, Options options) { - uint createdIndex = 0; - TraceIfEnabled(options, $"[EnsureLinkCreated] Creating link for (S={link.Source}, T={link.Target})."); - links.CreateAndUpdate(link.Source, link.Target, (before, after) => - { - var afterLink = new DoubletLink(after); - if (createdIndex == 0 && afterLink.Index != 0 && afterLink.Index != anyConstant) - { - createdIndex = afterLink.Index; - TraceIfEnabled(options, $"[EnsureLinkCreated] => assigned new ID={createdIndex}"); - } - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - - if (createdIndex == 0 || createdIndex == anyConstant) - { - createdIndex = links.SearchOrDefault(link.Source, link.Target); - } - return createdIndex; + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + if (link.Index == nullConstant) + { + // If no index => search or create + var existingIndex = links.SearchOrDefault(link.Source, link.Target); + if (existingIndex == default) + { + uint createdIndex = 0; + TraceIfEnabled(options, $"[EnsureLinkCreated] Creating link for (S={link.Source}, T={link.Target})."); + links.CreateAndUpdate(link.Source, link.Target, (before, after) => + { + var afterLink = new DoubletLink(after); + if (createdIndex == 0 && afterLink.Index != 0 && afterLink.Index != anyConstant) + { + createdIndex = afterLink.Index; + TraceIfEnabled(options, $"[EnsureLinkCreated] => assigned new ID={createdIndex}"); + } + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + + if (createdIndex == 0 || createdIndex == anyConstant) + { + createdIndex = links.SearchOrDefault(link.Source, link.Target); + } + return createdIndex; + } + else + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link already found => ID={existingIndex} => no-op."); + var existing = new DoubletLink(existingIndex, link.Source, link.Target); + options.ChangesHandler?.Invoke(existing, existing); + return existingIndex; + } + } + else + { + // We have an index => ensure created or updated + if (!links.Exists(link.Index)) + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} doesn't exist => ensuring creation."); + LinksExtensions.EnsureCreated(links, link.Index); + } + var stored = links.GetLink(link.Index); + var storedD = new DoubletLink(stored); + if (storedD.Source != link.Source || storedD.Target != link.Target) + { + TraceIfEnabled(options, + $"[EnsureLinkCreated] Updating link #{link.Index} => {storedD.Source}->{link.Source}, {storedD.Target}->{link.Target}."); + uint finalIndex = link.Index; + links.Update(new DoubletLink(link.Index, anyConstant, anyConstant), link, (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); + return finalIndex; + } + else + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); + options.ChangesHandler?.Invoke(storedD, storedD); + return link.Index; + } + } } - else + + // Helper for link naming logic + private static bool IsNumericOrStar(string? id) { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link already found => ID={existingIndex} => no-op."); - var existing = new DoubletLink(existingIndex, link.Source, link.Target); - options.ChangesHandler?.Invoke(existing, existing); - return existingIndex; + if (string.IsNullOrEmpty(id)) return false; + if (id == "*") return true; + uint dummy; + return uint.TryParse(id, out dummy); } - } - else - { - // We have an index => ensure created or updated - if (!links.Exists(link.Index)) + + private static void TraceIfEnabled(Options options, string message) { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} doesn't exist => ensuring creation."); - LinksExtensions.EnsureCreated(links, link.Index); + if (options.Trace) + { + Console.WriteLine(message); + } } - var stored = links.GetLink(link.Index); - var storedD = new DoubletLink(stored); - if (storedD.Source != link.Source || storedD.Target != link.Target) + + // Consolidates getting or creating a named link (leaf) without setting its relationships + private static uint EnsureNamedLeafLink(INamedTypesLinks links, string name, Options options) { - TraceIfEnabled(options, - $"[EnsureLinkCreated] Updating link #{link.Index} => {storedD.Source}->{link.Source}, {storedD.Target}->{link.Target}."); - uint finalIndex = link.Index; - links.Update(new DoubletLink(link.Index, anyConstant, anyConstant), link, (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); - return finalIndex; + var existing = links.GetByName(name); + if (existing != links.Constants.Null) return existing; + var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created named leaf '{name}' => ID={newId}"); + links.SetName(newId, name); + return newId; } - else + + // Applies a single structural update to an existing link: sets its source and target + private static void ApplyCompositeUpdate(INamedTypesLinks links, uint id, uint source, uint target, Options options) { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); - options.ChangesHandler?.Invoke(storedD, storedD); - return link.Index; + var restriction = new DoubletLink(id, links.Constants.Null, links.Constants.Null); + var substitution = new DoubletLink(id, source, target); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link ID={id} => Source={source}, Target={target}"); + links.Update(restriction, substitution, (before, after) => + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={before}, after={after}"); + return links.Constants.Continue; + }); + } + + /// + /// Detects a two-child composite pattern where at least one child matches the composite identifier. + /// + private static bool TryGetTwoChildCompositePattern( + LinoLink pattern, + out string compositeIdentifier, + out LinoLink leftPattern, + out LinoLink rightPattern) + { + compositeIdentifier = pattern.Id ?? string.Empty; + leftPattern = default!; + rightPattern = default!; + if (!string.IsNullOrEmpty(compositeIdentifier) + && pattern.Values != null + && pattern.Values.Count == 2) + { + leftPattern = pattern.Values[0]; + rightPattern = pattern.Values[1]; + // Only detect composites when one or both children share the identifier + if (leftPattern.Id == compositeIdentifier || rightPattern.Id == compositeIdentifier) + { + return true; + } + } + return false; } - } - } - - // Helper for link naming logic - private static bool IsNumericOrStar(string? id) - { - if (string.IsNullOrEmpty(id)) return false; - if (id == "*") return true; - uint dummy; - return uint.TryParse(id, out dummy); - } - private static void TraceIfEnabled(Options options, string message) - { - if (options.Trace) - { - Console.WriteLine(message); - } - } + private enum CompositeCase { Self, LeftMix, RightMix } - // Consolidates getting or creating a named link (leaf) without setting its relationships - private static uint EnsureNamedLeafLink(INamedTypesLinks links, string name, Options options) - { - var existing = links.GetByName(name); - if (existing != links.Constants.Null) return existing; - var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created named leaf '{name}' => ID={newId}"); - links.SetName(newId, name); - return newId; - } - - // Applies a single structural update to an existing link: sets its source and target - private static void ApplyCompositeUpdate(INamedTypesLinks links, uint id, uint source, uint target, Options options) - { - var restriction = new DoubletLink(id, links.Constants.Null, links.Constants.Null); - var substitution = new DoubletLink(id, source, target); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link ID={id} => Source={source}, Target={target}"); - links.Update(restriction, substitution, (before, after) => - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={before}, after={after}"); - return links.Constants.Continue; - }); - } - - /// - /// Detects a two-child composite pattern where at least one child matches the composite identifier. - /// - private static bool TryGetTwoChildCompositePattern( - LinoLink pattern, - out string compositeIdentifier, - out LinoLink leftPattern, - out LinoLink rightPattern) - { - compositeIdentifier = pattern.Id ?? string.Empty; - leftPattern = default!; - rightPattern = default!; - if (!string.IsNullOrEmpty(compositeIdentifier) - && pattern.Values != null - && pattern.Values.Count == 2) - { - leftPattern = pattern.Values[0]; - rightPattern = pattern.Values[1]; - // Only detect composites when one or both children share the identifier - if (leftPattern.Id == compositeIdentifier || rightPattern.Id == compositeIdentifier) + private static CompositeCase ClassifyCompositeCase(string name, LinoLink left, LinoLink right) { - return true; + if (left.Id == name && right.Id == name) return CompositeCase.Self; + if (left.Id == name && right.Id != name) return CompositeCase.LeftMix; + if (left.Id != name && right.Id == name) return CompositeCase.RightMix; + throw new InvalidOperationException($"Invalid composite pattern for name '{name}'"); } - } - return false; - } - - private enum CompositeCase { Self, LeftMix, RightMix } - - private static CompositeCase ClassifyCompositeCase(string name, LinoLink left, LinoLink right) - { - if (left.Id == name && right.Id == name) return CompositeCase.Self; - if (left.Id == name && right.Id != name) return CompositeCase.LeftMix; - if (left.Id != name && right.Id == name) return CompositeCase.RightMix; - throw new InvalidOperationException($"Invalid composite pattern for name '{name}'"); - } - - private static uint HandleStringComposite(string name, LinoLink left, LinoLink right, INamedTypesLinks links, Options options) - { - var id = EnsureNamedLeafLink(links, name, options); - var caseType = ClassifyCompositeCase(name, left, right); - switch (caseType) - { - case CompositeCase.Self: - ApplyCompositeUpdate(links, id, id, id, options); - return id; - case CompositeCase.LeftMix: - { - var otherId = EnsureNestedLinkCreatedRecursively(links, right, options); - ApplyCompositeUpdate(links, id, id, otherId, options); - return id; - } - case CompositeCase.RightMix: - { - var otherId = EnsureNestedLinkCreatedRecursively(links, left, options); - ApplyCompositeUpdate(links, id, otherId, id, options); - return id; - } - default: - throw new InvalidOperationException($"Unhandled composite case {caseType}"); - } - } - - /// - /// Resolves a single leaf pattern into its numeric or named link ID. - /// - private static uint ResolveLeaf(LinoLink pattern, INamedTypesLinks links, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - if (string.IsNullOrEmpty(pattern.Id)) - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with empty ID => returning ANY."); - return anyConstant; - } - if (pattern.Id == "*") - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with '*' => returning ANY."); - return anyConstant; - } - if (pattern.Id.StartsWith("$")) - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Variable leaf => returning ANY."); - return anyConstant; - } - if (uint.TryParse(pattern.Id, out uint parsedNumber)) - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Leaf parse => returning {parsedNumber}."); - return parsedNumber; - } - var existingId = links.GetByName(pattern.Id); - if (existingId != links.Constants.Null) - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Found existing named leaf '{pattern.Id}' => ID={existingId}"); - return existingId; - } - var newLeafId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] SetName({newLeafId}, '{pattern.Id}')"); - links.SetName(newLeafId, pattern.Id); - var restriction = new DoubletLink(newLeafId, links.Constants.Null, links.Constants.Null); - var substitution = new DoubletLink(newLeafId, newLeafId, newLeafId); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link {newLeafId} to be self-referential"); - links.Update(restriction, substitution, (beforeState, afterState) => - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={beforeState}, after={afterState}"); - return links.Constants.Continue; - }); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created new self-referential named leaf '{pattern.Id}' => ID={newLeafId}"); - return newLeafId; - } - /// - /// Ensures a composite link exists with the given index or named identifier and child IDs. - /// - private static uint CreateCompositeLink( - string? literalIdentifier, - uint sourceLinkId, - uint targetLinkId, - INamedTypesLinks links, - Options options) - { - // Determine the numeric index for the composite: default 0, wildcard, or parsed from identifier - uint compositeIndex = 0; - var wildcardIndex = links.Constants.Any; - if (!string.IsNullOrEmpty(literalIdentifier)) - { - if (literalIdentifier == "*") + private static uint HandleStringComposite(string name, LinoLink left, LinoLink right, INamedTypesLinks links, Options options) { - compositeIndex = wildcardIndex; + var id = EnsureNamedLeafLink(links, name, options); + var caseType = ClassifyCompositeCase(name, left, right); + switch (caseType) + { + case CompositeCase.Self: + ApplyCompositeUpdate(links, id, id, id, options); + return id; + case CompositeCase.LeftMix: + { + var otherId = EnsureNestedLinkCreatedRecursively(links, right, options); + ApplyCompositeUpdate(links, id, id, otherId, options); + return id; + } + case CompositeCase.RightMix: + { + var otherId = EnsureNestedLinkCreatedRecursively(links, left, options); + ApplyCompositeUpdate(links, id, otherId, id, options); + return id; + } + default: + throw new InvalidOperationException($"Unhandled composite case {caseType}"); + } } - else + + /// + /// Resolves a single leaf pattern into its numeric or named link ID. + /// + private static uint ResolveLeaf(LinoLink pattern, INamedTypesLinks links, Options options) { - var identifierClean = literalIdentifier.Replace(":", string.Empty); - if (uint.TryParse(identifierClean, out var parsedIndex)) - { - compositeIndex = parsedIndex; - } + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + if (string.IsNullOrEmpty(pattern.Id)) + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with empty ID => returning ANY."); + return anyConstant; + } + if (pattern.Id == "*") + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with '*' => returning ANY."); + return anyConstant; + } + if (pattern.Id.StartsWith("$")) + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Variable leaf => returning ANY."); + return anyConstant; + } + if (uint.TryParse(pattern.Id, out uint parsedNumber)) + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Leaf parse => returning {parsedNumber}."); + return parsedNumber; + } + var existingId = links.GetByName(pattern.Id); + if (existingId != links.Constants.Null) + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Found existing named leaf '{pattern.Id}' => ID={existingId}"); + return existingId; + } + var newLeafId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] SetName({newLeafId}, '{pattern.Id}')"); + links.SetName(newLeafId, pattern.Id); + var restriction = new DoubletLink(newLeafId, links.Constants.Null, links.Constants.Null); + var substitution = new DoubletLink(newLeafId, newLeafId, newLeafId); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link {newLeafId} to be self-referential"); + links.Update(restriction, substitution, (beforeState, afterState) => + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={beforeState}, after={afterState}"); + return links.Constants.Continue; + }); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created new self-referential named leaf '{pattern.Id}' => ID={newLeafId}"); + return newLeafId; + } + + /// + /// Ensures a composite link exists with the given index or named identifier and child IDs. + /// + private static uint CreateCompositeLink( + string? literalIdentifier, + uint sourceLinkId, + uint targetLinkId, + INamedTypesLinks links, + Options options) + { + // Determine the numeric index for the composite: default 0, wildcard, or parsed from identifier + uint compositeIndex = 0; + var wildcardIndex = links.Constants.Any; + if (!string.IsNullOrEmpty(literalIdentifier)) + { + if (literalIdentifier == "*") + { + compositeIndex = wildcardIndex; + } + else + { + var identifierClean = literalIdentifier.Replace(":", string.Empty); + if (uint.TryParse(identifierClean, out var parsedIndex)) + { + compositeIndex = parsedIndex; + } + } + } + // Build the composite link structure and ensure it exists + var compositeLinkDefinition = new DoubletLink(compositeIndex, sourceLinkId, targetLinkId); + var compositeLinkId = EnsureLinkCreated(links, compositeLinkDefinition, options); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created or ensured composite link => Index={compositeIndex}, Source={sourceLinkId}, Target={targetLinkId} => Actual ID={compositeLinkId}"); + // Assign the name for non-numeric identifiers + if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier) && !literalIdentifier.StartsWith("$")) + { + links.SetName(compositeLinkId, literalIdentifier); + } + return compositeLinkId; } - } - // Build the composite link structure and ensure it exists - var compositeLinkDefinition = new DoubletLink(compositeIndex, sourceLinkId, targetLinkId); - var compositeLinkId = EnsureLinkCreated(links, compositeLinkDefinition, options); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created or ensured composite link => Index={compositeIndex}, Source={sourceLinkId}, Target={targetLinkId} => Actual ID={compositeLinkId}"); - // Assign the name for non-numeric identifiers - if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier) && !literalIdentifier.StartsWith("$")) - { - links.SetName(compositeLinkId, literalIdentifier); - } - return compositeLinkId; - } - private static void ValidateLinksExistOrWillBeCreated( - INamedTypesLinks links, - IList restrictionPatterns, - IList substitutionPatterns, - Options options) - { - TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Starting validation"); + private static void ValidateLinksExistOrWillBeCreated( + INamedTypesLinks links, + IList restrictionPatterns, + IList substitutionPatterns, + Options options) + { + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Starting validation"); - var plan = BuildLinkReferencePlan(links, substitutionPatterns); + var plan = BuildLinkReferencePlan(links, substitutionPatterns); - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {string.Join(", ", plan.NumericIdsToBeCreated.OrderBy(id => id))}"); - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Named links to be created: {string.Join(", ", plan.NamesToBeCreated.OrderBy(name => name, StringComparer.Ordinal))}"); + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {string.Join(", ", plan.NumericIdsToBeCreated.OrderBy(id => id))}"); + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Named links to be created: {string.Join(", ", plan.NamesToBeCreated.OrderBy(name => name, StringComparer.Ordinal))}"); - CollectMissingReferences(restrictionPatterns, links, plan, false, "restriction", options); - CollectMissingReferences(substitutionPatterns, links, plan, true, "substitution", options); + CollectMissingReferences(restrictionPatterns, links, plan, false, "restriction", options); + CollectMissingReferences(substitutionPatterns, links, plan, true, "substitution", options); - if (plan.MissingReferences.Count > 0) - { - if (!options.AutoCreateMissingReferences) - { - var missing = plan.MissingReferences[0]; - throw new InvalidOperationException( - $"Invalid reference to non-existent link '{missing.Identifier}' in {missing.PatternType} pattern. " + - $"Link '{missing.Identifier}' does not exist and will not be created by this operation. " + - "Use --auto-create-missing-references to create missing references as point links." - ); + if (plan.MissingReferences.Count > 0) + { + if (!options.AutoCreateMissingReferences) + { + var missing = plan.MissingReferences[0]; + throw new InvalidOperationException( + $"Invalid reference to non-existent link '{missing.Identifier}' in {missing.PatternType} pattern. " + + $"Link '{missing.Identifier}' does not exist and will not be created by this operation. " + + "Use --auto-create-missing-references to create missing references as point links." + ); + } + + AutoCreateMissingReferences(links, plan, options); + } + + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); } - AutoCreateMissingReferences(links, plan, options); - } + private sealed class LinkReferencePlan + { + public HashSet NumericIdsToBeCreated { get; } = new(); + public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); + public HashSet<(uint Source, uint Target)> CompositePairsToBeCreated { get; } = new(); + public List MissingReferences { get; } = new(); + private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); - TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); - } + public void AddMissingReference(MissingLinkReference reference) + { + if (_missingReferenceKeys.Add(reference.Key)) + { + MissingReferences.Add(reference); + } + } + } - private sealed class LinkReferencePlan - { - public HashSet NumericIdsToBeCreated { get; } = new(); - public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); - public HashSet<(uint Source, uint Target)> CompositePairsToBeCreated { get; } = new(); - public List MissingReferences { get; } = new(); - private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); - - public void AddMissingReference(MissingLinkReference reference) - { - if (_missingReferenceKeys.Add(reference.Key)) + private sealed class MissingLinkReference { - MissingReferences.Add(reference); + public required string Identifier { get; init; } + public required string PatternType { get; init; } + public required uint? NumericId { get; init; } + public string Key => NumericId.HasValue ? $"id:{NumericId.Value}" : $"name:{Identifier}"; } - } - } - private sealed class MissingLinkReference - { - public required string Identifier { get; init; } - public required string PatternType { get; init; } - public required uint? NumericId { get; init; } - public string Key => NumericId.HasValue ? $"id:{NumericId.Value}" : $"name:{Identifier}"; - } - - private static LinkReferencePlan BuildLinkReferencePlan(INamedTypesLinks links, IList substitutionPatterns) - { - var plan = new LinkReferencePlan(); - var reservedNumericIds = new HashSet(); + private static LinkReferencePlan BuildLinkReferencePlan(INamedTypesLinks links, IList substitutionPatterns) + { + var plan = new LinkReferencePlan(); + var reservedNumericIds = new HashSet(); - foreach (var pattern in substitutionPatterns) - { - CollectExplicitDefinitions(pattern, plan, reservedNumericIds); - } + foreach (var pattern in substitutionPatterns) + { + CollectExplicitDefinitions(pattern, plan, reservedNumericIds); + } - foreach (var pattern in substitutionPatterns) - { - CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); - } + foreach (var pattern in substitutionPatterns) + { + CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); + } - foreach (var pattern in substitutionPatterns) - { - CollectCompositePairs(pattern, plan); - } + foreach (var pattern in substitutionPatterns) + { + CollectCompositePairs(pattern, plan); + } - return plan; - } + return plan; + } - private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePlan plan, HashSet reservedNumericIds) - { - if (IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var identifier)) - { - if (uint.TryParse(identifier, out var linkId)) + private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePlan plan, HashSet reservedNumericIds) { - plan.NumericIdsToBeCreated.Add(linkId); - reservedNumericIds.Add(linkId); + if (IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + if (uint.TryParse(identifier, out var linkId)) + { + plan.NumericIdsToBeCreated.Add(linkId); + reservedNumericIds.Add(linkId); + } + else + { + plan.NamesToBeCreated.Add(identifier); + } + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectExplicitDefinitions(subPattern, plan, reservedNumericIds); + } + } } - else + + private static void CollectCompositePairs(LinoLink pattern, LinkReferencePlan plan) { - plan.NamesToBeCreated.Add(identifier); + if (IsComposite(pattern) && + TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier) && + pattern.Values != null && + TryGetConcreteNumericIdentifier(pattern.Values[0].Id, out var source) && + TryGetConcreteNumericIdentifier(pattern.Values[1].Id, out var target)) + { + plan.CompositePairsToBeCreated.Add((source, target)); + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectCompositePairs(subPattern, plan); + } + } } - } - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) + private static void CollectImplicitDefinitions( + LinoLink pattern, + INamedTypesLinks links, + LinkReferencePlan plan, + HashSet reservedNumericIds) { - CollectExplicitDefinitions(subPattern, plan, reservedNumericIds); + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectImplicitDefinitions(subPattern, links, plan, reservedNumericIds); + } + } + + if (IsComposite(pattern) && !TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier)) + { + var nextId = GetNextAvailableLinkId(links, reservedNumericIds); + reservedNumericIds.Add(nextId); + plan.NumericIdsToBeCreated.Add(nextId); + } } - } - } - private static void CollectCompositePairs(LinoLink pattern, LinkReferencePlan plan) - { - if (IsComposite(pattern) && - TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier) && - pattern.Values != null && - TryGetConcreteNumericIdentifier(pattern.Values[0].Id, out var source) && - TryGetConcreteNumericIdentifier(pattern.Values[1].Id, out var target)) - { - plan.CompositePairsToBeCreated.Add((source, target)); - } - - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) + private static uint GetNextAvailableLinkId(INamedTypesLinks links, HashSet reservedNumericIds) { - CollectCompositePairs(subPattern, plan); + uint nextId = 1; + while (links.Exists(nextId) || reservedNumericIds.Contains(nextId)) + { + nextId++; + } + return nextId; } - } - } - private static void CollectImplicitDefinitions( - LinoLink pattern, - INamedTypesLinks links, - LinkReferencePlan plan, - HashSet reservedNumericIds) - { - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) + private static void CollectMissingReferences( + IList patterns, + INamedTypesLinks links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, + Options options) { - CollectImplicitDefinitions(subPattern, links, plan, reservedNumericIds); + foreach (var pattern in patterns) + { + CollectMissingReferences(pattern, links, plan, isSubstitution, patternType, options); + } } - } - - if (IsComposite(pattern) && !TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier)) - { - var nextId = GetNextAvailableLinkId(links, reservedNumericIds); - reservedNumericIds.Add(nextId); - plan.NumericIdsToBeCreated.Add(nextId); - } - } - - private static uint GetNextAvailableLinkId(INamedTypesLinks links, HashSet reservedNumericIds) - { - uint nextId = 1; - while (links.Exists(nextId) || reservedNumericIds.Contains(nextId)) - { - nextId++; - } - return nextId; - } - - private static void CollectMissingReferences( - IList patterns, - INamedTypesLinks links, - LinkReferencePlan plan, - bool isSubstitution, - string patternType, - Options options) - { - foreach (var pattern in patterns) - { - CollectMissingReferences(pattern, links, plan, isSubstitution, patternType, options); - } - } - private static void CollectMissingReferences( - LinoLink pattern, - INamedTypesLinks links, - LinkReferencePlan plan, - bool isSubstitution, - string patternType, - Options options) - { - var patternIdIsDefinition = isSubstitution && IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier); + private static void CollectMissingReferences( + LinoLink pattern, + INamedTypesLinks links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, + Options options) + { + var patternIdIsDefinition = isSubstitution && IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier); - if (!patternIdIsDefinition && TryGetConcreteIdentifier(pattern.Id, out var identifier)) - { - ValidateReferenceIdentifier(identifier, links, plan, patternType, options); - } + if (!patternIdIsDefinition && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + ValidateReferenceIdentifier(identifier, links, plan, patternType, options); + } - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectMissingReferences(subPattern, links, plan, isSubstitution, patternType, options); + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectMissingReferences(subPattern, links, plan, isSubstitution, patternType, options); + } + } } - } - } - private static void ValidateReferenceIdentifier( - string identifier, - INamedTypesLinks links, - LinkReferencePlan plan, - string patternType, - Options options) - { - if (uint.TryParse(identifier, out var linkId)) - { - if (!links.Exists(linkId) && !plan.NumericIdsToBeCreated.Contains(linkId)) + private static void ValidateReferenceIdentifier( + string identifier, + INamedTypesLinks links, + LinkReferencePlan plan, + string patternType, + Options options) { - plan.AddMissingReference(new MissingLinkReference - { - Identifier = identifier, - PatternType = patternType, - NumericId = linkId - }); - return; - } - TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); - return; - } + if (uint.TryParse(identifier, out var linkId)) + { + if (!links.Exists(linkId) && !plan.NumericIdsToBeCreated.Contains(linkId)) + { + plan.AddMissingReference(new MissingLinkReference + { + Identifier = identifier, + PatternType = patternType, + NumericId = linkId + }); + return; + } + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); + return; + } - if (links.GetByName(identifier) == links.Constants.Null && !plan.NamesToBeCreated.Contains(identifier)) - { - plan.AddMissingReference(new MissingLinkReference - { - Identifier = identifier, - PatternType = patternType, - NumericId = null - }); - return; - } - - TraceIfEnabled(options, $"[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {patternType} pattern"); - } + if (links.GetByName(identifier) == links.Constants.Null && !plan.NamesToBeCreated.Contains(identifier)) + { + plan.AddMissingReference(new MissingLinkReference + { + Identifier = identifier, + PatternType = patternType, + NumericId = null + }); + return; + } - private static void AutoCreateMissingReferences( - INamedTypesLinks links, - LinkReferencePlan plan, - Options options) - { - foreach (var missing in plan.MissingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) - { - var linkId = missing.NumericId!.Value; - if (links.Exists(linkId)) - { - continue; + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {patternType} pattern"); } - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId}."); - LinksExtensions.EnsureCreated(links, linkId); - if (plan.CompositePairsToBeCreated.Contains((linkId, linkId))) + private static void AutoCreateMissingReferences( + INamedTypesLinks links, + LinkReferencePlan plan, + Options options) { - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Link {linkId} exists as a placeholder because ({linkId}, {linkId}) is defined by the substitution."); - continue; + foreach (var missing in plan.MissingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) + { + var linkId = missing.NumericId!.Value; + if (links.Exists(linkId)) + { + continue; + } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId}."); + LinksExtensions.EnsureCreated(links, linkId); + if (plan.CompositePairsToBeCreated.Contains((linkId, linkId))) + { + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Link {linkId} exists as a placeholder because ({linkId}, {linkId}) is defined by the substitution."); + continue; + } + links.Update( + new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), + new DoubletLink(linkId, linkId, linkId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + + foreach (var missing in plan.MissingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) + { + if (links.GetByName(missing.Identifier) != links.Constants.Null) + { + continue; + } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{missing.Identifier}' as point link."); + EnsureNamedPointLink(links, missing.Identifier, options); + } } - links.Update( - new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), - new DoubletLink(linkId, linkId, linkId), - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } - - foreach (var missing in plan.MissingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) - { - if (links.GetByName(missing.Identifier) != links.Constants.Null) + + private static void EnsureNamedPointLink(INamedTypesLinks links, string name, Options options) { - continue; + if (links.GetByName(name) != links.Constants.Null) + { + return; + } + + var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(newId, name); + links.Update( + new DoubletLink(newId, links.Constants.Null, links.Constants.Null), + new DoubletLink(newId, newId, newId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); } - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{missing.Identifier}' as point link."); - EnsureNamedPointLink(links, missing.Identifier, options); - } - } + private static bool IsComposite(LinoLink pattern) => pattern.Values?.Count == 2; - private static void EnsureNamedPointLink(INamedTypesLinks links, string name, Options options) - { - if (links.GetByName(name) != links.Constants.Null) - { - return; - } - - var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - links.SetName(newId, name); - links.Update( - new DoubletLink(newId, links.Constants.Null, links.Constants.Null), - new DoubletLink(newId, newId, newId), - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } + private static bool TryGetConcreteIdentifier(string? id, out string identifier) + { + identifier = string.Empty; + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } - private static bool IsComposite(LinoLink pattern) => pattern.Values?.Count == 2; + identifier = id.TrimEnd(':'); + if (identifier.Length == 0 || identifier == "*" || identifier.StartsWith("$")) + { + return false; + } - private static bool TryGetConcreteIdentifier(string? id, out string identifier) - { - identifier = string.Empty; - if (string.IsNullOrWhiteSpace(id)) - { - return false; - } - - identifier = id.TrimEnd(':'); - if (identifier.Length == 0 || identifier == "*" || identifier.StartsWith("$")) - { - return false; - } - - return true; - } + return true; + } - private static bool TryGetConcreteNumericIdentifier(string? id, out uint linkId) - { - linkId = 0; - return TryGetConcreteIdentifier(id, out var identifier) && uint.TryParse(identifier, out linkId); + private static bool TryGetConcreteNumericIdentifier(string? id, out uint linkId) + { + linkId = 0; + return TryGetConcreteIdentifier(id, out var identifier) && uint.TryParse(identifier, out linkId); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs index 4bab672..d4c464a 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs @@ -6,114 +6,114 @@ namespace Foundation.Data.Doublets.Cli { - // Query Processor class with single static method to process queries - public static class BasicQueryProcessor - { - // ProcessQuery method to process queries - public static void ProcessQuery(ILinks links, string query) + // Query Processor class with single static method to process queries + public static class BasicQueryProcessor { - var parser = new Parser(); - var parsedLinks = parser.Parse(query); + // ProcessQuery method to process queries + public static void ProcessQuery(ILinks links, string query) + { + var parser = new Parser(); + var parsedLinks = parser.Parse(query); - if (parsedLinks.Count == 0) - { - return; - } + if (parsedLinks.Count == 0) + { + return; + } - var outerLink = parsedLinks[0]; - var outerLinkValues = outerLink.Values; + var outerLink = parsedLinks[0]; + var outerLinkValues = outerLink.Values; - if (outerLinkValues?.Count < 2) - { - return; - } + if (outerLinkValues?.Count < 2) + { + return; + } - var @null = links.Constants.Null; - var any = links.Constants.Any; + var @null = links.Constants.Null; + var any = links.Constants.Any; - if (outerLinkValues == null) - { - return; - } + if (outerLinkValues == null) + { + return; + } - var restrictionLink = outerLinkValues[0]; - var substitutionLink = outerLinkValues[1]; + var restrictionLink = outerLinkValues[0]; + var substitutionLink = outerLinkValues[1]; - if ((restrictionLink.Values?.Count == 0) && - (substitutionLink.Values?.Count == 0)) - { - return; - } - else if ((restrictionLink.Values?.Count > 0) && - (substitutionLink.Values?.Count > 0)) - { - // Update operation for multiple links - if (restrictionLink.Values.Count != substitutionLink.Values.Count) - { - Console.WriteLine("The number of restriction links and substitution links must be the same."); - return; - } + if ((restrictionLink.Values?.Count == 0) && + (substitutionLink.Values?.Count == 0)) + { + return; + } + else if ((restrictionLink.Values?.Count > 0) && + (substitutionLink.Values?.Count > 0)) + { + // Update operation for multiple links + if (restrictionLink.Values.Count != substitutionLink.Values.Count) + { + Console.WriteLine("The number of restriction links and substitution links must be the same."); + return; + } - for (int i = 0; i < restrictionLink.Values.Count; i++) - { - var restrictionLinoLink = restrictionLink.Values[i]; - var substitutionLinoLink = substitutionLink.Values[i]; + for (int i = 0; i < restrictionLink.Values.Count; i++) + { + var restrictionLinoLink = restrictionLink.Values[i]; + var substitutionLinoLink = substitutionLink.Values[i]; - var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); - var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); + var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); + var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); - links.Update(restrictionDoublet, substitutionDoublet, (before, after) => - { - return links.Constants.Continue; - }); - } + links.Update(restrictionDoublet, substitutionDoublet, (before, after) => + { + return links.Constants.Continue; + }); + } - return; - } - else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation - { - foreach (var linkToDelete in restrictionLink.Values ?? []) - { - var queryLink = ToDoubletLink(links, linkToDelete, any); - links.DeleteByQuery(queryLink); + return; + } + else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation + { + foreach (var linkToDelete in restrictionLink.Values ?? []) + { + var queryLink = ToDoubletLink(links, linkToDelete, any); + links.DeleteByQuery(queryLink); + } + return; + } + else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation + { + foreach (var linkToCreate in substitutionLink.Values ?? []) + { + var doubletLink = ToDoubletLink(links, linkToCreate, @null); + links.GetOrCreate(doubletLink.Source, doubletLink.Target); + } + return; + } } - return; - } - else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation - { - foreach (var linkToCreate in substitutionLink.Values ?? []) - { - var doubletLink = ToDoubletLink(links, linkToCreate, @null); - links.GetOrCreate(doubletLink.Source, doubletLink.Target); - } - return; - } - } - static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) - { - uint index = defaultValue; - uint source = defaultValue; - uint target = defaultValue; - if (!string.IsNullOrEmpty(linoLink.Id) && uint.TryParse(linoLink.Id, out uint linkId)) - { - index = linkId; - } - if (linoLink.Values?.Count == 2) - { - var sourceLink = linoLink.Values[0]; - var targetLink = linoLink.Values[1]; - if (!string.IsNullOrEmpty(sourceLink.Id) && uint.TryParse(sourceLink.Id, out uint sourceId)) - { - source = sourceId; - } - if (!string.IsNullOrEmpty(targetLink.Id) && uint.TryParse(targetLink.Id, out uint targetId)) + static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) { - target = targetId; + uint index = defaultValue; + uint source = defaultValue; + uint target = defaultValue; + if (!string.IsNullOrEmpty(linoLink.Id) && uint.TryParse(linoLink.Id, out uint linkId)) + { + index = linkId; + } + if (linoLink.Values?.Count == 2) + { + var sourceLink = linoLink.Values[0]; + var targetLink = linoLink.Values[1]; + if (!string.IsNullOrEmpty(sourceLink.Id) && uint.TryParse(sourceLink.Id, out uint sourceId)) + { + source = sourceId; + } + if (!string.IsNullOrEmpty(targetLink.Id) && uint.TryParse(targetLink.Id, out uint targetId)) + { + target = targetId; + } + } + return new DoubletLink(index, source, target); } - } - return new DoubletLink(index, source, target); } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs b/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs index 7024d18..9a26127 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs @@ -2,201 +2,201 @@ namespace Foundation.Data.Doublets.Cli { - public static class ChangesSimplifier - { - /// - /// Simplifies a list of changes by identifying chains of transformations. - /// If multiple final states are reachable from the same initial state, returns multiple simplified changes. - /// If a scenario arises where no initial or final states can be identified (no-ops), returns the original transitions as-is. - /// - /// List of tuples representing changes (before, after). - /// - /// Simplified list of changes from initial states to final states, - /// or original transitions if no change is detected. - /// - public static IEnumerable<(Link Before, Link After)> SimplifyChanges( - IEnumerable<(Link Before, Link After)> changes - ) + public static class ChangesSimplifier { - if (changes == null) throw new ArgumentNullException(nameof(changes)); - - var changesList = changes.ToList(); - if (changesList.Count == 0) - { - // No changes at all, return empty - return Enumerable.Empty<(Link, Link)>(); - } - - // **FIX for Issue #26**: Remove duplicate before states by keeping the last occurrence - // This handles cases where the same link is reported with multiple different transformations - changesList = RemoveDuplicateBeforeStates(changesList); - - // First, handle unchanged states directly - var unchangedStates = new List<(Link Before, Link After)>(); - var changedStates = new List<(Link Before, Link After)>(); - - foreach (var change in changesList) - { - if (LinkEqualityComparer.Instance.Equals(change.Before, change.After)) + /// + /// Simplifies a list of changes by identifying chains of transformations. + /// If multiple final states are reachable from the same initial state, returns multiple simplified changes. + /// If a scenario arises where no initial or final states can be identified (no-ops), returns the original transitions as-is. + /// + /// List of tuples representing changes (before, after). + /// + /// Simplified list of changes from initial states to final states, + /// or original transitions if no change is detected. + /// + public static IEnumerable<(Link Before, Link After)> SimplifyChanges( + IEnumerable<(Link Before, Link After)> changes + ) { - unchangedStates.Add(change); - } - else - { - changedStates.Add(change); - } - } - - // Gather all 'Before' links and all 'After' links from changed states - var beforeLinks = new HashSet>(changedStates.Select(c => c.Before), LinkEqualityComparer.Instance); - var afterLinks = new HashSet>(changedStates.Select(c => c.After), LinkEqualityComparer.Instance); + if (changes == null) throw new ArgumentNullException(nameof(changes)); - // Identify initial states: appear as Before but never as After - var initialStates = beforeLinks.Where(b => !afterLinks.Contains(b)).ToList(); + var changesList = changes.ToList(); + if (changesList.Count == 0) + { + // No changes at all, return empty + return Enumerable.Empty<(Link, Link)>(); + } - // Identify final states: appear as After but never as Before - var finalStates = afterLinks.Where(a => !beforeLinks.Contains(a)) - .ToHashSet(LinkEqualityComparer.Instance); + // **FIX for Issue #26**: Remove duplicate before states by keeping the last occurrence + // This handles cases where the same link is reported with multiple different transformations + changesList = RemoveDuplicateBeforeStates(changesList); - // Build adjacency (Before -> possible list of After links) - var adjacency = new Dictionary, List>>(LinkEqualityComparer.Instance); - foreach (var (before, after) in changedStates) - { - if (!adjacency.TryGetValue(before, out var list)) - { - list = new List>(); - adjacency[before] = list; - } - list.Add(after); - } + // First, handle unchanged states directly + var unchangedStates = new List<(Link Before, Link After)>(); + var changedStates = new List<(Link Before, Link After)>(); - // If we have no identified initial states, treat it as a no-op scenario: - // just return original transitions. - if (initialStates.Count == 0) - { - return changesList; - } + foreach (var change in changesList) + { + if (LinkEqualityComparer.Instance.Equals(change.Before, change.After)) + { + unchangedStates.Add(change); + } + else + { + changedStates.Add(change); + } + } - var results = new List<(Link Before, Link After)>(); + // Gather all 'Before' links and all 'After' links from changed states + var beforeLinks = new HashSet>(changedStates.Select(c => c.Before), LinkEqualityComparer.Instance); + var afterLinks = new HashSet>(changedStates.Select(c => c.After), LinkEqualityComparer.Instance); - // Add unchanged states first - results.AddRange(unchangedStates); + // Identify initial states: appear as Before but never as After + var initialStates = beforeLinks.Where(b => !afterLinks.Contains(b)).ToList(); - // Traverse each initial state with DFS - foreach (var initial in initialStates.Distinct(LinkEqualityComparer.Instance)) - { - var stack = new Stack>(); - stack.Push(initial); + // Identify final states: appear as After but never as Before + var finalStates = afterLinks.Where(a => !beforeLinks.Contains(a)) + .ToHashSet(LinkEqualityComparer.Instance); - var visited = new HashSet>(LinkEqualityComparer.Instance); + // Build adjacency (Before -> possible list of After links) + var adjacency = new Dictionary, List>>(LinkEqualityComparer.Instance); + foreach (var (before, after) in changedStates) + { + if (!adjacency.TryGetValue(before, out var list)) + { + list = new List>(); + adjacency[before] = list; + } + list.Add(after); + } - while (stack.Count > 0) - { - var current = stack.Pop(); - // Skip if already visited - if (!visited.Add(current)) - { - continue; - } - - bool hasNext = adjacency.TryGetValue(current, out var nextLinks); - bool isFinalOrDeadEnd = finalStates.Contains(current) || !hasNext || nextLinks!.Count == 0; - - // If final or no further transitions, record (initial -> current) - if (isFinalOrDeadEnd) - { - results.Add((initial, current)); - } - - // Otherwise push neighbors - if (hasNext) - { - foreach (var next in nextLinks!) + // If we have no identified initial states, treat it as a no-op scenario: + // just return original transitions. + if (initialStates.Count == 0) { - stack.Push(next); + return changesList; } - } - } - } - - // ***** IMPORTANT: Sort the final results so that - // items appear in ascending order by their After link. - // This ensures tests that expect a specific order pass reliably. - return results - .OrderBy(r => r.After.Index) - .ThenBy(r => r.After.Source) - .ThenBy(r => r.After.Target); - } - /// - /// Removes problematic duplicate before states that lead to simplification issues. - /// This fixes Issue #26 where multiple transformations from the same before state - /// to conflicting after states (including null states) would cause the simplifier to fail. - /// - /// The key insight: If we have multiple transitions from the same before state, - /// and one of them is to a "null" state (0: 0 0), we should prefer the non-null transition - /// as it represents the actual final transformation. - /// - /// The list of changes that may contain problematic duplicate before states - /// A list with problematic duplicates resolved - private static List<(Link Before, Link After)> RemoveDuplicateBeforeStates( - List<(Link Before, Link After)> changes) - { - // Group changes by their before state - var groupedChanges = changes.GroupBy(c => c.Before, LinkEqualityComparer.Instance); + var results = new List<(Link Before, Link After)>(); - var result = new List<(Link Before, Link After)>(); + // Add unchanged states first + results.AddRange(unchangedStates); - foreach (var group in groupedChanges) - { - var changesForThisBefore = group.ToList(); + // Traverse each initial state with DFS + foreach (var initial in initialStates.Distinct(LinkEqualityComparer.Instance)) + { + var stack = new Stack>(); + stack.Push(initial); + + var visited = new HashSet>(LinkEqualityComparer.Instance); + + while (stack.Count > 0) + { + var current = stack.Pop(); + // Skip if already visited + if (!visited.Add(current)) + { + continue; + } + + bool hasNext = adjacency.TryGetValue(current, out var nextLinks); + bool isFinalOrDeadEnd = finalStates.Contains(current) || !hasNext || nextLinks!.Count == 0; + + // If final or no further transitions, record (initial -> current) + if (isFinalOrDeadEnd) + { + results.Add((initial, current)); + } + + // Otherwise push neighbors + if (hasNext) + { + foreach (var next in nextLinks!) + { + stack.Push(next); + } + } + } + } - if (changesForThisBefore.Count == 1) - { - // No duplicates, keep as is - result.AddRange(changesForThisBefore); + // ***** IMPORTANT: Sort the final results so that + // items appear in ascending order by their After link. + // This ensures tests that expect a specific order pass reliably. + return results + .OrderBy(r => r.After.Index) + .ThenBy(r => r.After.Source) + .ThenBy(r => r.After.Target); } - else + + /// + /// Removes problematic duplicate before states that lead to simplification issues. + /// This fixes Issue #26 where multiple transformations from the same before state + /// to conflicting after states (including null states) would cause the simplifier to fail. + /// + /// The key insight: If we have multiple transitions from the same before state, + /// and one of them is to a "null" state (0: 0 0), we should prefer the non-null transition + /// as it represents the actual final transformation. + /// + /// The list of changes that may contain problematic duplicate before states + /// A list with problematic duplicates resolved + private static List<(Link Before, Link After)> RemoveDuplicateBeforeStates( + List<(Link Before, Link After)> changes) { - // Multiple changes from the same before state - // Check if any of them is to a null state (0: 0 0) - var nullTransition = changesForThisBefore.FirstOrDefault(c => - c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0); - var nonNullTransitions = changesForThisBefore.Where(c => - !(c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0)).ToList(); - - if (nullTransition != default && nonNullTransitions.Count > 0) - { - // Issue #26 scenario: We have both null and non-null transitions - // Prefer the non-null transitions as they represent the actual final states - result.AddRange(nonNullTransitions); - } - else - { - // No null transitions involved, this is a legitimate multiple-branch scenario - // Keep all transitions - result.AddRange(changesForThisBefore); - } - } - } + // Group changes by their before state + var groupedChanges = changes.GroupBy(c => c.Before, LinkEqualityComparer.Instance); - return result; - } + var result = new List<(Link Before, Link After)>(); - /// - /// An equality comparer for Link{uint} that checks Index/Source/Target. - /// - private class LinkEqualityComparer : IEqualityComparer> - { - public static readonly LinkEqualityComparer Instance = new LinkEqualityComparer(); + foreach (var group in groupedChanges) + { + var changesForThisBefore = group.ToList(); + + if (changesForThisBefore.Count == 1) + { + // No duplicates, keep as is + result.AddRange(changesForThisBefore); + } + else + { + // Multiple changes from the same before state + // Check if any of them is to a null state (0: 0 0) + var nullTransition = changesForThisBefore.FirstOrDefault(c => + c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0); + var nonNullTransitions = changesForThisBefore.Where(c => + !(c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0)).ToList(); + + if (nullTransition != default && nonNullTransitions.Count > 0) + { + // Issue #26 scenario: We have both null and non-null transitions + // Prefer the non-null transitions as they represent the actual final states + result.AddRange(nonNullTransitions); + } + else + { + // No null transitions involved, this is a legitimate multiple-branch scenario + // Keep all transitions + result.AddRange(changesForThisBefore); + } + } + } - public bool Equals(Link x, Link y) - => x.Index == y.Index && x.Source == y.Source && x.Target == y.Target; + return result; + } - public int GetHashCode(Link obj) - => HashCode.Combine(obj.Index, obj.Source, obj.Target); + /// + /// An equality comparer for Link{uint} that checks Index/Source/Target. + /// + private class LinkEqualityComparer : IEqualityComparer> + { + public static readonly LinkEqualityComparer Instance = new LinkEqualityComparer(); + + public bool Equals(Link x, Link y) + => x.Index == y.Index && x.Source == y.Source && x.Target == y.Target; + + public int GetHashCode(Link obj) + => HashCode.Combine(obj.Index, obj.Source, obj.Target); + } } - } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs b/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs index 1af9d0d..0622e64 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs @@ -4,64 +4,64 @@ namespace Foundation.Data.Doublets.Cli { - public static class LinksExtensions - { - public static void EnsureCreated(this ILinks links, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber { links.EnsureCreated(links.Create, addresses); } - - public static void EnsureCreated(this ILinks links, Func creator, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber + public static class LinksExtensions { - var nonExistentAddresses = new HashSet(); - foreach (var address in addresses) - { - EnsureSupportedInternalReference(links, address); - if (!links.Exists(address)) + public static void EnsureCreated(this ILinks links, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber { links.EnsureCreated(links.Create, addresses); } + + public static void EnsureCreated(this ILinks links, Func creator, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber { - nonExistentAddresses.Add(address); - } - } + var nonExistentAddresses = new HashSet(); + foreach (var address in addresses) + { + EnsureSupportedInternalReference(links, address); + if (!links.Exists(address)) + { + nonExistentAddresses.Add(address); + } + } - if (nonExistentAddresses.Count > 0) - { - var max = nonExistentAddresses.Max()!; - var createdLinks = new List(); - var seenCreatedLinks = new HashSet(); - TLinkAddress createdLink; + if (nonExistentAddresses.Count > 0) + { + var max = nonExistentAddresses.Max()!; + var createdLinks = new List(); + var seenCreatedLinks = new HashSet(); + TLinkAddress createdLink; - do - { - createdLink = creator(); - EnsureSupportedInternalReference(links, createdLink); + do + { + createdLink = creator(); + EnsureSupportedInternalReference(links, createdLink); - if (!seenCreatedLinks.Add(createdLink)) - { - throw new InvalidOperationException($"Link creation returned address {createdLink} more than once before reaching target {max}."); - } + if (!seenCreatedLinks.Add(createdLink)) + { + throw new InvalidOperationException($"Link creation returned address {createdLink} more than once before reaching target {max}."); + } - if (Comparer.Default.Compare(createdLink, max) > 0) - { - throw new InvalidOperationException($"Link creation produced address {createdLink} beyond requested target {max}."); - } + if (Comparer.Default.Compare(createdLink, max) > 0) + { + throw new InvalidOperationException($"Link creation produced address {createdLink} beyond requested target {max}."); + } - createdLinks.Add(createdLink); + createdLinks.Add(createdLink); + } + while (createdLink != max); + + for (var i = 0; i < createdLinks.Count; i++) + { + if (!nonExistentAddresses.Contains(createdLinks[i]) && links.Exists(createdLinks[i])) + { + links.Delete(createdLinks[i]); + } + } + } } - while (createdLink != max); - for (var i = 0; i < createdLinks.Count; i++) + private static void EnsureSupportedInternalReference(ILinks links, TLinkAddress address) where TLinkAddress : IUnsignedNumber { - if (!nonExistentAddresses.Contains(createdLinks[i]) && links.Exists(createdLinks[i])) - { - links.Delete(createdLinks[i]); - } + if (!links.Constants.IsInternalReference(address)) + { + throw new InvalidOperationException($"Cannot ensure unsupported link address {address}. Only non-zero internal references in the supported range can be created."); + } } - } - } - - private static void EnsureSupportedInternalReference(ILinks links, TLinkAddress address) where TLinkAddress : IUnsignedNumber - { - if (!links.Constants.IsInternalReference(address)) - { - throw new InvalidOperationException($"Cannot ensure unsupported link address {address}. Only non-zero internal references in the supported range can be created."); - } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs index 34b3435..e40c3e0 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs @@ -8,343 +8,343 @@ namespace Foundation.Data.Doublets.Cli { - public static class MixedQueryProcessor - { - public class Options + public static class MixedQueryProcessor { - public string? Query { get; set; } - public WriteHandler? ChangesHandler { get; set; } + public class Options + { + public string? Query { get; set; } + public WriteHandler? ChangesHandler { get; set; } - public static implicit operator Options(string query) => new Options { Query = query }; - } + public static implicit operator Options(string query) => new Options { Query = query }; + } - public static void ProcessQuery(ILinks links, Options options) - { - ArgumentNullException.ThrowIfNull(links); - ArgumentNullException.ThrowIfNull(options); + public static void ProcessQuery(ILinks links, Options options) + { + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(options); - var query = options.Query; - var @null = links.Constants.Null; - var any = links.Constants.Any; - if (string.IsNullOrEmpty(query)) - { - return; - } + var query = options.Query; + var @null = links.Constants.Null; + var any = links.Constants.Any; + if (string.IsNullOrEmpty(query)) + { + return; + } - var parser = new Parser(); - var parsedLinks = parser.Parse(query); + var parser = new Parser(); + var parsedLinks = parser.Parse(query); - if (parsedLinks.Count == 0) - { - return; - } + if (parsedLinks.Count == 0) + { + return; + } - var outerLink = parsedLinks[0]; - var outerLinkValues = outerLink.Values; - if (outerLinkValues?.Count < 2) - { - return; - } + var outerLink = parsedLinks[0]; + var outerLinkValues = outerLink.Values; + if (outerLinkValues?.Count < 2) + { + return; + } - var restrictionLink = outerLinkValues![0]; - var substitutionLink = outerLinkValues![1]; + var restrictionLink = outerLinkValues![0]; + var substitutionLink = outerLinkValues![1]; - if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) - { - return; - } - else if ((restrictionLink.Values?.Count > 0) && (substitutionLink.Values?.Count > 0)) - { - // Build dictionaries - var restrictionLinksById = (restrictionLink.Values ?? new List()) - .Where(l => !string.IsNullOrEmpty(l.Id)) - .ToDictionary(l => l.Id!); - if (!string.IsNullOrEmpty(restrictionLink.Id)) - { - restrictionLinksById[restrictionLink.Id!] = restrictionLink; - } + if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) + { + return; + } + else if ((restrictionLink.Values?.Count > 0) && (substitutionLink.Values?.Count > 0)) + { + // Build dictionaries + var restrictionLinksById = (restrictionLink.Values ?? new List()) + .Where(l => !string.IsNullOrEmpty(l.Id)) + .ToDictionary(l => l.Id!); + if (!string.IsNullOrEmpty(restrictionLink.Id)) + { + restrictionLinksById[restrictionLink.Id!] = restrictionLink; + } - var substitutionLinksById = (substitutionLink.Values ?? new List()) - .Where(l => !string.IsNullOrEmpty(l.Id)) - .ToDictionary(l => l.Id!); - if (!string.IsNullOrEmpty(substitutionLink.Id)) - { - substitutionLinksById[substitutionLink.Id!] = substitutionLink; - } + var substitutionLinksById = (substitutionLink.Values ?? new List()) + .Where(l => !string.IsNullOrEmpty(l.Id)) + .ToDictionary(l => l.Id!); + if (!string.IsNullOrEmpty(substitutionLink.Id)) + { + substitutionLinksById[substitutionLink.Id!] = substitutionLink; + } - var allIds = restrictionLinksById.Keys.Union(substitutionLinksById.Keys).ToList(); + var allIds = restrictionLinksById.Keys.Union(substitutionLinksById.Keys).ToList(); - // Collect variable assignments from restriction links - var variableAssignments = new Dictionary(); - foreach (var kv in restrictionLinksById) - { - var lino = kv.Value; - if (lino.Values?.Count == 2 && lino.Id != null) - { - // This means we have something like (2: $var1 $var2) or (2: 1 $var) - // Get the actual DB link to resolve variables - var dbl = ToDoubletLink(links, lino, any); - if (dbl.Index != any && dbl.Index != @null) + // Collect variable assignments from restriction links + var variableAssignments = new Dictionary(); + foreach (var kv in restrictionLinksById) + { + var lino = kv.Value; + if (lino.Values?.Count == 2 && lino.Id != null) + { + // This means we have something like (2: $var1 $var2) or (2: 1 $var) + // Get the actual DB link to resolve variables + var dbl = ToDoubletLink(links, lino, any); + if (dbl.Index != any && dbl.Index != @null) + { + var actual = new DoubletLink(links.GetLink(dbl.Index)); + // actual.Source and actual.Target contain the real numbers + // lino.Values[0].Id and lino.Values[1].Id may contain variables + AssignVariableFromLink(lino.Values[0].Id, actual.Source, variableAssignments, any, @null); + AssignVariableFromLink(lino.Values[1].Id, actual.Target, variableAssignments, any, @null); + } + } + else if (lino.Values?.Count == 2 && lino.Id?.StartsWith("$") != true) + { + // Similar logic for a link without explicit index but with variables + var dbl = ToDoubletLink(links, lino, any); + // If dbl.Index is unknown, we can't directly read from DB by index, but we can still assign known numeric parts + // If source or target is numeric or '*', no assignment needed unless it's variable + AssignVariableFromLink(lino.Values[0].Id, dbl.Source, variableAssignments, any, @null); + AssignVariableFromLink(lino.Values[1].Id, dbl.Target, variableAssignments, any, @null); + } + } + + // Before comparing variables for no-op, let's apply variable substitution to substitution links + // Replace variables in substitutionLinksById with their assigned values if any + foreach (var kv in substitutionLinksById.ToList()) + { + var lino = kv.Value; + if (lino.Values?.Count == 2) + { + var newSourceId = ReplaceVariable(lino.Values[0].Id, variableAssignments); + var newTargetId = ReplaceVariable(lino.Values[1].Id, variableAssignments); + + if (newSourceId != lino.Values[0].Id || newTargetId != lino.Values[1].Id) + { + if (lino.Id != null) + { + lino = new LinoLink(lino.Id, new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); + } + else + { + lino = new LinoLink(new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); + } + substitutionLinksById[kv.Key] = lino; + } + } + } + + // Basic variable no-op check + var variableIds = allIds.Where(id => id.StartsWith("$")).ToArray(); + foreach (var varId in variableIds) + { + if (restrictionLinksById.TryGetValue(varId, out var varRestrictionLink) + && substitutionLinksById.TryGetValue(varId, out var varSubstitutionLink)) + { + if (AreLinksEquivalent(varRestrictionLink, varSubstitutionLink)) + { + // Remove this variable from difference tracking + allIds = allIds.Except([varId]).ToList(); + } + } + } + + // After handling variables, if allIds is empty, it means no changes. + // If we have variables, let's treat this scenario as a read operation. + if (!allIds.Any() && variableIds.Any()) + { + // Perform read operation for each restriction pattern link + foreach (var kv in restrictionLinksById) + { + var restrictionPattern = ToDoubletLink(links, kv.Value, links.Constants.Any); + ReadAll(links, restrictionPattern, options); + } + return; + } + + // If we still have differences, proceed with sets/unsets/updates + foreach (var id in allIds) + { + bool hasRestriction = restrictionLinksById.TryGetValue(id, out var restrictionLinoLink); + bool hasSubstitution = substitutionLinksById.TryGetValue(id, out var substitutionLinoLink); + + if (hasRestriction && hasSubstitution) + { + // Update operation + var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); + var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); + + links.Update(restrictionDoublet, substitutionDoublet, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } + else if (hasRestriction && !hasSubstitution) + { + var queryLink = ToDoubletLink(links, restrictionLinoLink, any); + Unset(links, queryLink, options); + } + else if (!hasRestriction && hasSubstitution) + { + var doubletLink = ToDoubletLink(links, substitutionLinoLink, @null); + Set(links, doubletLink, options); + } + } + + return; + } + else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation + { + foreach (var linkToDelete in restrictionLink.Values ?? []) + { + var queryLink = ToDoubletLink(links, linkToDelete, any); + Unset(links, queryLink, options); + } + return; + } + else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation { - var actual = new DoubletLink(links.GetLink(dbl.Index)); - // actual.Source and actual.Target contain the real numbers - // lino.Values[0].Id and lino.Values[1].Id may contain variables - AssignVariableFromLink(lino.Values[0].Id, actual.Source, variableAssignments, any, @null); - AssignVariableFromLink(lino.Values[1].Id, actual.Target, variableAssignments, any, @null); + foreach (var linkToCreate in substitutionLink.Values ?? []) + { + var doubletLink = ToDoubletLink(links, linkToCreate, @null); + Set(links, doubletLink, options); + } + return; } - } - else if (lino.Values?.Count == 2 && lino.Id?.StartsWith("$") != true) - { - // Similar logic for a link without explicit index but with variables - var dbl = ToDoubletLink(links, lino, any); - // If dbl.Index is unknown, we can't directly read from DB by index, but we can still assign known numeric parts - // If source or target is numeric or '*', no assignment needed unless it's variable - AssignVariableFromLink(lino.Values[0].Id, dbl.Source, variableAssignments, any, @null); - AssignVariableFromLink(lino.Values[1].Id, dbl.Target, variableAssignments, any, @null); - } } - // Before comparing variables for no-op, let's apply variable substitution to substitution links - // Replace variables in substitutionLinksById with their assigned values if any - foreach (var kv in substitutionLinksById.ToList()) + static string ReplaceVariable(string? id, Dictionary variableAssignments) { - var lino = kv.Value; - if (lino.Values?.Count == 2) - { - var newSourceId = ReplaceVariable(lino.Values[0].Id, variableAssignments); - var newTargetId = ReplaceVariable(lino.Values[1].Id, variableAssignments); - - if (newSourceId != lino.Values[0].Id || newTargetId != lino.Values[1].Id) + if (string.IsNullOrEmpty(id)) return id ?? ""; + if (variableAssignments.TryGetValue(id, out var val)) { - if (lino.Id != null) - { - lino = new LinoLink(lino.Id, new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); - } - else - { - lino = new LinoLink(new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); - } - substitutionLinksById[kv.Key] = lino; + return val.ToString(); } - } + return id; } - // Basic variable no-op check - var variableIds = allIds.Where(id => id.StartsWith("$")).ToArray(); - foreach (var varId in variableIds) + static void AssignVariableFromLink(string? varId, uint val, Dictionary variableAssignments, uint any, uint @null) { - if (restrictionLinksById.TryGetValue(varId, out var varRestrictionLink) - && substitutionLinksById.TryGetValue(varId, out var varSubstitutionLink)) - { - if (AreLinksEquivalent(varRestrictionLink, varSubstitutionLink)) + if (!string.IsNullOrEmpty(varId) && varId.StartsWith("$") && val != any && val != @null) { - // Remove this variable from difference tracking - allIds = allIds.Except([varId]).ToList(); + variableAssignments[varId] = val; } - } } - // After handling variables, if allIds is empty, it means no changes. - // If we have variables, let's treat this scenario as a read operation. - if (!allIds.Any() && variableIds.Any()) + static bool AreLinksEquivalent(LinoLink a, LinoLink b) { - // Perform read operation for each restriction pattern link - foreach (var kv in restrictionLinksById) - { - var restrictionPattern = ToDoubletLink(links, kv.Value, links.Constants.Any); - ReadAll(links, restrictionPattern, options); - } - return; + if (a.Id != b.Id) return false; + if (a.Values?.Count != b.Values?.Count) return false; + if (a.Values == null || b.Values == null) return a.Values == b.Values; + for (int i = 0; i < a.Values.Count; i++) + { + var av = a.Values[i]; + var bv = b.Values[i]; + if (av.Id != bv.Id) return false; + } + return true; } - // If we still have differences, proceed with sets/unsets/updates - foreach (var id in allIds) + static void Set(this ILinks links, DoubletLink substitutionLink, Options options) { - bool hasRestriction = restrictionLinksById.TryGetValue(id, out var restrictionLinoLink); - bool hasSubstitution = substitutionLinksById.TryGetValue(id, out var substitutionLinoLink); + var @null = links.Constants.Null; + var any = links.Constants.Any; + if (substitutionLink.Source == any) + { + throw new ArgumentException($"The source of the link {substitutionLink} cannot be any."); + } + if (substitutionLink.Target == any) + { + throw new ArgumentException($"The target of the link {substitutionLink} cannot be any."); + } + if (substitutionLink.Index != @null) + { + // links.EnsureCreated(doubletLink.Index); + LinksExtensions.EnsureCreated(links, substitutionLink.Index); // contain fix + var restrictionDoublet = new DoubletLink(substitutionLink.Index, any, any); + options.ChangesHandler?.Invoke(null, restrictionDoublet); + links.Update(restrictionDoublet, substitutionLink, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } + else + { + // Get or create + var linkIndex = links.SearchOrDefault(substitutionLink.Source, substitutionLink.Target); - if (hasRestriction && hasSubstitution) - { - // Update operation - var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); - var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); + if (linkIndex == default) + { + linkIndex = links.CreateAndUpdate(substitutionLink.Source, substitutionLink.Target, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } + else + { + var existingLink = new DoubletLink(linkIndex, substitutionLink.Source, substitutionLink.Target); + options.ChangesHandler?.Invoke(existingLink, existingLink); + } + } + } - links.Update(restrictionDoublet, substitutionDoublet, (before, after) => + static void ReadAll(this ILinks links, DoubletLink restrictionLink, Options options) + { + links.Each(restrictionLink, link => { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + return options.ChangesHandler?.Invoke(link, link) ?? links.Constants.Continue; }); - } - else if (hasRestriction && !hasSubstitution) - { - var queryLink = ToDoubletLink(links, restrictionLinoLink, any); - Unset(links, queryLink, options); - } - else if (!hasRestriction && hasSubstitution) - { - var doubletLink = ToDoubletLink(links, substitutionLinoLink, @null); - Set(links, doubletLink, options); - } } - return; - } - else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation - { - foreach (var linkToDelete in restrictionLink.Values ?? []) - { - var queryLink = ToDoubletLink(links, linkToDelete, any); - Unset(links, queryLink, options); - } - return; - } - else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation - { - foreach (var linkToCreate in substitutionLink.Values ?? []) + static void Unset(this ILinks links, DoubletLink restrictionLink, Options options) { - var doubletLink = ToDoubletLink(links, linkToCreate, @null); - Set(links, doubletLink, options); + var linksToDelete = links.All(restrictionLink); + foreach (var link in linksToDelete) + { + links.Delete(link, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } } - return; - } - } - static string ReplaceVariable(string? id, Dictionary variableAssignments) - { - if (string.IsNullOrEmpty(id)) return id ?? ""; - if (variableAssignments.TryGetValue(id, out var val)) - { - return val.ToString(); - } - return id; - } - - static void AssignVariableFromLink(string? varId, uint val, Dictionary variableAssignments, uint any, uint @null) - { - if (!string.IsNullOrEmpty(varId) && varId.StartsWith("$") && val != any && val != @null) - { - variableAssignments[varId] = val; - } - } - - static bool AreLinksEquivalent(LinoLink a, LinoLink b) - { - if (a.Id != b.Id) return false; - if (a.Values?.Count != b.Values?.Count) return false; - if (a.Values == null || b.Values == null) return a.Values == b.Values; - for (int i = 0; i < a.Values.Count; i++) - { - var av = a.Values[i]; - var bv = b.Values[i]; - if (av.Id != bv.Id) return false; - } - return true; - } - - static void Set(this ILinks links, DoubletLink substitutionLink, Options options) - { - var @null = links.Constants.Null; - var any = links.Constants.Any; - if (substitutionLink.Source == any) - { - throw new ArgumentException($"The source of the link {substitutionLink} cannot be any."); - } - if (substitutionLink.Target == any) - { - throw new ArgumentException($"The target of the link {substitutionLink} cannot be any."); - } - if (substitutionLink.Index != @null) - { - // links.EnsureCreated(doubletLink.Index); - LinksExtensions.EnsureCreated(links, substitutionLink.Index); // contain fix - var restrictionDoublet = new DoubletLink(substitutionLink.Index, any, any); - options.ChangesHandler?.Invoke(null, restrictionDoublet); - links.Update(restrictionDoublet, substitutionLink, (before, after) => + static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - } - else - { - // Get or create - var linkIndex = links.SearchOrDefault(substitutionLink.Source, substitutionLink.Target); - - if (linkIndex == default) - { - linkIndex = links.CreateAndUpdate(substitutionLink.Source, substitutionLink.Target, (before, after) => - { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - } - else - { - var existingLink = new DoubletLink(linkIndex, substitutionLink.Source, substitutionLink.Target); - options.ChangesHandler?.Invoke(existingLink, existingLink); + uint index = defaultValue; + uint source = defaultValue; + uint target = defaultValue; + TryParseLinkId(linoLink.Id, links.Constants, ref index); + if (linoLink.Values?.Count == 2) + { + var sourceLink = linoLink.Values[0]; + TryParseLinkId(sourceLink.Id, links.Constants, ref source); + var targetLink = linoLink.Values[1]; + TryParseLinkId(targetLink.Id, links.Constants, ref target); + } + return new DoubletLink(index, source, target); } - } - } - - static void ReadAll(this ILinks links, DoubletLink restrictionLink, Options options) - { - links.Each(restrictionLink, link => - { - return options.ChangesHandler?.Invoke(link, link) ?? links.Constants.Continue; - }); - } - static void Unset(this ILinks links, DoubletLink restrictionLink, Options options) - { - var linksToDelete = links.All(restrictionLink); - foreach (var link in linksToDelete) - { - links.Delete(link, (before, after) => + static bool TryParseLinkId(string? id, LinksConstants constants, ref uint parsedValue) { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - } - } - - static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) - { - uint index = defaultValue; - uint source = defaultValue; - uint target = defaultValue; - TryParseLinkId(linoLink.Id, links.Constants, ref index); - if (linoLink.Values?.Count == 2) - { - var sourceLink = linoLink.Values[0]; - TryParseLinkId(sourceLink.Id, links.Constants, ref source); - var targetLink = linoLink.Values[1]; - TryParseLinkId(targetLink.Id, links.Constants, ref target); - } - return new DoubletLink(index, source, target); - } - - static bool TryParseLinkId(string? id, LinksConstants constants, ref uint parsedValue) - { - if (string.IsNullOrEmpty(id)) - { - return false; - } - if (id == "*") - { - parsedValue = constants.Any; - return true; - } - else if (id.EndsWith(":")) - { - var trimmed = id.TrimEnd(':'); - if (uint.TryParse(trimmed, out uint linkId)) - { - parsedValue = linkId; - return true; + if (string.IsNullOrEmpty(id)) + { + return false; + } + if (id == "*") + { + parsedValue = constants.Any; + return true; + } + else if (id.EndsWith(":")) + { + var trimmed = id.TrimEnd(':'); + if (uint.TryParse(trimmed, out uint linkId)) + { + parsedValue = linkId; + return true; + } + } + else if (uint.TryParse(id, out uint linkId)) + { + parsedValue = linkId; + return true; + } + return false; } - } - else if (uint.TryParse(id, out uint linkId)) - { - parsedValue = linkId; - return true; - } - return false; } - } } \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs index a868af5..f5a947d 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs @@ -12,8 +12,8 @@ namespace Foundation.Data.Doublets.Cli; public enum PersistentTransformationKind { - Once, - Always + Once, + Always } public sealed record PersistentTransformation( @@ -22,409 +22,409 @@ public sealed record PersistentTransformation( string Condition, string Substitution) { - public string Query => $"({Condition} {Substitution})"; + public string Query => $"({Condition} {Substitution})"; } public sealed class PersistentTransformationDecorator : LinksDecoratorBase, INamedTypesLinks { - private const string InternalNamePrefix = "__persistent_transformation:"; - - private readonly INamedTypesLinks _namedLinks; - private readonly INamedTypesLinks _triggerLinks; - private readonly bool _trace; - private bool _applyingTriggers; - private bool _suppressTriggers; - - public bool AutoCreateMissingReferences { get; set; } - - public PersistentTransformationDecorator( - INamedTypesLinks links, - INamedTypesLinks triggerLinks, - bool trace = false) - : base(links) - { - _namedLinks = links; - _triggerLinks = triggerLinks; - _trace = trace; - } - - public static string MakeTriggersDatabaseFilename(string databaseFilename) - { - var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); - var directory = Path.GetDirectoryName(databaseFilename); - return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.triggers.links"); - } - - public uint StoreTrigger(PersistentTransformationKind kind, string query) - { - var parsed = PersistentTransformationQuery.Parse(query); - return WithoutTriggerApplication(() => - { - var schema = EnsureSchema(); - var conditionText = EnsureNamedPoint(_triggerLinks, ConditionTextName(parsed.Condition)); - var substitutionText = EnsureNamedPoint(_triggerLinks, SubstitutionTextName(parsed.Substitution)); - var conditionRecord = _triggerLinks.GetOrCreate(schema.Condition, conditionText); - var substitutionRecord = _triggerLinks.GetOrCreate(schema.Substitution, substitutionText); - var payload = _triggerLinks.GetOrCreate(conditionRecord, substitutionRecord); - var triggerType = kind == PersistentTransformationKind.Always ? schema.Always : schema.Once; - var root = _triggerLinks.GetOrCreate(triggerType, payload); - Trace($"Stored {kind} trigger #{root}: {parsed.Query}"); - return root; - }); - } - - public int RemoveTriggers(string query) - { - var parsed = PersistentTransformationQuery.Parse(query); - return WithoutTriggerApplication(() => - { - var matchingTriggers = GetTriggers() - .Where(trigger => trigger.Condition == parsed.Condition && trigger.Substitution == parsed.Substitution) - .ToList(); - - foreach (var trigger in matchingTriggers) - { - DeleteTriggerRoot(trigger.Root); - } - - return matchingTriggers.Count; - }); - } - - public IReadOnlyList GetTriggers() - { - if (!TryGetSchema(out var schema)) - { - return []; - } + private const string InternalNamePrefix = "__persistent_transformation:"; + + private readonly INamedTypesLinks _namedLinks; + private readonly INamedTypesLinks _triggerLinks; + private readonly bool _trace; + private bool _applyingTriggers; + private bool _suppressTriggers; - var linksByIndex = AllLinks(_triggerLinks).ToDictionary(link => link.Index); - var triggers = new List(); + public bool AutoCreateMissingReferences { get; set; } - foreach (var link in linksByIndex.Values.OrderBy(link => link.Index)) + public PersistentTransformationDecorator( + INamedTypesLinks links, + INamedTypesLinks triggerLinks, + bool trace = false) + : base(links) { - var kind = link.Source == schema.Always - ? PersistentTransformationKind.Always - : link.Source == schema.Once - ? PersistentTransformationKind.Once - : (PersistentTransformationKind?)null; - - if (kind is null || !linksByIndex.TryGetValue(link.Target, out var payload)) - { - continue; - } - - if (!linksByIndex.TryGetValue(payload.Source, out var conditionRecord) - || !linksByIndex.TryGetValue(payload.Target, out var substitutionRecord) - || conditionRecord.Source != schema.Condition - || substitutionRecord.Source != schema.Substitution) - { - continue; - } - - var condition = DecodeTextName(_triggerLinks.GetName(conditionRecord.Target), "condition"); - var substitution = DecodeTextName(_triggerLinks.GetName(substitutionRecord.Target), "substitution"); - if (condition is null || substitution is null) - { - continue; - } - - triggers.Add(new PersistentTransformation(link.Index, kind.Value, condition, substitution)); + _namedLinks = links; + _triggerLinks = triggerLinks; + _trace = trace; } - return triggers; - } - - public override uint Create(IList? substitution, WriteHandler? handler) - { - return RunWriteOperation(() => _links.Create(substitution, handler)); - } - - public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) - { - return RunWriteOperation(() => _links.Update(restriction, substitution, handler)); - } - - public override uint Delete(IList? restriction, WriteHandler? handler) - { - return RunWriteOperation(() => _links.Delete(restriction, handler)); - } - - public override uint Each(IList? restriction, ReadHandler? handler) - { - return _links.Each(restriction, handler); - } - - public string? GetName(uint link) - { - return _namedLinks.GetName(link); - } - - public uint SetName(uint link, string name) - { - return _namedLinks.SetName(link, name); - } - - public uint GetByName(string name) - { - return _namedLinks.GetByName(name); - } - - public void RemoveName(uint link) - { - _namedLinks.RemoveName(link); - } - - private uint RunWriteOperation(Func operation) - { - var result = operation(); - ApplyTriggersAfterOperation(); - return result; - } - - private void ApplyTriggersAfterOperation() - { - if (_suppressTriggers || _applyingTriggers) + public static string MakeTriggersDatabaseFilename(string databaseFilename) { - return; + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.triggers.links"); } - var triggers = GetTriggers(); - if (triggers.Count == 0) + public uint StoreTrigger(PersistentTransformationKind kind, string query) { - return; + var parsed = PersistentTransformationQuery.Parse(query); + return WithoutTriggerApplication(() => + { + var schema = EnsureSchema(); + var conditionText = EnsureNamedPoint(_triggerLinks, ConditionTextName(parsed.Condition)); + var substitutionText = EnsureNamedPoint(_triggerLinks, SubstitutionTextName(parsed.Substitution)); + var conditionRecord = _triggerLinks.GetOrCreate(schema.Condition, conditionText); + var substitutionRecord = _triggerLinks.GetOrCreate(schema.Substitution, substitutionText); + var payload = _triggerLinks.GetOrCreate(conditionRecord, substitutionRecord); + var triggerType = kind == PersistentTransformationKind.Always ? schema.Always : schema.Once; + var root = _triggerLinks.GetOrCreate(triggerType, payload); + Trace($"Stored {kind} trigger #{root}: {parsed.Query}"); + return root; + }); } - _applyingTriggers = true; - try + public int RemoveTriggers(string query) { - foreach (var trigger in triggers) - { - var changes = new List<(DoubletLink Before, DoubletLink After)>(); - QueryProcessor.ProcessQuery(this, new QueryProcessor.Options + var parsed = PersistentTransformationQuery.Parse(query); + return WithoutTriggerApplication(() => { - Query = trigger.Query, - Trace = _trace, - AutoCreateMissingReferences = AutoCreateMissingReferences, - ChangesHandler = (before, after) => - { - changes.Add((new DoubletLink(before), new DoubletLink(after))); - return Constants.Continue; - } + var matchingTriggers = GetTriggers() + .Where(trigger => trigger.Condition == parsed.Condition && trigger.Substitution == parsed.Substitution) + .ToList(); + + foreach (var trigger in matchingTriggers) + { + DeleteTriggerRoot(trigger.Root); + } + + return matchingTriggers.Count; }); + } - if (changes.Count > 0 && trigger.Kind == PersistentTransformationKind.Once) + public IReadOnlyList GetTriggers() + { + if (!TryGetSchema(out var schema)) + { + return []; + } + + var linksByIndex = AllLinks(_triggerLinks).ToDictionary(link => link.Index); + var triggers = new List(); + + foreach (var link in linksByIndex.Values.OrderBy(link => link.Index)) { - DeleteTriggerRoot(trigger.Root); + var kind = link.Source == schema.Always + ? PersistentTransformationKind.Always + : link.Source == schema.Once + ? PersistentTransformationKind.Once + : (PersistentTransformationKind?)null; + + if (kind is null || !linksByIndex.TryGetValue(link.Target, out var payload)) + { + continue; + } + + if (!linksByIndex.TryGetValue(payload.Source, out var conditionRecord) + || !linksByIndex.TryGetValue(payload.Target, out var substitutionRecord) + || conditionRecord.Source != schema.Condition + || substitutionRecord.Source != schema.Substitution) + { + continue; + } + + var condition = DecodeTextName(_triggerLinks.GetName(conditionRecord.Target), "condition"); + var substitution = DecodeTextName(_triggerLinks.GetName(substitutionRecord.Target), "substitution"); + if (condition is null || substitution is null) + { + continue; + } + + triggers.Add(new PersistentTransformation(link.Index, kind.Value, condition, substitution)); } - } + + return triggers; } - finally + + public override uint Create(IList? substitution, WriteHandler? handler) { - _applyingTriggers = false; + return RunWriteOperation(() => _links.Create(substitution, handler)); } - } - private T WithoutTriggerApplication(Func action) - { - var previousSuppressTriggers = _suppressTriggers; - _suppressTriggers = true; - try + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) { - return action(); + return RunWriteOperation(() => _links.Update(restriction, substitution, handler)); } - finally + + public override uint Delete(IList? restriction, WriteHandler? handler) { - _suppressTriggers = previousSuppressTriggers; + return RunWriteOperation(() => _links.Delete(restriction, handler)); } - } - - private TriggerSchema EnsureSchema() - { - var type = EnsureNamedPoint(_triggerLinks, "Type"); - var trigger = EnsureNamedPoint(_triggerLinks, "Trigger"); - var once = EnsureNamedPoint(_triggerLinks, "Once"); - var always = EnsureNamedPoint(_triggerLinks, "Always"); - var condition = EnsureNamedPoint(_triggerLinks, "Condition"); - var substitution = EnsureNamedPoint(_triggerLinks, "Substitution"); - - _triggerLinks.GetOrCreate(type, trigger); - _triggerLinks.GetOrCreate(trigger, once); - _triggerLinks.GetOrCreate(trigger, always); - _triggerLinks.GetOrCreate(type, condition); - _triggerLinks.GetOrCreate(type, substitution); - - return new TriggerSchema(type, trigger, once, always, condition, substitution); - } - - private bool TryGetSchema(out TriggerSchema schema) - { - var type = _triggerLinks.GetByName("Type"); - var trigger = _triggerLinks.GetByName("Trigger"); - var once = _triggerLinks.GetByName("Once"); - var always = _triggerLinks.GetByName("Always"); - var condition = _triggerLinks.GetByName("Condition"); - var substitution = _triggerLinks.GetByName("Substitution"); - var @null = _triggerLinks.Constants.Null; - - if (type == @null || trigger == @null || once == @null || always == @null || condition == @null || substitution == @null) + + public override uint Each(IList? restriction, ReadHandler? handler) { - schema = default; - return false; + return _links.Each(restriction, handler); } - schema = new TriggerSchema(type, trigger, once, always, condition, substitution); - return true; - } + public string? GetName(uint link) + { + return _namedLinks.GetName(link); + } - private void DeleteTriggerRoot(uint root) - { - if (!_triggerLinks.Exists(root)) + public uint SetName(uint link, string name) { - return; + return _namedLinks.SetName(link, name); } - var rootLink = new DoubletLink(_triggerLinks.GetLink(root)); - _triggerLinks.Delete(rootLink, null); - Trace($"Deleted trigger #{root}"); - } + public uint GetByName(string name) + { + return _namedLinks.GetByName(name); + } - private static uint EnsureNamedPoint(INamedTypesLinks links, string name) - { - var existing = links.GetByName(name); - if (existing != links.Constants.Null) + public void RemoveName(uint link) { - return existing; + _namedLinks.RemoveName(link); } - var id = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - links.SetName(id, name); - links.Update( - new DoubletLink(id, links.Constants.Null, links.Constants.Null), - new DoubletLink(id, id, id), - null); - return id; - } - - private static List AllLinks(INamedTypesLinks links) - { - var any = links.Constants.Any; - return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); - } - - private static string ConditionTextName(string condition) - { - return $"{InternalNamePrefix}condition:{condition}"; - } - - private static string SubstitutionTextName(string substitution) - { - return $"{InternalNamePrefix}substitution:{substitution}"; - } - - private static string? DecodeTextName(string? name, string part) - { - var prefix = $"{InternalNamePrefix}{part}:"; - return name is not null && name.StartsWith(prefix, StringComparison.Ordinal) - ? name[prefix.Length..] - : null; - } - - private void Trace(string message) - { - if (_trace) + private uint RunWriteOperation(Func operation) { - Console.WriteLine($"[PersistentTransformation] {message}"); + var result = operation(); + ApplyTriggersAfterOperation(); + return result; } - } - private readonly record struct TriggerSchema(uint Type, uint Trigger, uint Once, uint Always, uint Condition, uint Substitution); + private void ApplyTriggersAfterOperation() + { + if (_suppressTriggers || _applyingTriggers) + { + return; + } - private sealed record PersistentTransformationQuery(string Condition, string Substitution) - { - public string Query => $"({Condition} {Substitution})"; + var triggers = GetTriggers(); + if (triggers.Count == 0) + { + return; + } - public static PersistentTransformationQuery Parse(string query) + _applyingTriggers = true; + try + { + foreach (var trigger in triggers) + { + var changes = new List<(DoubletLink Before, DoubletLink After)>(); + QueryProcessor.ProcessQuery(this, new QueryProcessor.Options + { + Query = trigger.Query, + Trace = _trace, + AutoCreateMissingReferences = AutoCreateMissingReferences, + ChangesHandler = (before, after) => + { + changes.Add((new DoubletLink(before), new DoubletLink(after))); + return Constants.Continue; + } + }); + + if (changes.Count > 0 && trigger.Kind == PersistentTransformationKind.Once) + { + DeleteTriggerRoot(trigger.Root); + } + } + } + finally + { + _applyingTriggers = false; + } + } + + private T WithoutTriggerApplication(Func action) { - var parser = new Parser(); - var parsedLinks = parser.Parse(query); - if (parsedLinks.Count == 0) - { - throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); - } - - LinoLink condition; - LinoLink substitution; - var outerLink = parsedLinks[0]; - if (outerLink.Values is { Count: >= 2 } outerValues) - { - condition = outerValues[0]; - substitution = outerValues[1]; - } - else if (parsedLinks.Count >= 2) - { - condition = parsedLinks[0]; - substitution = parsedLinks[1]; - } - else - { - throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); - } - - return new PersistentTransformationQuery(Format(condition), Format(substitution)); + var previousSuppressTriggers = _suppressTriggers; + _suppressTriggers = true; + try + { + return action(); + } + finally + { + _suppressTriggers = previousSuppressTriggers; + } } - private static string Format(LinoLink link) + private TriggerSchema EnsureSchema() { - if (link.Values is null || link.Values.Count == 0) - { - return string.IsNullOrEmpty(link.Id) ? "()" : EscapeReference(link.Id); - } - - var values = string.Join(" ", link.Values.Select(Format)); - if (string.IsNullOrEmpty(link.Id)) - { - return $"({values})"; - } - - return $"({EscapeReference(link.Id)}: {values})"; + var type = EnsureNamedPoint(_triggerLinks, "Type"); + var trigger = EnsureNamedPoint(_triggerLinks, "Trigger"); + var once = EnsureNamedPoint(_triggerLinks, "Once"); + var always = EnsureNamedPoint(_triggerLinks, "Always"); + var condition = EnsureNamedPoint(_triggerLinks, "Condition"); + var substitution = EnsureNamedPoint(_triggerLinks, "Substitution"); + + _triggerLinks.GetOrCreate(type, trigger); + _triggerLinks.GetOrCreate(trigger, once); + _triggerLinks.GetOrCreate(trigger, always); + _triggerLinks.GetOrCreate(type, condition); + _triggerLinks.GetOrCreate(type, substitution); + + return new TriggerSchema(type, trigger, once, always, condition, substitution); } - private static string EscapeReference(string reference) + private bool TryGetSchema(out TriggerSchema schema) { - if (string.IsNullOrWhiteSpace(reference)) - { - return string.Empty; - } - - var hasSingleQuote = reference.Contains('\''); - var hasDoubleQuote = reference.Contains('"'); - var needsQuoting = reference.Contains(':') - || reference.Contains('(') - || reference.Contains(')') - || reference.Contains(' ') - || reference.Contains('\t') - || reference.Contains('\n') - || reference.Contains('\r') - || hasSingleQuote - || hasDoubleQuote; - - if (hasSingleQuote && hasDoubleQuote) - { - return $"'{reference.Replace("'", "\\'")}'"; - } - - if (hasDoubleQuote) - { - return $"'{reference}'"; - } - - if (hasSingleQuote) - { - return $"\"{reference}\""; - } - - return needsQuoting ? $"'{reference}'" : reference; + var type = _triggerLinks.GetByName("Type"); + var trigger = _triggerLinks.GetByName("Trigger"); + var once = _triggerLinks.GetByName("Once"); + var always = _triggerLinks.GetByName("Always"); + var condition = _triggerLinks.GetByName("Condition"); + var substitution = _triggerLinks.GetByName("Substitution"); + var @null = _triggerLinks.Constants.Null; + + if (type == @null || trigger == @null || once == @null || always == @null || condition == @null || substitution == @null) + { + schema = default; + return false; + } + + schema = new TriggerSchema(type, trigger, once, always, condition, substitution); + return true; + } + + private void DeleteTriggerRoot(uint root) + { + if (!_triggerLinks.Exists(root)) + { + return; + } + + var rootLink = new DoubletLink(_triggerLinks.GetLink(root)); + _triggerLinks.Delete(rootLink, null); + Trace($"Deleted trigger #{root}"); + } + + private static uint EnsureNamedPoint(INamedTypesLinks links, string name) + { + var existing = links.GetByName(name); + if (existing != links.Constants.Null) + { + return existing; + } + + var id = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(id, name); + links.Update( + new DoubletLink(id, links.Constants.Null, links.Constants.Null), + new DoubletLink(id, id, id), + null); + return id; + } + + private static List AllLinks(INamedTypesLinks links) + { + var any = links.Constants.Any; + return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); + } + + private static string ConditionTextName(string condition) + { + return $"{InternalNamePrefix}condition:{condition}"; + } + + private static string SubstitutionTextName(string substitution) + { + return $"{InternalNamePrefix}substitution:{substitution}"; + } + + private static string? DecodeTextName(string? name, string part) + { + var prefix = $"{InternalNamePrefix}{part}:"; + return name is not null && name.StartsWith(prefix, StringComparison.Ordinal) + ? name[prefix.Length..] + : null; + } + + private void Trace(string message) + { + if (_trace) + { + Console.WriteLine($"[PersistentTransformation] {message}"); + } + } + + private readonly record struct TriggerSchema(uint Type, uint Trigger, uint Once, uint Always, uint Condition, uint Substitution); + + private sealed record PersistentTransformationQuery(string Condition, string Substitution) + { + public string Query => $"({Condition} {Substitution})"; + + public static PersistentTransformationQuery Parse(string query) + { + var parser = new Parser(); + var parsedLinks = parser.Parse(query); + if (parsedLinks.Count == 0) + { + throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); + } + + LinoLink condition; + LinoLink substitution; + var outerLink = parsedLinks[0]; + if (outerLink.Values is { Count: >= 2 } outerValues) + { + condition = outerValues[0]; + substitution = outerValues[1]; + } + else if (parsedLinks.Count >= 2) + { + condition = parsedLinks[0]; + substitution = parsedLinks[1]; + } + else + { + throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); + } + + return new PersistentTransformationQuery(Format(condition), Format(substitution)); + } + + private static string Format(LinoLink link) + { + if (link.Values is null || link.Values.Count == 0) + { + return string.IsNullOrEmpty(link.Id) ? "()" : EscapeReference(link.Id); + } + + var values = string.Join(" ", link.Values.Select(Format)); + if (string.IsNullOrEmpty(link.Id)) + { + return $"({values})"; + } + + return $"({EscapeReference(link.Id)}: {values})"; + } + + private static string EscapeReference(string reference) + { + if (string.IsNullOrWhiteSpace(reference)) + { + return string.Empty; + } + + var hasSingleQuote = reference.Contains('\''); + var hasDoubleQuote = reference.Contains('"'); + var needsQuoting = reference.Contains(':') + || reference.Contains('(') + || reference.Contains(')') + || reference.Contains(' ') + || reference.Contains('\t') + || reference.Contains('\n') + || reference.Contains('\r') + || hasSingleQuote + || hasDoubleQuote; + + if (hasSingleQuote && hasDoubleQuote) + { + return $"'{reference.Replace("'", "\\'")}'"; + } + + if (hasDoubleQuote) + { + return $"'{reference}'"; + } + + if (hasSingleQuote) + { + return $"\"{reference}\""; + } + + return needsQuoting ? $"'{reference}'" : reference; + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs index e7aaed8..acd4cc3 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs @@ -77,12 +77,14 @@ public void Dispose() public override TLinkAddress Delete(IList? restriction, WriteHandler? handler) { var constants = _links.Constants; - return _links.Delete(restriction, (before, after) => { - if (handler == null) { + return _links.Delete(restriction, (before, after) => + { + if (handler == null) + { return constants.Continue; } return handler(before, after); }); } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs index 5bf8b90..8f8bb08 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -12,9 +12,9 @@ namespace Foundation.Data.Doublets.Cli; /// The kind of write operation recorded by a transition. public enum TransitionKind { - Create, - Update, - Delete + Create, + Update, + Delete } /// @@ -25,8 +25,8 @@ public enum TransitionKind /// public enum CommitMode { - Sync, - Async + Sync, + Async } /// @@ -43,55 +43,55 @@ public enum CommitMode /// public abstract record LogRetentionPolicy { - public sealed record Infinite() : LogRetentionPolicy; - public sealed record Chunked(long ChunkSize, string ArchiveDirectory) : LogRetentionPolicy; - public sealed record Sized(long MaxTransitions) : LogRetentionPolicy; - - public static LogRetentionPolicy Default { get; } = new Infinite(); - - /// - /// Parses a CLI spec: infinite, sized:<n>, or - /// chunked:<n>:<dir>. - /// - public static LogRetentionPolicy Parse(string spec) - { - ArgumentNullException.ThrowIfNull(spec); - var trimmed = spec.Trim(); - if (trimmed.Length == 0 || trimmed.Equals("infinite", StringComparison.OrdinalIgnoreCase)) - { - return new Infinite(); - } - - var lowered = trimmed.ToLowerInvariant(); - if (lowered.StartsWith("sized:", StringComparison.Ordinal)) - { - var rest = trimmed.Substring("sized:".Length); - if (!long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var max) || max < 0) - { - throw new ArgumentException($"Invalid sized retention spec '{spec}'.", nameof(spec)); - } - return new Sized(max); - } - - if (lowered.StartsWith("chunked:", StringComparison.Ordinal)) - { - var rest = trimmed.Substring("chunked:".Length); - var colon = rest.IndexOf(':'); - if (colon <= 0 || colon == rest.Length - 1) - { - throw new ArgumentException($"Invalid chunked retention spec '{spec}'.", nameof(spec)); - } - var sizeText = rest.Substring(0, colon); - var dir = rest.Substring(colon + 1); - if (!long.TryParse(sizeText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chunkSize) || chunkSize <= 0) - { - throw new ArgumentException($"Invalid chunked size in '{spec}'.", nameof(spec)); - } - return new Chunked(chunkSize, dir); - } - - throw new ArgumentException($"Unknown retention spec '{spec}'.", nameof(spec)); - } + public sealed record Infinite() : LogRetentionPolicy; + public sealed record Chunked(long ChunkSize, string ArchiveDirectory) : LogRetentionPolicy; + public sealed record Sized(long MaxTransitions) : LogRetentionPolicy; + + public static LogRetentionPolicy Default { get; } = new Infinite(); + + /// + /// Parses a CLI spec: infinite, sized:<n>, or + /// chunked:<n>:<dir>. + /// + public static LogRetentionPolicy Parse(string spec) + { + ArgumentNullException.ThrowIfNull(spec); + var trimmed = spec.Trim(); + if (trimmed.Length == 0 || trimmed.Equals("infinite", StringComparison.OrdinalIgnoreCase)) + { + return new Infinite(); + } + + var lowered = trimmed.ToLowerInvariant(); + if (lowered.StartsWith("sized:", StringComparison.Ordinal)) + { + var rest = trimmed.Substring("sized:".Length); + if (!long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var max) || max < 0) + { + throw new ArgumentException($"Invalid sized retention spec '{spec}'.", nameof(spec)); + } + return new Sized(max); + } + + if (lowered.StartsWith("chunked:", StringComparison.Ordinal)) + { + var rest = trimmed.Substring("chunked:".Length); + var colon = rest.IndexOf(':'); + if (colon <= 0 || colon == rest.Length - 1) + { + throw new ArgumentException($"Invalid chunked retention spec '{spec}'.", nameof(spec)); + } + var sizeText = rest.Substring(0, colon); + var dir = rest.Substring(colon + 1); + if (!long.TryParse(sizeText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chunkSize) || chunkSize <= 0) + { + throw new ArgumentException($"Invalid chunked size in '{spec}'.", nameof(spec)); + } + return new Chunked(chunkSize, dir); + } + + throw new ArgumentException($"Unknown retention spec '{spec}'.", nameof(spec)); + } } /// @@ -108,83 +108,83 @@ public readonly record struct Transition( DoubletLink Before, DoubletLink After) { - internal const string SchemaVersion = "v1"; - - /// Encodes the transition as a single line stored as the - /// name of one link in the log doublets store. - public string Serialize() - { - return string.Join('|', - SchemaVersion, - TransactionId.ToString("N"), - Sequence.ToString(CultureInfo.InvariantCulture), - Timestamp.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), - ((int)Kind).ToString(CultureInfo.InvariantCulture), - $"{Before.Index},{Before.Source},{Before.Target}", - $"{After.Index},{After.Source},{After.Target}"); - } - - public static bool TryParse(string text, out Transition transition) - { - transition = default; - if (string.IsNullOrWhiteSpace(text)) return false; - var parts = text.Split('|'); - if (parts.Length < 7 || parts[0] != SchemaVersion) return false; - if (!Guid.TryParseExact(parts[1], "N", out var txId)) return false; - if (!long.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) return false; - if (!long.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms)) return false; - if (!int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var kindValue)) return false; - if (!TryParseLink(parts[5], out var before)) return false; - if (!TryParseLink(parts[6], out var after)) return false; - transition = new Transition( - txId, - seq, - DateTimeOffset.FromUnixTimeMilliseconds(ms), - (TransitionKind)kindValue, - before, - after); - return true; - } - - private static bool TryParseLink(string text, out DoubletLink link) - { - link = default; - var parts = text.Split(','); - if (parts.Length != 3) return false; - if (!uint.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) return false; - if (!uint.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var source)) return false; - if (!uint.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var target)) return false; - link = new DoubletLink(index, source, target); - return true; - } + internal const string SchemaVersion = "v1"; + + /// Encodes the transition as a single line stored as the + /// name of one link in the log doublets store. + public string Serialize() + { + return string.Join('|', + SchemaVersion, + TransactionId.ToString("N"), + Sequence.ToString(CultureInfo.InvariantCulture), + Timestamp.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + ((int)Kind).ToString(CultureInfo.InvariantCulture), + $"{Before.Index},{Before.Source},{Before.Target}", + $"{After.Index},{After.Source},{After.Target}"); + } + + public static bool TryParse(string text, out Transition transition) + { + transition = default; + if (string.IsNullOrWhiteSpace(text)) return false; + var parts = text.Split('|'); + if (parts.Length < 7 || parts[0] != SchemaVersion) return false; + if (!Guid.TryParseExact(parts[1], "N", out var txId)) return false; + if (!long.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) return false; + if (!long.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms)) return false; + if (!int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var kindValue)) return false; + if (!TryParseLink(parts[5], out var before)) return false; + if (!TryParseLink(parts[6], out var after)) return false; + transition = new Transition( + txId, + seq, + DateTimeOffset.FromUnixTimeMilliseconds(ms), + (TransitionKind)kindValue, + before, + after); + return true; + } + + private static bool TryParseLink(string text, out DoubletLink link) + { + link = default; + var parts = text.Split(','); + if (parts.Length != 3) return false; + if (!uint.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) return false; + if (!uint.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var source)) return false; + if (!uint.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var target)) return false; + link = new DoubletLink(index, source, target); + return true; + } } /// A live transaction handle. Disposal without commit rolls /// back automatically (R10). public interface ITransaction : IDisposable { - Guid Id { get; } - DateTimeOffset StartedAt { get; } - bool IsCommitted { get; } - bool IsRolledBack { get; } - IReadOnlyList Transitions { get; } - void Commit(); - void Rollback(); - Task CommitAsync(CancellationToken cancellationToken = default); + Guid Id { get; } + DateTimeOffset StartedAt { get; } + bool IsCommitted { get; } + bool IsRolledBack { get; } + IReadOnlyList Transitions { get; } + void Commit(); + void Rollback(); + Task CommitAsync(CancellationToken cancellationToken = default); } /// A links store with transactional semantics layered on top /// of the underlying . public interface ITransactionsLinks : INamedTypesLinks { - ITransaction BeginTransaction(); - Task BeginTransactionAsync(CancellationToken cancellationToken = default); - IReadOnlyList Log { get; } - LogRetentionPolicy RetentionPolicy { get; set; } - CommitMode CommitMode { get; set; } - void Recover(); - long AppliedSequence { get; } - long LastLoggedSequence { get; } + ITransaction BeginTransaction(); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + IReadOnlyList Log { get; } + LogRetentionPolicy RetentionPolicy { get; set; } + CommitMode CommitMode { get; set; } + void Recover(); + long AppliedSequence { get; } + long LastLoggedSequence { get; } } /// @@ -196,732 +196,732 @@ public interface ITransactionsLinks : INamedTypesLinks /// public sealed class TransactionsDecorator : LinksDecoratorBase, ITransactionsLinks, IDisposable { - internal const string CommitMarkerPrefix = "__transactions:commit:"; - internal const string RollbackMarkerPrefix = "__transactions:rollback:"; - internal const string AppliedMarkerPrefix = "__transactions:applied:"; - internal const string TransitionNamePrefix = "__transactions:transition:"; - - private readonly INamedTypesLinks _inner; - private readonly INamedTypesLinks _logStore; - private readonly bool _trace; - private readonly object _lock = new(); - private readonly List _log = new(); - private readonly HashSet _committed = new(); - private readonly HashSet _rolledBack = new(); - private readonly HashSet _applied = new(); - private readonly BlockingCollection> _asyncQueue = new(); - private readonly CancellationTokenSource _backgroundCts = new(); - private readonly Task _backgroundWorker; - private Transaction? _current; - private long _sequenceCounter; - private long _appliedSequence; - private bool _disposed; - private bool _replaying; - private LogRetentionPolicy _retentionPolicy; - private CommitMode _commitMode; - - public TransactionsDecorator( - INamedTypesLinks inner, - INamedTypesLinks logStore, - LogRetentionPolicy? retentionPolicy = null, - CommitMode commitMode = CommitMode.Sync, - bool trace = false) - : base(inner) - { - _inner = inner; - _logStore = logStore; - _retentionPolicy = retentionPolicy ?? LogRetentionPolicy.Default; - _commitMode = commitMode; - _trace = trace; - _backgroundWorker = Task.Run(RunBackgroundWorker); - Recover(); - } - - public CommitMode CommitMode - { - get { lock (_lock) return _commitMode; } - set { lock (_lock) _commitMode = value; } - } - - public LogRetentionPolicy RetentionPolicy - { - get { lock (_lock) return _retentionPolicy; } - set { lock (_lock) _retentionPolicy = value ?? LogRetentionPolicy.Default; } - } - - public IReadOnlyList Log - { - get { lock (_lock) return _log.ToArray(); } - } - - public long AppliedSequence { get { lock (_lock) return _appliedSequence; } } - public long LastLoggedSequence { get { lock (_lock) return _sequenceCounter; } } - - public ITransaction BeginTransaction() - { - lock (_lock) - { - if (_current is not null) - { - throw new InvalidOperationException("Nested transactions are not supported."); - } - _current = new Transaction(this, autoCommit: false); - Trace($"Began transaction {_current.Id:N}."); - return _current; - } - } - - public Task BeginTransactionAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(BeginTransaction()); - } - - // Write API (wraps the user's handler so we observe before/after) ------- - - public override uint Create(IList? substitution, WriteHandler? handler) - { - return RunWrite(TransitionKind.Create, h => _inner.Create(substitution, h), handler); - } - - public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) - { - return RunWrite(TransitionKind.Update, h => _inner.Update(restriction, substitution, h), handler); - } - - public override uint Delete(IList? restriction, WriteHandler? handler) - { - return RunWrite(TransitionKind.Delete, h => _inner.Delete(restriction, h), handler); - } - - private uint RunWrite( - TransitionKind kind, - Func, uint> innerCall, - WriteHandler? userHandler) - { - if (_replaying) - { - return innerCall(userHandler ?? NullHandler); - } - - Transaction transaction; - bool ownsTransaction; - lock (_lock) - { - if (_current is null) - { - _current = new Transaction(this, autoCommit: true); - ownsTransaction = true; - } - else - { - ownsTransaction = false; - } - transaction = _current; - } - - var @continue = _inner.Constants.Continue; - var observed = new Dictionary(); - var observedOrder = new List(); - - WriteHandler wrapped = (before, after) => - { - var beforeLink = before is null ? default(DoubletLink?) : new DoubletLink(before); - var afterLink = after is null ? default(DoubletLink?) : new DoubletLink(after); - var key = beforeLink?.Index ?? afterLink?.Index ?? 0; - if (key != 0) - { - if (!observed.TryGetValue(key, out var state)) - { - observedOrder.Add(key); - state = (beforeLink, afterLink); - } - else - { - state = (state.Before ?? beforeLink, afterLink); - } - observed[key] = state; - } - return userHandler is null ? @continue : userHandler(before, after); - }; + internal const string CommitMarkerPrefix = "__transactions:commit:"; + internal const string RollbackMarkerPrefix = "__transactions:rollback:"; + internal const string AppliedMarkerPrefix = "__transactions:applied:"; + internal const string TransitionNamePrefix = "__transactions:transition:"; - uint result; - try + private readonly INamedTypesLinks _inner; + private readonly INamedTypesLinks _logStore; + private readonly bool _trace; + private readonly object _lock = new(); + private readonly List _log = new(); + private readonly HashSet _committed = new(); + private readonly HashSet _rolledBack = new(); + private readonly HashSet _applied = new(); + private readonly BlockingCollection> _asyncQueue = new(); + private readonly CancellationTokenSource _backgroundCts = new(); + private readonly Task _backgroundWorker; + private Transaction? _current; + private long _sequenceCounter; + private long _appliedSequence; + private bool _disposed; + private bool _replaying; + private LogRetentionPolicy _retentionPolicy; + private CommitMode _commitMode; + + public TransactionsDecorator( + INamedTypesLinks inner, + INamedTypesLinks logStore, + LogRetentionPolicy? retentionPolicy = null, + CommitMode commitMode = CommitMode.Sync, + bool trace = false) + : base(inner) { - result = innerCall(wrapped); + _inner = inner; + _logStore = logStore; + _retentionPolicy = retentionPolicy ?? LogRetentionPolicy.Default; + _commitMode = commitMode; + _trace = trace; + _backgroundWorker = Task.Run(RunBackgroundWorker); + Recover(); } - catch + + public CommitMode CommitMode { - // best-effort: record nothing if the inner store threw before any - // before/after callback fired, and discard the auto transaction. - if (ownsTransaction) - { - lock (_lock) - { - if (_current == transaction) _current = null; - } - } - throw; + get { lock (_lock) return _commitMode; } + set { lock (_lock) _commitMode = value; } } - foreach (var key in observedOrder) + public LogRetentionPolicy RetentionPolicy { - var state = observed[key]; - var before = state.Before ?? default; - var after = state.After ?? default; - RecordTransition(transaction, kind, before, after); + get { lock (_lock) return _retentionPolicy; } + set { lock (_lock) _retentionPolicy = value ?? LogRetentionPolicy.Default; } } - if (ownsTransaction) + public IReadOnlyList Log { - transaction.Commit(); + get { lock (_lock) return _log.ToArray(); } } - return result; - } + public long AppliedSequence { get { lock (_lock) return _appliedSequence; } } + public long LastLoggedSequence { get { lock (_lock) return _sequenceCounter; } } - private static DoubletLink LinkOrEmpty(IList? raw) - { - return raw is null ? default : new DoubletLink(raw); - } - - private static uint NullHandler(IList? before, IList? after) => default; + public ITransaction BeginTransaction() + { + lock (_lock) + { + if (_current is not null) + { + throw new InvalidOperationException("Nested transactions are not supported."); + } + _current = new Transaction(this, autoCommit: false); + Trace($"Began transaction {_current.Id:N}."); + return _current; + } + } - private void RecordTransition(Transaction transaction, TransitionKind kind, DoubletLink before, DoubletLink after) - { - Transition transition; - lock (_lock) + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) { - var sequence = ++_sequenceCounter; - transition = new Transition( - transaction.Id, - sequence, - DateTimeOffset.UtcNow, - kind, - before, - after); - transaction.AddTransition(transition); - _log.Add(transition); - WriteTransitionToLog(transition); - Trace($"Recorded {kind} seq={sequence} tx={transaction.Id:N}: ({before.Index},{before.Source},{before.Target}) -> ({after.Index},{after.Source},{after.Target})."); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(BeginTransaction()); } - } - // INamedTypes forwarding ------------------------------------------------ + // Write API (wraps the user's handler so we observe before/after) ------- - public string? GetName(uint link) => _inner.GetName(link); - public uint SetName(uint link, string name) => _inner.SetName(link, name); - public uint GetByName(string name) => _inner.GetByName(name); - public void RemoveName(uint link) => _inner.RemoveName(link); + public override uint Create(IList? substitution, WriteHandler? handler) + { + return RunWrite(TransitionKind.Create, h => _inner.Create(substitution, h), handler); + } - // Recovery -------------------------------------------------------------- + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) + { + return RunWrite(TransitionKind.Update, h => _inner.Update(restriction, substitution, h), handler); + } - public void Recover() - { - lock (_lock) + public override uint Delete(IList? restriction, WriteHandler? handler) { - _log.Clear(); - _committed.Clear(); - _rolledBack.Clear(); - _applied.Clear(); - _sequenceCounter = 0; - _appliedSequence = 0; + return RunWrite(TransitionKind.Delete, h => _inner.Delete(restriction, h), handler); + } - var any = _logStore.Constants.Any; - var anyLink = new DoubletLink(any, any, any); - foreach (var raw in _logStore.All(anyLink)) - { - var link = new DoubletLink(raw); - var name = _logStore.GetName(link.Index); - if (string.IsNullOrEmpty(name)) continue; + private uint RunWrite( + TransitionKind kind, + Func, uint> innerCall, + WriteHandler? userHandler) + { + if (_replaying) + { + return innerCall(userHandler ?? NullHandler); + } - if (name.StartsWith(TransitionNamePrefix, StringComparison.Ordinal)) + Transaction transaction; + bool ownsTransaction; + lock (_lock) { - var payload = name.Substring(TransitionNamePrefix.Length); - if (Transition.TryParse(payload, out var transition)) - { - InsertOrdered(_log, transition); - if (transition.Sequence > _sequenceCounter) + if (_current is null) { - _sequenceCounter = transition.Sequence; + _current = new Transaction(this, autoCommit: true); + ownsTransaction = true; } - } + else + { + ownsTransaction = false; + } + transaction = _current; } - else if (name.StartsWith(CommitMarkerPrefix, StringComparison.Ordinal)) + + var @continue = _inner.Constants.Continue; + var observed = new Dictionary(); + var observedOrder = new List(); + + WriteHandler wrapped = (before, after) => { - if (Guid.TryParseExact(name.Substring(CommitMarkerPrefix.Length), "N", out var txId)) - { - _committed.Add(txId); - } - } - else if (name.StartsWith(RollbackMarkerPrefix, StringComparison.Ordinal)) + var beforeLink = before is null ? default(DoubletLink?) : new DoubletLink(before); + var afterLink = after is null ? default(DoubletLink?) : new DoubletLink(after); + var key = beforeLink?.Index ?? afterLink?.Index ?? 0; + if (key != 0) + { + if (!observed.TryGetValue(key, out var state)) + { + observedOrder.Add(key); + state = (beforeLink, afterLink); + } + else + { + state = (state.Before ?? beforeLink, afterLink); + } + observed[key] = state; + } + return userHandler is null ? @continue : userHandler(before, after); + }; + + uint result; + try { - if (Guid.TryParseExact(name.Substring(RollbackMarkerPrefix.Length), "N", out var txId)) - { - _rolledBack.Add(txId); - } - } - else if (name.StartsWith(AppliedMarkerPrefix, StringComparison.Ordinal)) + result = innerCall(wrapped); + } + catch { - var rest = name.Substring(AppliedMarkerPrefix.Length); - if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) - { - _applied.Add(seq); - if (seq > _appliedSequence) _appliedSequence = seq; - } + // best-effort: record nothing if the inner store threw before any + // before/after callback fired, and discard the auto transaction. + if (ownsTransaction) + { + lock (_lock) + { + if (_current == transaction) _current = null; + } + } + throw; } - } - _replaying = true; - try - { - // Re-apply committed transitions whose side-effects were lost - // (e.g. async crash before checkpoint). Only those not yet - // recorded as applied are touched. - foreach (var transition in _log) + foreach (var key in observedOrder) { - if (!_committed.Contains(transition.TransactionId)) continue; - if (_applied.Contains(transition.Sequence)) continue; - TryApplyTransition(transition, recordApplied: true); + var state = observed[key]; + var before = state.Before ?? default; + var after = state.After ?? default; + RecordTransition(transaction, kind, before, after); } - // Auto-rollback transitions written but never committed and never - // rolled back: this is the crash-mid-transaction case (R10). - foreach (var transition in _log.OrderByDescending(t => t.Sequence)) + if (ownsTransaction) { - if (_committed.Contains(transition.TransactionId)) continue; - if (_rolledBack.Contains(transition.TransactionId)) continue; - TryRevertTransition(transition); + transaction.Commit(); } - // Mark recovered-but-incomplete transactions as rolled back so - // we don't try to revert them on the next open. - var pendingTxIds = _log - .Where(t => !_committed.Contains(t.TransactionId) && !_rolledBack.Contains(t.TransactionId)) - .Select(t => t.TransactionId) - .Distinct() - .ToList(); - foreach (var txId in pendingTxIds) + return result; + } + + private static DoubletLink LinkOrEmpty(IList? raw) + { + return raw is null ? default : new DoubletLink(raw); + } + + private static uint NullHandler(IList? before, IList? after) => default; + + private void RecordTransition(Transaction transaction, TransitionKind kind, DoubletLink before, DoubletLink after) + { + Transition transition; + lock (_lock) { - _rolledBack.Add(txId); - WriteMarker(RollbackMarkerPrefix + txId.ToString("N")); + var sequence = ++_sequenceCounter; + transition = new Transition( + transaction.Id, + sequence, + DateTimeOffset.UtcNow, + kind, + before, + after); + transaction.AddTransition(transition); + _log.Add(transition); + WriteTransitionToLog(transition); + Trace($"Recorded {kind} seq={sequence} tx={transaction.Id:N}: ({before.Index},{before.Source},{before.Target}) -> ({after.Index},{after.Source},{after.Target})."); } - } - finally - { - _replaying = false; - } } - } - // Disposal -------------------------------------------------------------- + // INamedTypes forwarding ------------------------------------------------ + + public string? GetName(uint link) => _inner.GetName(link); + public uint SetName(uint link, string name) => _inner.SetName(link, name); + public uint GetByName(string name) => _inner.GetByName(name); + public void RemoveName(uint link) => _inner.RemoveName(link); - /// - /// Stops the background worker. The wrapped data store and log store - /// are not disposed here; callers are expected to own those. - /// - public void Dispose() => Shutdown(); + // Recovery -------------------------------------------------------------- - /// - /// Stops the background worker. Kept as a named method for backwards - /// compatibility; delegates to it. - /// - public void Shutdown() - { - if (_disposed) return; - _disposed = true; - try + public void Recover() { - _asyncQueue.CompleteAdding(); - _backgroundCts.Cancel(); - _backgroundWorker.Wait(TimeSpan.FromSeconds(5)); + lock (_lock) + { + _log.Clear(); + _committed.Clear(); + _rolledBack.Clear(); + _applied.Clear(); + _sequenceCounter = 0; + _appliedSequence = 0; + + var any = _logStore.Constants.Any; + var anyLink = new DoubletLink(any, any, any); + foreach (var raw in _logStore.All(anyLink)) + { + var link = new DoubletLink(raw); + var name = _logStore.GetName(link.Index); + if (string.IsNullOrEmpty(name)) continue; + + if (name.StartsWith(TransitionNamePrefix, StringComparison.Ordinal)) + { + var payload = name.Substring(TransitionNamePrefix.Length); + if (Transition.TryParse(payload, out var transition)) + { + InsertOrdered(_log, transition); + if (transition.Sequence > _sequenceCounter) + { + _sequenceCounter = transition.Sequence; + } + } + } + else if (name.StartsWith(CommitMarkerPrefix, StringComparison.Ordinal)) + { + if (Guid.TryParseExact(name.Substring(CommitMarkerPrefix.Length), "N", out var txId)) + { + _committed.Add(txId); + } + } + else if (name.StartsWith(RollbackMarkerPrefix, StringComparison.Ordinal)) + { + if (Guid.TryParseExact(name.Substring(RollbackMarkerPrefix.Length), "N", out var txId)) + { + _rolledBack.Add(txId); + } + } + else if (name.StartsWith(AppliedMarkerPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(AppliedMarkerPrefix.Length); + if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + _applied.Add(seq); + if (seq > _appliedSequence) _appliedSequence = seq; + } + } + } + + _replaying = true; + try + { + // Re-apply committed transitions whose side-effects were lost + // (e.g. async crash before checkpoint). Only those not yet + // recorded as applied are touched. + foreach (var transition in _log) + { + if (!_committed.Contains(transition.TransactionId)) continue; + if (_applied.Contains(transition.Sequence)) continue; + TryApplyTransition(transition, recordApplied: true); + } + + // Auto-rollback transitions written but never committed and never + // rolled back: this is the crash-mid-transaction case (R10). + foreach (var transition in _log.OrderByDescending(t => t.Sequence)) + { + if (_committed.Contains(transition.TransactionId)) continue; + if (_rolledBack.Contains(transition.TransactionId)) continue; + TryRevertTransition(transition); + } + + // Mark recovered-but-incomplete transactions as rolled back so + // we don't try to revert them on the next open. + var pendingTxIds = _log + .Where(t => !_committed.Contains(t.TransactionId) && !_rolledBack.Contains(t.TransactionId)) + .Select(t => t.TransactionId) + .Distinct() + .ToList(); + foreach (var txId in pendingTxIds) + { + _rolledBack.Add(txId); + WriteMarker(RollbackMarkerPrefix + txId.ToString("N")); + } + } + finally + { + _replaying = false; + } + } } - catch + + // Disposal -------------------------------------------------------------- + + /// + /// Stops the background worker. The wrapped data store and log store + /// are not disposed here; callers are expected to own those. + /// + public void Dispose() => Shutdown(); + + /// + /// Stops the background worker. Kept as a named method for backwards + /// compatibility; delegates to it. + /// + public void Shutdown() { - // best-effort shutdown + if (_disposed) return; + _disposed = true; + try + { + _asyncQueue.CompleteAdding(); + _backgroundCts.Cancel(); + _backgroundWorker.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // best-effort shutdown + } + _asyncQueue.Dispose(); + _backgroundCts.Dispose(); } - _asyncQueue.Dispose(); - _backgroundCts.Dispose(); - } - // Commit / rollback paths ----------------------------------------------- + // Commit / rollback paths ----------------------------------------------- - internal void OnCommit(Transaction transaction, bool forceAsync) - { - bool runAsync; - Transition[] transitions; - lock (_lock) + internal void OnCommit(Transaction transaction, bool forceAsync) { - if (transaction.IsCommitted || transaction.IsRolledBack) return; - _committed.Add(transaction.Id); - transaction.MarkCommitted(); - WriteMarker(CommitMarkerPrefix + transaction.Id.ToString("N")); - if (_current == transaction) _current = null; - runAsync = forceAsync || _commitMode == CommitMode.Async; - transitions = transaction.Transitions.ToArray(); - Trace($"Committed tx {transaction.Id:N} (mode={(runAsync ? "async" : "sync")}, transitions={transitions.Length})."); + bool runAsync; + Transition[] transitions; + lock (_lock) + { + if (transaction.IsCommitted || transaction.IsRolledBack) return; + _committed.Add(transaction.Id); + transaction.MarkCommitted(); + WriteMarker(CommitMarkerPrefix + transaction.Id.ToString("N")); + if (_current == transaction) _current = null; + runAsync = forceAsync || _commitMode == CommitMode.Async; + transitions = transaction.Transitions.ToArray(); + Trace($"Committed tx {transaction.Id:N} (mode={(runAsync ? "async" : "sync")}, transitions={transitions.Length})."); + } + + if (runAsync) + { + _asyncQueue.Add(() => Task.Run(() => ApplyTransitionsAsync(transitions))); + } + else + { + lock (_lock) + { + foreach (var transition in transitions) + { + MarkApplied(transition); + } + EnforceRetentionLocked(); + } + } } - if (runAsync) + internal void OnRollback(Transaction transaction) { - _asyncQueue.Add(() => Task.Run(() => ApplyTransitionsAsync(transitions))); + lock (_lock) + { + if (transaction.IsCommitted || transaction.IsRolledBack) return; + transaction.MarkRolledBack(); + _rolledBack.Add(transaction.Id); + _replaying = true; + try + { + foreach (var transition in transaction.Transitions.AsEnumerable().Reverse()) + { + TryRevertTransition(transition); + } + } + finally + { + _replaying = false; + } + WriteMarker(RollbackMarkerPrefix + transaction.Id.ToString("N")); + if (_current == transaction) _current = null; + Trace($"Rolled back tx {transaction.Id:N} ({transaction.Transitions.Count} transitions)."); + EnforceRetentionLocked(); + } } - else + + private void TryRevertTransition(Transition transition) { - lock (_lock) - { - foreach (var transition in transitions) + try { - MarkApplied(transition); - } - EnforceRetentionLocked(); - } - } - } - - internal void OnRollback(Transaction transaction) - { - lock (_lock) - { - if (transaction.IsCommitted || transaction.IsRolledBack) return; - transaction.MarkRolledBack(); - _rolledBack.Add(transaction.Id); - _replaying = true; - try - { - foreach (var transition in transaction.Transitions.AsEnumerable().Reverse()) - { - TryRevertTransition(transition); - } - } - finally - { - _replaying = false; - } - WriteMarker(RollbackMarkerPrefix + transaction.Id.ToString("N")); - if (_current == transaction) _current = null; - Trace($"Rolled back tx {transaction.Id:N} ({transaction.Transitions.Count} transitions)."); - EnforceRetentionLocked(); - } - } - - private void TryRevertTransition(Transition transition) - { - try - { - if (transition.Before.Index == 0) - { - DeleteIfExists(transition.After.Index); - } - else - { - RestoreLink(transition.Before); - } - } - catch (Exception ex) - { - Trace($"Failed to revert transition seq={transition.Sequence}: {ex.Message}"); - } - } - - /// - /// Revert a single transition's side-effect against the data store - /// without writing a new log entry. Intended for use by higher-level - /// decorators (e.g. version control) that need to drive replay/rewind - /// without producing additional transitions. - /// - public void RevertTransition(Transition transition) - { - lock (_lock) - { - _replaying = true; - try - { - TryRevertTransition(transition); - } - finally - { - _replaying = false; - } - } - } - - /// - /// Apply a single transition's side-effect against the data store - /// without writing a new log entry. Intended for use by higher-level - /// decorators (e.g. version control) that need to drive replay/rewind - /// without producing additional transitions. - /// - public void ApplyTransition(Transition transition) - { - lock (_lock) - { - _replaying = true; - try - { - TryApplyTransition(transition, recordApplied: false); - } - finally - { - _replaying = false; - } - } - } - - private void TryApplyTransition(Transition transition, bool recordApplied) - { - try - { - if (transition.After.Index == 0) - { - DeleteIfExists(transition.Before.Index); - } - else - { - RestoreLink(transition.After); - } - - if (recordApplied) - { - MarkApplied(transition); - } - } - catch (Exception ex) - { - Trace($"Failed to apply transition seq={transition.Sequence}: {ex.Message}"); - } - } - - private void MarkApplied(Transition transition) - { - if (_applied.Add(transition.Sequence)) - { - WriteMarker(AppliedMarkerPrefix + transition.Sequence.ToString(CultureInfo.InvariantCulture)); - if (transition.Sequence > _appliedSequence) _appliedSequence = transition.Sequence; - } - } - - private void RestoreLink(DoubletLink link) - { - if (link.Index == 0) return; - if (!_inner.Exists(link.Index)) - { - _inner.EnsureCreated(link.Index); - } - _inner.Update( - new DoubletLink(link.Index, _inner.Constants.Any, _inner.Constants.Any), - new DoubletLink(link.Index, link.Source, link.Target), - null); - } - - private void DeleteIfExists(uint index) - { - if (index != 0 && _inner.Exists(index)) - { - _inner.Delete(new DoubletLink(index, _inner.Constants.Any, _inner.Constants.Any), null); - } - } - - internal void WriteTransitionToLog(Transition transition) - { - var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); - var name = TransitionNamePrefix + transition.Serialize(); - _logStore.SetName(link, name); - } - - internal void WriteMarker(string name) - { - var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); - _logStore.SetName(link, name); - } - - private static void InsertOrdered(List list, Transition transition) - { - var lo = 0; - var hi = list.Count; - while (lo < hi) - { - var mid = (lo + hi) >> 1; - if (list[mid].Sequence < transition.Sequence) lo = mid + 1; else hi = mid; - } - list.Insert(lo, transition); - } - - private void EnforceRetentionLocked() - { - switch (_retentionPolicy) - { - case LogRetentionPolicy.Infinite: - return; - case LogRetentionPolicy.Sized sized: - EnforceSizedLocked(sized.MaxTransitions); - break; - case LogRetentionPolicy.Chunked chunked: - EnforceChunkedLocked(chunked); - break; - } - } - - private void EnforceSizedLocked(long maxTransitions) - { - if (maxTransitions <= 0) return; - while (_log.Count > maxTransitions) + if (transition.Before.Index == 0) + { + DeleteIfExists(transition.After.Index); + } + else + { + RestoreLink(transition.Before); + } + } + catch (Exception ex) + { + Trace($"Failed to revert transition seq={transition.Sequence}: {ex.Message}"); + } + } + + /// + /// Revert a single transition's side-effect against the data store + /// without writing a new log entry. Intended for use by higher-level + /// decorators (e.g. version control) that need to drive replay/rewind + /// without producing additional transitions. + /// + public void RevertTransition(Transition transition) { - var head = _log[0]; - if (!_applied.Contains(head.Sequence)) - { - // R7: never drop an un-applied transition. - TryApplyTransition(head, recordApplied: true); - if (!_applied.Contains(head.Sequence)) break; - } - _log.RemoveAt(0); - Trace($"Dropped applied transition seq={head.Sequence} per sized retention."); + lock (_lock) + { + _replaying = true; + try + { + TryRevertTransition(transition); + } + finally + { + _replaying = false; + } + } } - } - private void EnforceChunkedLocked(LogRetentionPolicy.Chunked chunked) - { - if (chunked.ChunkSize <= 0) return; - if (_log.Count < chunked.ChunkSize) return; + /// + /// Apply a single transition's side-effect against the data store + /// without writing a new log entry. Intended for use by higher-level + /// decorators (e.g. version control) that need to drive replay/rewind + /// without producing additional transitions. + /// + public void ApplyTransition(Transition transition) + { + lock (_lock) + { + _replaying = true; + try + { + TryApplyTransition(transition, recordApplied: false); + } + finally + { + _replaying = false; + } + } + } - var chunk = _log.Take((int)chunked.ChunkSize).ToList(); - foreach (var transition in chunk) + private void TryApplyTransition(Transition transition, bool recordApplied) { - if (!_applied.Contains(transition.Sequence)) - { - TryApplyTransition(transition, recordApplied: true); - if (!_applied.Contains(transition.Sequence)) return; // never drop unapplied - } - } - - try + try + { + if (transition.After.Index == 0) + { + DeleteIfExists(transition.Before.Index); + } + else + { + RestoreLink(transition.After); + } + + if (recordApplied) + { + MarkApplied(transition); + } + } + catch (Exception ex) + { + Trace($"Failed to apply transition seq={transition.Sequence}: {ex.Message}"); + } + } + + private void MarkApplied(Transition transition) { - Directory.CreateDirectory(chunked.ArchiveDirectory); - var fileName = Path.Combine( - chunked.ArchiveDirectory, - $"transitions-chunk-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{Guid.NewGuid():N}.log"); - using (var writer = new StreamWriter(fileName, append: false)) - { - foreach (var t in chunk) writer.WriteLine(t.Serialize()); - } - Trace($"Archived {chunk.Count} transitions to {fileName}."); - } - catch (Exception ex) - { - Trace($"Chunk archive failed: {ex.Message}"); - return; - } - - _log.RemoveRange(0, chunk.Count); - } - - private async Task ApplyTransitionsAsync(IReadOnlyList transitions) - { - foreach (var transition in transitions) - { - try - { - lock (_lock) + if (_applied.Add(transition.Sequence)) { - // Side-effects normally already applied (inner store ran - // them inline). Re-apply only if needed and mark applied. - MarkApplied(transition); + WriteMarker(AppliedMarkerPrefix + transition.Sequence.ToString(CultureInfo.InvariantCulture)); + if (transition.Sequence > _appliedSequence) _appliedSequence = transition.Sequence; } - } - catch - { - // Recovery on next open will resume. - } } - lock (_lock) + private void RestoreLink(DoubletLink link) { - EnforceRetentionLocked(); + if (link.Index == 0) return; + if (!_inner.Exists(link.Index)) + { + _inner.EnsureCreated(link.Index); + } + _inner.Update( + new DoubletLink(link.Index, _inner.Constants.Any, _inner.Constants.Any), + new DoubletLink(link.Index, link.Source, link.Target), + null); } - await Task.CompletedTask; - } - private void RunBackgroundWorker() - { - try + private void DeleteIfExists(uint index) { - foreach (var work in _asyncQueue.GetConsumingEnumerable(_backgroundCts.Token)) - { - try { work().GetAwaiter().GetResult(); } catch { /* ignored */ } - } + if (index != 0 && _inner.Exists(index)) + { + _inner.Delete(new DoubletLink(index, _inner.Constants.Any, _inner.Constants.Any), null); + } } - catch (OperationCanceledException) { /* expected */ } - catch { /* background should never blow up */ } - } - private void Trace(string message) - { - if (_trace) Console.WriteLine($"[Transactions] {message}"); - } + internal void WriteTransitionToLog(Transition transition) + { + var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); + var name = TransitionNamePrefix + transition.Serialize(); + _logStore.SetName(link, name); + } - /// Conventional sidecar filename for the transitions log. - public static string MakeTransitionsDatabaseFilename(string databaseFilename) - { - ArgumentNullException.ThrowIfNull(databaseFilename); - var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); - var directory = Path.GetDirectoryName(databaseFilename); - return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.transitions.links"); - } + internal void WriteMarker(string name) + { + var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); + _logStore.SetName(link, name); + } - // Transaction handle ---------------------------------------------------- + private static void InsertOrdered(List list, Transition transition) + { + var lo = 0; + var hi = list.Count; + while (lo < hi) + { + var mid = (lo + hi) >> 1; + if (list[mid].Sequence < transition.Sequence) lo = mid + 1; else hi = mid; + } + list.Insert(lo, transition); + } - internal sealed class Transaction : ITransaction - { - private readonly TransactionsDecorator _owner; - private readonly List _transitions = new(); - private readonly bool _autoCommit; - private int _state; // 0 = open, 1 = committed, 2 = rolled back + private void EnforceRetentionLocked() + { + switch (_retentionPolicy) + { + case LogRetentionPolicy.Infinite: + return; + case LogRetentionPolicy.Sized sized: + EnforceSizedLocked(sized.MaxTransitions); + break; + case LogRetentionPolicy.Chunked chunked: + EnforceChunkedLocked(chunked); + break; + } + } - public Transaction(TransactionsDecorator owner, bool autoCommit) + private void EnforceSizedLocked(long maxTransitions) { - _owner = owner; - _autoCommit = autoCommit; - Id = Guid.NewGuid(); - StartedAt = DateTimeOffset.UtcNow; + if (maxTransitions <= 0) return; + while (_log.Count > maxTransitions) + { + var head = _log[0]; + if (!_applied.Contains(head.Sequence)) + { + // R7: never drop an un-applied transition. + TryApplyTransition(head, recordApplied: true); + if (!_applied.Contains(head.Sequence)) break; + } + _log.RemoveAt(0); + Trace($"Dropped applied transition seq={head.Sequence} per sized retention."); + } + } + + private void EnforceChunkedLocked(LogRetentionPolicy.Chunked chunked) + { + if (chunked.ChunkSize <= 0) return; + if (_log.Count < chunked.ChunkSize) return; + + var chunk = _log.Take((int)chunked.ChunkSize).ToList(); + foreach (var transition in chunk) + { + if (!_applied.Contains(transition.Sequence)) + { + TryApplyTransition(transition, recordApplied: true); + if (!_applied.Contains(transition.Sequence)) return; // never drop unapplied + } + } + + try + { + Directory.CreateDirectory(chunked.ArchiveDirectory); + var fileName = Path.Combine( + chunked.ArchiveDirectory, + $"transitions-chunk-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{Guid.NewGuid():N}.log"); + using (var writer = new StreamWriter(fileName, append: false)) + { + foreach (var t in chunk) writer.WriteLine(t.Serialize()); + } + Trace($"Archived {chunk.Count} transitions to {fileName}."); + } + catch (Exception ex) + { + Trace($"Chunk archive failed: {ex.Message}"); + return; + } + + _log.RemoveRange(0, chunk.Count); } - public Guid Id { get; } - public DateTimeOffset StartedAt { get; } - public bool IsCommitted => _state == 1; - public bool IsRolledBack => _state == 2; - public IReadOnlyList Transitions => _transitions; + private async Task ApplyTransitionsAsync(IReadOnlyList transitions) + { + foreach (var transition in transitions) + { + try + { + lock (_lock) + { + // Side-effects normally already applied (inner store ran + // them inline). Re-apply only if needed and mark applied. + MarkApplied(transition); + } + } + catch + { + // Recovery on next open will resume. + } + } + + lock (_lock) + { + EnforceRetentionLocked(); + } + await Task.CompletedTask; + } - internal void AddTransition(Transition transition) => _transitions.Add(transition); - internal void MarkCommitted() => _state = 1; - internal void MarkRolledBack() => _state = 2; + private void RunBackgroundWorker() + { + try + { + foreach (var work in _asyncQueue.GetConsumingEnumerable(_backgroundCts.Token)) + { + try { work().GetAwaiter().GetResult(); } catch { /* ignored */ } + } + } + catch (OperationCanceledException) { /* expected */ } + catch { /* background should never blow up */ } + } - public void Commit() => _owner.OnCommit(this, forceAsync: false); + private void Trace(string message) + { + if (_trace) Console.WriteLine($"[Transactions] {message}"); + } - public Task CommitAsync(CancellationToken cancellationToken = default) + /// Conventional sidecar filename for the transitions log. + public static string MakeTransitionsDatabaseFilename(string databaseFilename) { - cancellationToken.ThrowIfCancellationRequested(); - _owner.OnCommit(this, forceAsync: true); - return Task.CompletedTask; + ArgumentNullException.ThrowIfNull(databaseFilename); + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.transitions.links"); } - public void Rollback() => _owner.OnRollback(this); + // Transaction handle ---------------------------------------------------- - public void Dispose() + internal sealed class Transaction : ITransaction { - if (_state == 0) - { - // Per-write auto transactions should not auto-rollback if the - // caller forgot to commit (Commit happens automatically in - // RunWrite); for explicit user transactions, dispose = rollback. - if (_autoCommit) + private readonly TransactionsDecorator _owner; + private readonly List _transitions = new(); + private readonly bool _autoCommit; + private int _state; // 0 = open, 1 = committed, 2 = rolled back + + public Transaction(TransactionsDecorator owner, bool autoCommit) { - _owner.OnCommit(this, forceAsync: false); + _owner = owner; + _autoCommit = autoCommit; + Id = Guid.NewGuid(); + StartedAt = DateTimeOffset.UtcNow; } - else + + public Guid Id { get; } + public DateTimeOffset StartedAt { get; } + public bool IsCommitted => _state == 1; + public bool IsRolledBack => _state == 2; + public IReadOnlyList Transitions => _transitions; + + internal void AddTransition(Transition transition) => _transitions.Add(transition); + internal void MarkCommitted() => _state = 1; + internal void MarkRolledBack() => _state = 2; + + public void Commit() => _owner.OnCommit(this, forceAsync: false); + + public Task CommitAsync(CancellationToken cancellationToken = default) { - _owner.OnRollback(this); + cancellationToken.ThrowIfCancellationRequested(); + _owner.OnCommit(this, forceAsync: true); + return Task.CompletedTask; + } + + public void Rollback() => _owner.OnRollback(this); + + public void Dispose() + { + if (_state == 0) + { + // Per-write auto transactions should not auto-rollback if the + // caller forgot to commit (Commit happens automatically in + // RunWrite); for explicit user transactions, dispose = rollback. + if (_autoCommit) + { + _owner.OnCommit(this, forceAsync: false); + } + else + { + _owner.OnRollback(this); + } + } } - } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs index be43302..147204c 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs @@ -18,17 +18,17 @@ public sealed record BranchInfo(string Name, string? Parent, long ForkSeq, long /// public interface IVersionControlLinks : INamedTypesLinks { - string CurrentBranch { get; } - long CurrentSequence { get; } - ITransaction BeginTransaction(); - Task BeginTransactionAsync(CancellationToken cancellationToken = default); - IReadOnlyList ListBranches(); - IReadOnlyDictionary ListTags(); - void Branch(string name, long? from = null); - void SwitchBranch(string name); - void Checkout(long sequence); - void Tag(string name, long? sequence = null); - bool TryGetTag(string name, out long sequence); + string CurrentBranch { get; } + long CurrentSequence { get; } + ITransaction BeginTransaction(); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + IReadOnlyList ListBranches(); + IReadOnlyDictionary ListTags(); + void Branch(string name, long? from = null); + void SwitchBranch(string name); + void Checkout(long sequence); + void Tag(string name, long? sequence = null); + bool TryGetTag(string name, out long sequence); } /// @@ -40,608 +40,608 @@ public interface IVersionControlLinks : INamedTypesLinks /// public sealed class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks, IDisposable { - /// Default name of the initial branch (analogous to git's main). - public const string DefaultBranchName = "main"; - - internal const string BranchPrefix = "__vc:branch:"; - internal const string TagPrefix = "__vc:tag:"; - internal const string CurrentPrefix = "__vc:current="; - internal const string AppliedPrefix = "__vc:applied="; - internal const string TransitionPrefix = "__vc:trans:"; - - private readonly TransactionsDecorator _transactions; - private readonly INamedTypesLinks _branchesStore; - private readonly object _lock = new(); - private readonly Dictionary _branches = new(StringComparer.Ordinal); - private readonly Dictionary _tags = new(StringComparer.Ordinal); - private readonly Dictionary _transitionBranches = new(); - private readonly Dictionary _branchLinks = new(StringComparer.Ordinal); - private readonly Dictionary _tagLinks = new(StringComparer.Ordinal); - private uint _currentBranchLink; - private uint _appliedLink; - private string _currentBranch = DefaultBranchName; - private long _currentApplied; - private VersionControlTransaction? _activeTransaction; - private readonly bool _trace; - - /// - /// Rolls back and releases the transaction that is still open, if any. - /// The wrapped transactions decorator and branches store are owned by - /// the caller and are deliberately left untouched. - /// - public void Dispose() - { - VersionControlTransaction? active; - lock (_lock) - { - active = _activeTransaction; - _activeTransaction = null; - } - active?.Dispose(); - } - - public VersionControlDecorator( - TransactionsDecorator transactions, - INamedTypesLinks branchesStore, - bool trace = false) - : base(transactions) - { - _transactions = transactions ?? throw new ArgumentNullException(nameof(transactions)); - _branchesStore = branchesStore ?? throw new ArgumentNullException(nameof(branchesStore)); - _trace = trace; - Recover(); - EnsureDefaultBranch(); - } - - public string CurrentBranch { get { lock (_lock) return _currentBranch; } } - public long CurrentSequence { get { lock (_lock) return _currentApplied; } } - - public IReadOnlyList ListBranches() - { - lock (_lock) return _branches.Values.OrderBy(b => b.Name, StringComparer.Ordinal).ToArray(); - } - - public IReadOnlyDictionary ListTags() - { - lock (_lock) return new Dictionary(_tags, StringComparer.Ordinal); - } - - public bool TryGetTag(string name, out long sequence) - { - lock (_lock) return _tags.TryGetValue(name, out sequence); - } - - public ITransaction BeginTransaction() - { - lock (_lock) - { - if (_activeTransaction is not null) - { - throw new InvalidOperationException("Nested version-control transactions are not supported."); - } - - var beforeSequence = _transactions.LastLoggedSequence; - var branchName = _currentBranch; - var inner = _transactions.BeginTransaction(); - _activeTransaction = new VersionControlTransaction(this, inner, branchName, beforeSequence); - return _activeTransaction; - } - } - - public Task BeginTransactionAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(BeginTransaction()); - } - - // -- Write overrides (attribute new transitions to the current branch) -- - - public override uint Create(IList? substitution, WriteHandler? handler) - { - return RunVcWrite(() => _transactions.Create(substitution, handler)); - } - - public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) - { - return RunVcWrite(() => _transactions.Update(restriction, substitution, handler)); - } - - public override uint Delete(IList? restriction, WriteHandler? handler) - { - return RunVcWrite(() => _transactions.Delete(restriction, handler)); - } - - private uint RunVcWrite(Func innerWrite) - { - lock (_lock) - { - var beforeSeq = _transactions.LastLoggedSequence; - var result = innerWrite(); - if (_activeTransaction is null) - { - AttributeNewTransitionsLocked(beforeSeq, _currentBranch); - } - return result; - } - } - - private void AttributeNewTransitionsLocked(long beforeSeq, string branchName) - { - var afterSeq = _transactions.LastLoggedSequence; - if (afterSeq <= beforeSeq) return; - - for (var s = beforeSeq + 1; s <= afterSeq; s++) - { - _transitionBranches[s] = branchName; - WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={branchName}"); - } - if (_branches.TryGetValue(branchName, out var info)) - { - var updated = info with { Head = afterSeq }; - _branches[branchName] = updated; - UpdateBranchLinkLocked(updated); - } - if (string.Equals(_currentBranch, branchName, StringComparison.Ordinal)) - { - _currentApplied = afterSeq; - SetAppliedLocked(afterSeq); - } - } - - // -- Branching --------------------------------------------------------- - - public void Branch(string name, long? from = null) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Branch name must not be empty.", nameof(name)); - } - lock (_lock) - { - EnsureNoOpenTransactionLocked(nameof(Branch)); - if (_branches.ContainsKey(name)) - { - throw new InvalidOperationException($"Branch '{name}' already exists."); - } - var parent = _currentBranch; - var forkSeq = from ?? _currentApplied; - if (forkSeq < 0) - { - throw new ArgumentOutOfRangeException(nameof(from), forkSeq, "Fork point cannot be negative."); - } - if (forkSeq > 0) - { - var path = BuildBranchSeqsLocked(parent); - if (!path.Contains(forkSeq)) + /// Default name of the initial branch (analogous to git's main). + public const string DefaultBranchName = "main"; + + internal const string BranchPrefix = "__vc:branch:"; + internal const string TagPrefix = "__vc:tag:"; + internal const string CurrentPrefix = "__vc:current="; + internal const string AppliedPrefix = "__vc:applied="; + internal const string TransitionPrefix = "__vc:trans:"; + + private readonly TransactionsDecorator _transactions; + private readonly INamedTypesLinks _branchesStore; + private readonly object _lock = new(); + private readonly Dictionary _branches = new(StringComparer.Ordinal); + private readonly Dictionary _tags = new(StringComparer.Ordinal); + private readonly Dictionary _transitionBranches = new(); + private readonly Dictionary _branchLinks = new(StringComparer.Ordinal); + private readonly Dictionary _tagLinks = new(StringComparer.Ordinal); + private uint _currentBranchLink; + private uint _appliedLink; + private string _currentBranch = DefaultBranchName; + private long _currentApplied; + private VersionControlTransaction? _activeTransaction; + private readonly bool _trace; + + /// + /// Rolls back and releases the transaction that is still open, if any. + /// The wrapped transactions decorator and branches store are owned by + /// the caller and are deliberately left untouched. + /// + public void Dispose() + { + VersionControlTransaction? active; + lock (_lock) { - throw new InvalidOperationException($"Fork point {forkSeq} is not reachable on branch '{parent}'."); + active = _activeTransaction; + _activeTransaction = null; } - } - CreateBranchLocked(name, parent, forkSeq, head: forkSeq); - Trace($"Created branch '{name}' from '{parent}' at seq {forkSeq}."); + active?.Dispose(); } - } - public void SwitchBranch(string name) - { - lock (_lock) + public VersionControlDecorator( + TransactionsDecorator transactions, + INamedTypesLinks branchesStore, + bool trace = false) + : base(transactions) { - EnsureNoOpenTransactionLocked(nameof(SwitchBranch)); - if (!_branches.TryGetValue(name, out var target)) - { - throw new InvalidOperationException($"Unknown branch '{name}'."); - } - var targetPath = BuildBranchSeqsLocked(name); - ApplyDiffToLocked(targetPath, newBranch: name); - Trace($"Switched to branch '{name}' at seq {_currentApplied}."); + _transactions = transactions ?? throw new ArgumentNullException(nameof(transactions)); + _branchesStore = branchesStore ?? throw new ArgumentNullException(nameof(branchesStore)); + _trace = trace; + Recover(); + EnsureDefaultBranch(); } - } - public void Checkout(long sequence) - { - lock (_lock) + public string CurrentBranch { get { lock (_lock) return _currentBranch; } } + public long CurrentSequence { get { lock (_lock) return _currentApplied; } } + + public IReadOnlyList ListBranches() { - EnsureNoOpenTransactionLocked(nameof(Checkout)); - if (sequence < 0) - { - throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must be non-negative."); - } - var path = BuildBranchSeqsLocked(_currentBranch); - if (sequence > 0 && !path.Contains(sequence)) - { - throw new InvalidOperationException($"Sequence {sequence} is not reachable on branch '{_currentBranch}'."); - } - ApplyDiffToLocked(path.Where(s => s <= sequence).ToList(), newBranch: _currentBranch); - Trace($"Checked out seq {sequence} on branch '{_currentBranch}'."); - } - } - - public void Tag(string name, long? sequence = null) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Tag name must not be empty.", nameof(name)); - } - lock (_lock) - { - EnsureNoOpenTransactionLocked(nameof(Tag)); - var seq = sequence ?? _currentApplied; - if (seq < 0) - { - throw new ArgumentOutOfRangeException(nameof(sequence), seq, "Tag sequence must be non-negative."); - } - _tags[name] = seq; - UpdateTagLinkLocked(name, seq); - Trace($"Created tag '{name}' at seq {seq}."); - } - } - - // -- Path / diff helpers ---------------------------------------------- - - private void ApplyDiffToLocked(List targetPath, string newBranch) - { - var currentPath = BuildBranchSeqsLocked(_currentBranch) - .Where(s => s <= _currentApplied) - .ToList(); - - var common = 0; - var max = Math.Min(currentPath.Count, targetPath.Count); - while (common < max && currentPath[common] == targetPath[common]) common++; - - for (var i = currentPath.Count - 1; i >= common; i--) - { - var transition = FindTransition(currentPath[i]); - if (transition is not null) - { - _transactions.RevertTransition(transition.Value); - } - } - for (var i = common; i < targetPath.Count; i++) - { - var transition = FindTransition(targetPath[i]); - if (transition is not null) - { - _transactions.ApplyTransition(transition.Value); - } - } - - if (!ReferenceEquals(newBranch, _currentBranch)) - { - _currentBranch = newBranch; - SetCurrentBranchLocked(newBranch); - } - _currentApplied = targetPath.Count == 0 ? 0 : targetPath[^1]; - SetAppliedLocked(_currentApplied); - } - - private void EnsureNoOpenTransactionLocked(string operation) - { - if (_activeTransaction is not null) - { - throw new InvalidOperationException($"{operation} is not allowed while a version-control transaction is open."); + lock (_lock) return _branches.Values.OrderBy(b => b.Name, StringComparer.Ordinal).ToArray(); } - } - - private void CommitVersionTransaction(VersionControlTransaction transaction) - { - lock (_lock) - { - transaction.Inner.Commit(); - if (ReferenceEquals(_activeTransaction, transaction)) - { - _activeTransaction = null; - AttributeNewTransitionsLocked(transaction.BeforeSequence, transaction.BranchName); - } + + public IReadOnlyDictionary ListTags() + { + lock (_lock) return new Dictionary(_tags, StringComparer.Ordinal); } - } - private void RollbackVersionTransaction(VersionControlTransaction transaction) - { - lock (_lock) - { - try - { - transaction.Inner.Rollback(); - } - finally - { - if (ReferenceEquals(_activeTransaction, transaction)) + public bool TryGetTag(string name, out long sequence) + { + lock (_lock) return _tags.TryGetValue(name, out sequence); + } + + public ITransaction BeginTransaction() + { + lock (_lock) { - _activeTransaction = null; + if (_activeTransaction is not null) + { + throw new InvalidOperationException("Nested version-control transactions are not supported."); + } + + var beforeSequence = _transactions.LastLoggedSequence; + var branchName = _currentBranch; + var inner = _transactions.BeginTransaction(); + _activeTransaction = new VersionControlTransaction(this, inner, branchName, beforeSequence); + return _activeTransaction; } - } } - } - private List BuildBranchSeqsLocked(string branchName) - { - return BuildBranchSeqsLocked(branchName, new HashSet(StringComparer.Ordinal)); - } + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(BeginTransaction()); + } + + // -- Write overrides (attribute new transitions to the current branch) -- + + public override uint Create(IList? substitution, WriteHandler? handler) + { + return RunVcWrite(() => _transactions.Create(substitution, handler)); + } - private List BuildBranchSeqsLocked(string branchName, HashSet visited) - { - if (!_branches.TryGetValue(branchName, out var info)) return new List(); - if (!visited.Add(branchName)) return new List(); - var seqs = new List(); - if (info.Parent is not null && _branches.ContainsKey(info.Parent)) + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) { - seqs.AddRange(BuildBranchSeqsLocked(info.Parent, visited).Where(s => s <= info.ForkSeq)); + return RunVcWrite(() => _transactions.Update(restriction, substitution, handler)); } - var own = _transitionBranches - .Where(p => p.Value == branchName && p.Key <= info.Head) - .Select(p => p.Key) - .OrderBy(s => s); - seqs.AddRange(own); - return seqs; - } - private Transition? FindTransition(long sequence) - { - foreach (var t in _transactions.Log) + public override uint Delete(IList? restriction, WriteHandler? handler) { - if (t.Sequence == sequence) return t; + return RunVcWrite(() => _transactions.Delete(restriction, handler)); } - return null; - } - // -- Persistence helpers ---------------------------------------------- + private uint RunVcWrite(Func innerWrite) + { + lock (_lock) + { + var beforeSeq = _transactions.LastLoggedSequence; + var result = innerWrite(); + if (_activeTransaction is null) + { + AttributeNewTransitionsLocked(beforeSeq, _currentBranch); + } + return result; + } + } - private void EnsureDefaultBranch() - { - lock (_lock) + private void AttributeNewTransitionsLocked(long beforeSeq, string branchName) { - var existing = _transactions.LastLoggedSequence; - if (!_branches.ContainsKey(DefaultBranchName)) - { - // Pre-existing transitions are attributed to the default branch. - for (var s = 1L; s <= existing; s++) + var afterSeq = _transactions.LastLoggedSequence; + if (afterSeq <= beforeSeq) return; + + for (var s = beforeSeq + 1; s <= afterSeq; s++) { - if (!_transitionBranches.ContainsKey(s)) - { - _transitionBranches[s] = DefaultBranchName; - WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={DefaultBranchName}"); - } + _transitionBranches[s] = branchName; + WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={branchName}"); } - CreateBranchLocked(DefaultBranchName, parent: null, forkSeq: 0, head: existing); - _currentBranch = DefaultBranchName; - _currentApplied = existing; - SetCurrentBranchLocked(DefaultBranchName); - SetAppliedLocked(existing); - } - else if (_currentBranchLink == 0) - { - SetCurrentBranchLocked(_currentBranch); - } - } - } - - private void CreateBranchLocked(string name, string? parent, long forkSeq, long head) - { - var info = new BranchInfo(name, parent, forkSeq, head); - _branches[name] = info; - UpdateBranchLinkLocked(info); - } - - private void UpdateBranchLinkLocked(BranchInfo info) - { - var nameMarker = EncodeBranchMarker(info); - if (!_branchLinks.TryGetValue(info.Name, out var link)) - { - link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - _branchLinks[info.Name] = link; - } - _branchesStore.SetName(link, nameMarker); - } - - private void UpdateTagLinkLocked(string name, long seq) - { - var nameMarker = $"{TagPrefix}{name}={seq.ToString(CultureInfo.InvariantCulture)}"; - if (!_tagLinks.TryGetValue(name, out var link)) - { - link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - _tagLinks[name] = link; - } - _branchesStore.SetName(link, nameMarker); - } - - private void SetCurrentBranchLocked(string name) - { - _currentBranch = name; - if (_currentBranchLink == 0) - { - _currentBranchLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - } - _branchesStore.SetName(_currentBranchLink, $"{CurrentPrefix}{name}"); - } - - private void SetAppliedLocked(long seq) - { - if (_appliedLink == 0) - { - _appliedLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - } - _branchesStore.SetName(_appliedLink, $"{AppliedPrefix}{seq.ToString(CultureInfo.InvariantCulture)}"); - } - - private void WriteImmutableMarker(string name) - { - var link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - _branchesStore.SetName(link, name); - } - - private static string EncodeBranchMarker(BranchInfo info) - { - var parent = info.Parent ?? string.Empty; - return string.Concat( - BranchPrefix, - info.Name, - ":parent=", parent, - ":fork=", info.ForkSeq.ToString(CultureInfo.InvariantCulture), - ":head=", info.Head.ToString(CultureInfo.InvariantCulture)); - } - - private static bool TryDecodeBranchMarker(string text, out BranchInfo info) - { - info = default!; - if (!text.StartsWith(BranchPrefix, StringComparison.Ordinal)) return false; - var rest = text.Substring(BranchPrefix.Length); - var parentIdx = rest.IndexOf(":parent=", StringComparison.Ordinal); - if (parentIdx < 0) return false; - var name = rest.Substring(0, parentIdx); - rest = rest.Substring(parentIdx + ":parent=".Length); - var forkIdx = rest.IndexOf(":fork=", StringComparison.Ordinal); - if (forkIdx < 0) return false; - var parentText = rest.Substring(0, forkIdx); - rest = rest.Substring(forkIdx + ":fork=".Length); - var headIdx = rest.IndexOf(":head=", StringComparison.Ordinal); - if (headIdx < 0) return false; - var forkText = rest.Substring(0, headIdx); - var headText = rest.Substring(headIdx + ":head=".Length); - if (!long.TryParse(forkText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fork)) return false; - if (!long.TryParse(headText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var head)) return false; - info = new BranchInfo(name, parentText.Length == 0 ? null : parentText, fork, head); - return true; - } - - public void Recover() - { - lock (_lock) - { - _branches.Clear(); - _tags.Clear(); - _transitionBranches.Clear(); - _branchLinks.Clear(); - _tagLinks.Clear(); - _currentBranch = DefaultBranchName; - _currentBranchLink = 0; - _appliedLink = 0; - _currentApplied = 0; - - var any = _branchesStore.Constants.Any; - var anyLink = new DoubletLink(any, any, any); - foreach (var raw in _branchesStore.All(anyLink)) - { - var link = new DoubletLink(raw); - var name = _branchesStore.GetName(link.Index); - if (string.IsNullOrEmpty(name)) continue; - - if (name.StartsWith(BranchPrefix, StringComparison.Ordinal)) + if (_branches.TryGetValue(branchName, out var info)) { - if (TryDecodeBranchMarker(name, out var info)) - { - _branches[info.Name] = info; - _branchLinks[info.Name] = link.Index; - } + var updated = info with { Head = afterSeq }; + _branches[branchName] = updated; + UpdateBranchLinkLocked(updated); } - else if (name.StartsWith(CurrentPrefix, StringComparison.Ordinal)) + if (string.Equals(_currentBranch, branchName, StringComparison.Ordinal)) { - _currentBranch = name.Substring(CurrentPrefix.Length); - _currentBranchLink = link.Index; + _currentApplied = afterSeq; + SetAppliedLocked(afterSeq); } - else if (name.StartsWith(AppliedPrefix, StringComparison.Ordinal)) + } + + // -- Branching --------------------------------------------------------- + + public void Branch(string name, long? from = null) + { + if (string.IsNullOrWhiteSpace(name)) { - var rest = name.Substring(AppliedPrefix.Length); - if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) - { - _currentApplied = seq; - _appliedLink = link.Index; - } + throw new ArgumentException("Branch name must not be empty.", nameof(name)); } - else if (name.StartsWith(TagPrefix, StringComparison.Ordinal)) + lock (_lock) { - var rest = name.Substring(TagPrefix.Length); - var eq = rest.IndexOf('='); - if (eq > 0) - { - var tagName = rest.Substring(0, eq); - if (long.TryParse(rest.Substring(eq + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var tagSeq)) + EnsureNoOpenTransactionLocked(nameof(Branch)); + if (_branches.ContainsKey(name)) { - _tags[tagName] = tagSeq; - _tagLinks[tagName] = link.Index; + throw new InvalidOperationException($"Branch '{name}' already exists."); } - } + var parent = _currentBranch; + var forkSeq = from ?? _currentApplied; + if (forkSeq < 0) + { + throw new ArgumentOutOfRangeException(nameof(from), forkSeq, "Fork point cannot be negative."); + } + if (forkSeq > 0) + { + var path = BuildBranchSeqsLocked(parent); + if (!path.Contains(forkSeq)) + { + throw new InvalidOperationException($"Fork point {forkSeq} is not reachable on branch '{parent}'."); + } + } + CreateBranchLocked(name, parent, forkSeq, head: forkSeq); + Trace($"Created branch '{name}' from '{parent}' at seq {forkSeq}."); } - else if (name.StartsWith(TransitionPrefix, StringComparison.Ordinal)) + } + + public void SwitchBranch(string name) + { + lock (_lock) { - var rest = name.Substring(TransitionPrefix.Length); - var colon = rest.IndexOf(":branch=", StringComparison.Ordinal); - if (colon > 0 && - long.TryParse(rest.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) - { - var branchName = rest.Substring(colon + ":branch=".Length); - _transitionBranches[seq] = branchName; - } + EnsureNoOpenTransactionLocked(nameof(SwitchBranch)); + if (!_branches.TryGetValue(name, out var target)) + { + throw new InvalidOperationException($"Unknown branch '{name}'."); + } + var targetPath = BuildBranchSeqsLocked(name); + ApplyDiffToLocked(targetPath, newBranch: name); + Trace($"Switched to branch '{name}' at seq {_currentApplied}."); } - } } - } - // -- INamedTypes forwarding ------------------------------------------- + public void Checkout(long sequence) + { + lock (_lock) + { + EnsureNoOpenTransactionLocked(nameof(Checkout)); + if (sequence < 0) + { + throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must be non-negative."); + } + var path = BuildBranchSeqsLocked(_currentBranch); + if (sequence > 0 && !path.Contains(sequence)) + { + throw new InvalidOperationException($"Sequence {sequence} is not reachable on branch '{_currentBranch}'."); + } + ApplyDiffToLocked(path.Where(s => s <= sequence).ToList(), newBranch: _currentBranch); + Trace($"Checked out seq {sequence} on branch '{_currentBranch}'."); + } + } - public string? GetName(uint link) => _transactions.GetName(link); - public uint SetName(uint link, string name) => _transactions.SetName(link, name); - public uint GetByName(string name) => _transactions.GetByName(name); - public void RemoveName(uint link) => _transactions.RemoveName(link); + public void Tag(string name, long? sequence = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Tag name must not be empty.", nameof(name)); + } + lock (_lock) + { + EnsureNoOpenTransactionLocked(nameof(Tag)); + var seq = sequence ?? _currentApplied; + if (seq < 0) + { + throw new ArgumentOutOfRangeException(nameof(sequence), seq, "Tag sequence must be non-negative."); + } + _tags[name] = seq; + UpdateTagLinkLocked(name, seq); + Trace($"Created tag '{name}' at seq {seq}."); + } + } - // -- Convenience ------------------------------------------------------ + // -- Path / diff helpers ---------------------------------------------- - /// Conventional sidecar filename for the version-control store. - public static string MakeVersionControlDatabaseFilename(string databaseFilename) - { - ArgumentNullException.ThrowIfNull(databaseFilename); - var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); - var directory = Path.GetDirectoryName(databaseFilename); - return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.versioncontrol.links"); - } + private void ApplyDiffToLocked(List targetPath, string newBranch) + { + var currentPath = BuildBranchSeqsLocked(_currentBranch) + .Where(s => s <= _currentApplied) + .ToList(); - private void Trace(string message) - { - if (_trace) Console.WriteLine($"[VersionControl] {message}"); - } + var common = 0; + var max = Math.Min(currentPath.Count, targetPath.Count); + while (common < max && currentPath[common] == targetPath[common]) common++; - private sealed class VersionControlTransaction : ITransaction - { - private readonly VersionControlDecorator _owner; + for (var i = currentPath.Count - 1; i >= common; i--) + { + var transition = FindTransition(currentPath[i]); + if (transition is not null) + { + _transactions.RevertTransition(transition.Value); + } + } + for (var i = common; i < targetPath.Count; i++) + { + var transition = FindTransition(targetPath[i]); + if (transition is not null) + { + _transactions.ApplyTransition(transition.Value); + } + } - internal VersionControlTransaction( - VersionControlDecorator owner, - ITransaction inner, - string branchName, - long beforeSequence) + if (!ReferenceEquals(newBranch, _currentBranch)) + { + _currentBranch = newBranch; + SetCurrentBranchLocked(newBranch); + } + _currentApplied = targetPath.Count == 0 ? 0 : targetPath[^1]; + SetAppliedLocked(_currentApplied); + } + + private void EnsureNoOpenTransactionLocked(string operation) { - _owner = owner; - Inner = inner; - BranchName = branchName; - BeforeSequence = beforeSequence; + if (_activeTransaction is not null) + { + throw new InvalidOperationException($"{operation} is not allowed while a version-control transaction is open."); + } } - internal ITransaction Inner { get; } - internal string BranchName { get; } - internal long BeforeSequence { get; } + private void CommitVersionTransaction(VersionControlTransaction transaction) + { + lock (_lock) + { + transaction.Inner.Commit(); + if (ReferenceEquals(_activeTransaction, transaction)) + { + _activeTransaction = null; + AttributeNewTransitionsLocked(transaction.BeforeSequence, transaction.BranchName); + } + } + } - public Guid Id => Inner.Id; - public DateTimeOffset StartedAt => Inner.StartedAt; - public bool IsCommitted => Inner.IsCommitted; - public bool IsRolledBack => Inner.IsRolledBack; - public IReadOnlyList Transitions => Inner.Transitions; + private void RollbackVersionTransaction(VersionControlTransaction transaction) + { + lock (_lock) + { + try + { + transaction.Inner.Rollback(); + } + finally + { + if (ReferenceEquals(_activeTransaction, transaction)) + { + _activeTransaction = null; + } + } + } + } - public void Commit() => _owner.CommitVersionTransaction(this); + private List BuildBranchSeqsLocked(string branchName) + { + return BuildBranchSeqsLocked(branchName, new HashSet(StringComparer.Ordinal)); + } - public Task CommitAsync(CancellationToken cancellationToken = default) + private List BuildBranchSeqsLocked(string branchName, HashSet visited) { - cancellationToken.ThrowIfCancellationRequested(); - _owner.CommitVersionTransaction(this); - return Task.CompletedTask; + if (!_branches.TryGetValue(branchName, out var info)) return new List(); + if (!visited.Add(branchName)) return new List(); + var seqs = new List(); + if (info.Parent is not null && _branches.ContainsKey(info.Parent)) + { + seqs.AddRange(BuildBranchSeqsLocked(info.Parent, visited).Where(s => s <= info.ForkSeq)); + } + var own = _transitionBranches + .Where(p => p.Value == branchName && p.Key <= info.Head) + .Select(p => p.Key) + .OrderBy(s => s); + seqs.AddRange(own); + return seqs; } - public void Rollback() => _owner.RollbackVersionTransaction(this); + private Transition? FindTransition(long sequence) + { + foreach (var t in _transactions.Log) + { + if (t.Sequence == sequence) return t; + } + return null; + } - public void Dispose() + // -- Persistence helpers ---------------------------------------------- + + private void EnsureDefaultBranch() + { + lock (_lock) + { + var existing = _transactions.LastLoggedSequence; + if (!_branches.ContainsKey(DefaultBranchName)) + { + // Pre-existing transitions are attributed to the default branch. + for (var s = 1L; s <= existing; s++) + { + if (!_transitionBranches.ContainsKey(s)) + { + _transitionBranches[s] = DefaultBranchName; + WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={DefaultBranchName}"); + } + } + CreateBranchLocked(DefaultBranchName, parent: null, forkSeq: 0, head: existing); + _currentBranch = DefaultBranchName; + _currentApplied = existing; + SetCurrentBranchLocked(DefaultBranchName); + SetAppliedLocked(existing); + } + else if (_currentBranchLink == 0) + { + SetCurrentBranchLocked(_currentBranch); + } + } + } + + private void CreateBranchLocked(string name, string? parent, long forkSeq, long head) + { + var info = new BranchInfo(name, parent, forkSeq, head); + _branches[name] = info; + UpdateBranchLinkLocked(info); + } + + private void UpdateBranchLinkLocked(BranchInfo info) + { + var nameMarker = EncodeBranchMarker(info); + if (!_branchLinks.TryGetValue(info.Name, out var link)) + { + link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _branchLinks[info.Name] = link; + } + _branchesStore.SetName(link, nameMarker); + } + + private void UpdateTagLinkLocked(string name, long seq) + { + var nameMarker = $"{TagPrefix}{name}={seq.ToString(CultureInfo.InvariantCulture)}"; + if (!_tagLinks.TryGetValue(name, out var link)) + { + link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _tagLinks[name] = link; + } + _branchesStore.SetName(link, nameMarker); + } + + private void SetCurrentBranchLocked(string name) + { + _currentBranch = name; + if (_currentBranchLink == 0) + { + _currentBranchLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + } + _branchesStore.SetName(_currentBranchLink, $"{CurrentPrefix}{name}"); + } + + private void SetAppliedLocked(long seq) + { + if (_appliedLink == 0) + { + _appliedLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + } + _branchesStore.SetName(_appliedLink, $"{AppliedPrefix}{seq.ToString(CultureInfo.InvariantCulture)}"); + } + + private void WriteImmutableMarker(string name) + { + var link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _branchesStore.SetName(link, name); + } + + private static string EncodeBranchMarker(BranchInfo info) { - if (!Inner.IsCommitted && !Inner.IsRolledBack) - { - _owner.RollbackVersionTransaction(this); - } + var parent = info.Parent ?? string.Empty; + return string.Concat( + BranchPrefix, + info.Name, + ":parent=", parent, + ":fork=", info.ForkSeq.ToString(CultureInfo.InvariantCulture), + ":head=", info.Head.ToString(CultureInfo.InvariantCulture)); + } + + private static bool TryDecodeBranchMarker(string text, out BranchInfo info) + { + info = default!; + if (!text.StartsWith(BranchPrefix, StringComparison.Ordinal)) return false; + var rest = text.Substring(BranchPrefix.Length); + var parentIdx = rest.IndexOf(":parent=", StringComparison.Ordinal); + if (parentIdx < 0) return false; + var name = rest.Substring(0, parentIdx); + rest = rest.Substring(parentIdx + ":parent=".Length); + var forkIdx = rest.IndexOf(":fork=", StringComparison.Ordinal); + if (forkIdx < 0) return false; + var parentText = rest.Substring(0, forkIdx); + rest = rest.Substring(forkIdx + ":fork=".Length); + var headIdx = rest.IndexOf(":head=", StringComparison.Ordinal); + if (headIdx < 0) return false; + var forkText = rest.Substring(0, headIdx); + var headText = rest.Substring(headIdx + ":head=".Length); + if (!long.TryParse(forkText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fork)) return false; + if (!long.TryParse(headText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var head)) return false; + info = new BranchInfo(name, parentText.Length == 0 ? null : parentText, fork, head); + return true; + } + + public void Recover() + { + lock (_lock) + { + _branches.Clear(); + _tags.Clear(); + _transitionBranches.Clear(); + _branchLinks.Clear(); + _tagLinks.Clear(); + _currentBranch = DefaultBranchName; + _currentBranchLink = 0; + _appliedLink = 0; + _currentApplied = 0; + + var any = _branchesStore.Constants.Any; + var anyLink = new DoubletLink(any, any, any); + foreach (var raw in _branchesStore.All(anyLink)) + { + var link = new DoubletLink(raw); + var name = _branchesStore.GetName(link.Index); + if (string.IsNullOrEmpty(name)) continue; + + if (name.StartsWith(BranchPrefix, StringComparison.Ordinal)) + { + if (TryDecodeBranchMarker(name, out var info)) + { + _branches[info.Name] = info; + _branchLinks[info.Name] = link.Index; + } + } + else if (name.StartsWith(CurrentPrefix, StringComparison.Ordinal)) + { + _currentBranch = name.Substring(CurrentPrefix.Length); + _currentBranchLink = link.Index; + } + else if (name.StartsWith(AppliedPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(AppliedPrefix.Length); + if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + _currentApplied = seq; + _appliedLink = link.Index; + } + } + else if (name.StartsWith(TagPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(TagPrefix.Length); + var eq = rest.IndexOf('='); + if (eq > 0) + { + var tagName = rest.Substring(0, eq); + if (long.TryParse(rest.Substring(eq + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var tagSeq)) + { + _tags[tagName] = tagSeq; + _tagLinks[tagName] = link.Index; + } + } + } + else if (name.StartsWith(TransitionPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(TransitionPrefix.Length); + var colon = rest.IndexOf(":branch=", StringComparison.Ordinal); + if (colon > 0 && + long.TryParse(rest.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + var branchName = rest.Substring(colon + ":branch=".Length); + _transitionBranches[seq] = branchName; + } + } + } + } + } + + // -- INamedTypes forwarding ------------------------------------------- + + public string? GetName(uint link) => _transactions.GetName(link); + public uint SetName(uint link, string name) => _transactions.SetName(link, name); + public uint GetByName(string name) => _transactions.GetByName(name); + public void RemoveName(uint link) => _transactions.RemoveName(link); + + // -- Convenience ------------------------------------------------------ + + /// Conventional sidecar filename for the version-control store. + public static string MakeVersionControlDatabaseFilename(string databaseFilename) + { + ArgumentNullException.ThrowIfNull(databaseFilename); + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.versioncontrol.links"); + } + + private void Trace(string message) + { + if (_trace) Console.WriteLine($"[VersionControl] {message}"); + } + + private sealed class VersionControlTransaction : ITransaction + { + private readonly VersionControlDecorator _owner; + + internal VersionControlTransaction( + VersionControlDecorator owner, + ITransaction inner, + string branchName, + long beforeSequence) + { + _owner = owner; + Inner = inner; + BranchName = branchName; + BeforeSequence = beforeSequence; + } + + internal ITransaction Inner { get; } + internal string BranchName { get; } + internal long BeforeSequence { get; } + + public Guid Id => Inner.Id; + public DateTimeOffset StartedAt => Inner.StartedAt; + public bool IsCommitted => Inner.IsCommitted; + public bool IsRolledBack => Inner.IsRolledBack; + public IReadOnlyList Transitions => Inner.Transitions; + + public void Commit() => _owner.CommitVersionTransaction(this); + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _owner.CommitVersionTransaction(this); + return Task.CompletedTask; + } + + public void Rollback() => _owner.RollbackVersionTransaction(this); + + public void Dispose() + { + if (!Inner.IsCommitted && !Inner.IsRolledBack) + { + _owner.RollbackVersionTransaction(this); + } + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs index 627f7f6..66bb41f 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs @@ -9,1839 +9,1839 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class AdvancedMixedQueryProcessor - { - [Fact] - public void CreateSingleLinkTest() + public class AdvancedMixedQueryProcessor { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } + [Fact] + public void CreateSingleLinkTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } - [Fact] - public void CreateSingleLinkWithIndexTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } + [Fact] + public void CreateSingleLinkWithIndexTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } - [Fact] - public void CreateSingleLinkWithIndexAfterGapTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((2: 2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void CreateSingleLinkWithIndexAfterGapTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((2: 2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void CreateSingleLinkWithIndexAfterDoubleGapTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((3: 3 3)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 3, 3, 3); - }); - } + [Fact] + public void CreateSingleLinkWithIndexAfterDoubleGapTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((3: 3 3)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 3, 3, 3); + }); + } - [Fact] - public void CreateLinkWithSource2Target2Test() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void CreateLinkWithSource2Target2Test() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void CreateMultipleLinksTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void CreateMultipleLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1 1) (2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void Create2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 1, 2); - }); - } + [Fact] + public void Create2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 1, 2); + }); + } - [Fact] - public void Create3LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 1) ((2 2) (3 3)))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(5, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 2, 3); - AssertLinkExists(allLinks, 5, 1, 4); - }); - } + [Fact] + public void Create3LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 1) ((2 2) (3 3)))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(5, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 2, 3); + AssertLinkExists(allLinks, 5, 1, 4); + }); + } - [Fact] - public void Create4LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) (4 4))))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(7, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 3, 4); - AssertLinkExists(allLinks, 6, 2, 5); - AssertLinkExists(allLinks, 7, 1, 6); - }); - } + [Fact] + public void Create4LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) (4 4))))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(7, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 3, 4); + AssertLinkExists(allLinks, 6, 2, 5); + AssertLinkExists(allLinks, 7, 1, 6); + }); + } - [Fact] - public void Create5LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Structure visualization: - // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5) - // ((4 4) (5 5)) => #6: 4->5 - // ((3 3) ((4 4) (5 5))) => #7: 3->6 - // ((2 2) ((3 3) ((4 4) (5 5)))) => #8: 2->7 - // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9: 1->8 - // - // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))" - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(9, allLinks.Count); - - // Leaf links - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 5, 5); - - // ((4 4) (5 5)) => #6:4->5 - AssertLinkExists(allLinks, 6, 4, 5); - - // ((3 3) ((4 4) (5 5))) => #7:3->6 - AssertLinkExists(allLinks, 7, 3, 6); - - // ((2 2) ((3 3) ((4 4) (5 5)))) => #8:2->7 - AssertLinkExists(allLinks, 8, 2, 7); - - // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9:1->8 - AssertLinkExists(allLinks, 9, 1, 8); - }); - } + [Fact] + public void Create5LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Structure visualization: + // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5) + // ((4 4) (5 5)) => #6: 4->5 + // ((3 3) ((4 4) (5 5))) => #7: 3->6 + // ((2 2) ((3 3) ((4 4) (5 5)))) => #8: 2->7 + // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9: 1->8 + // + // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))" + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(9, allLinks.Count); + + // Leaf links + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 5, 5); + + // ((4 4) (5 5)) => #6:4->5 + AssertLinkExists(allLinks, 6, 4, 5); + + // ((3 3) ((4 4) (5 5))) => #7:3->6 + AssertLinkExists(allLinks, 7, 3, 6); + + // ((2 2) ((3 3) ((4 4) (5 5)))) => #8:2->7 + AssertLinkExists(allLinks, 8, 2, 7); + + // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9:1->8 + AssertLinkExists(allLinks, 9, 1, 8); + }); + } - [Fact] - public void Create6LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Structure: - // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6) - // ((5 5) (6 6)) => #7:5->6 - // ((4 4) ((5 5) (6 6))) => #8:4->7 - // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 - // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 - // - // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))" - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(11, allLinks.Count); - - // Leaf links - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 5, 5); - AssertLinkExists(allLinks, 6, 6, 6); - - // ((5 5) (6 6)) => #7:5->6 - AssertLinkExists(allLinks, 7, 5, 6); - - // ((4 4) ((5 5) (6 6))) => #8:4->7 - AssertLinkExists(allLinks, 8, 4, 7); - - // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 - AssertLinkExists(allLinks, 9, 3, 8); - - // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 - AssertLinkExists(allLinks, 10, 2, 9); - - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 - AssertLinkExists(allLinks, 11, 1, 10); - }); - } + [Fact] + public void Create6LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Structure: + // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6) + // ((5 5) (6 6)) => #7:5->6 + // ((4 4) ((5 5) (6 6))) => #8:4->7 + // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 + // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 + // + // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))" + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(11, allLinks.Count); + + // Leaf links + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 5, 5); + AssertLinkExists(allLinks, 6, 6, 6); + + // ((5 5) (6 6)) => #7:5->6 + AssertLinkExists(allLinks, 7, 5, 6); + + // ((4 4) ((5 5) (6 6))) => #8:4->7 + AssertLinkExists(allLinks, 8, 4, 7); + + // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 + AssertLinkExists(allLinks, 9, 3, 8); + + // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 + AssertLinkExists(allLinks, 10, 2, 9); + + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 + AssertLinkExists(allLinks, 11, 1, 10); + }); + } - [Fact] - public void Create7LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6), (7 7) - // ((6 6) (7 7)) => #8:6->7 - // ((5 5) ((6 6) (7 7))) => #9:5->8 - // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 - // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 - // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 - // - // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))" - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(13, allLinks.Count); - - // Leaf links - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 5, 5); - AssertLinkExists(allLinks, 6, 6, 6); - AssertLinkExists(allLinks, 7, 7, 7); - - // ((6 6) (7 7)) => #8:6->7 - AssertLinkExists(allLinks, 8, 6, 7); - - // ((5 5) ((6 6) (7 7))) => #9:5->8 - AssertLinkExists(allLinks, 9, 5, 8); - - // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 - AssertLinkExists(allLinks, 10, 4, 9); - - // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 - AssertLinkExists(allLinks, 11, 3, 10); - - // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 - AssertLinkExists(allLinks, 12, 2, 11); - - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 - AssertLinkExists(allLinks, 13, 1, 12); - }); - } + [Fact] + public void Create7LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6), (7 7) + // ((6 6) (7 7)) => #8:6->7 + // ((5 5) ((6 6) (7 7))) => #9:5->8 + // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 + // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 + // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 + // + // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))" + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(13, allLinks.Count); + + // Leaf links + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 5, 5); + AssertLinkExists(allLinks, 6, 6, 6); + AssertLinkExists(allLinks, 7, 7, 7); + + // ((6 6) (7 7)) => #8:6->7 + AssertLinkExists(allLinks, 8, 6, 7); + + // ((5 5) ((6 6) (7 7))) => #9:5->8 + AssertLinkExists(allLinks, 9, 5, 8); + + // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 + AssertLinkExists(allLinks, 10, 4, 9); + + // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 + AssertLinkExists(allLinks, 11, 3, 10); + + // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 + AssertLinkExists(allLinks, 12, 2, 11); + + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 + AssertLinkExists(allLinks, 13, 1, 12); + }); + } - [Fact] - public void UpdateSingleLinkTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((2 2)))"); - - // Act - ProcessQuery(links, "(((1: 1 1)) ((1: 1 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void UpdateSingleLinkTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((2 2)))"); + + // Act + ProcessQuery(links, "(((1: 1 1)) ((1: 1 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void ExactMatchAndDelete2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Act - ProcessQuery(links, "(((3: (1: 1 1) (2: 2 2))) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void ExactMatchAndDelete2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Act + ProcessQuery(links, "(((3: (1: 1 1) (2: 2 2))) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void MatchWithExactIndexAndDelete2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Act - ProcessQuery(links, "(( (3: (1 *) (* 2)) ) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void MatchWithExactIndexAndDelete2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Act + ProcessQuery(links, "(( (3: (1 *) (* 2)) ) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void MatchAndDelete2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Act - ProcessQuery(links, "(( ((1 *) (* 2)) ) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void MatchAndDelete2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Act + ProcessQuery(links, "(( ((1 *) (* 2)) ) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void NoExactMatch2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQueryStrict(links, "() ((1: 1 1) (2: 2 2))"); - - // Act - ProcessQueryStrict(links, "((1: (1: 1 1) (1: 2 1))) ()"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + [Fact] + public void NoExactMatch2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQueryStrict(links, "() ((1: 1 1) (2: 2 2))"); + + // Act + ProcessQueryStrict(links, "((1: (1: 1 1) (1: 2 1))) ()"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void NoUpdateUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((2 2)))"); - - Options options = new Options(); - - var changes = new List<(DoubletLink, DoubletLink)>(); - options.Query = "((($index: $source $target)) (($index: $source $target)))"; - options.ChangesHandler = (before, after) => - { - changes.Add((new DoubletLink(before), new DoubletLink(after))); - return links.Constants.Continue; - }; - - // Act - ProcessQuery(links, options); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - Assert.Equal(2, changes.Count); - AssertChangeExists(changes, new DoubletLink(1, 1, 1), new DoubletLink(1, 1, 1)); - AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); - }); - } + [Fact] + public void NoUpdateUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((2 2)))"); - [Fact] - public void SwapSourceAndTargetForSingleLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((1 2)))"); - - // Act - ProcessQuery(links, "(((2: $source $target)) ((2: $target $source)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + Options options = new Options(); - [Fact] - public void SwapSourceAndTargetForAllLinksUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange: create initial links (1: 1 2) and (2: 2 1) - ProcessQuery(links, "(() ((1 2) (2 1)))"); - - // Act: swap source and target for all links - ProcessQuery(links, "((($index: $source $target)) (($index: $target $source)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 2, 1); - AssertLinkExists(allLinks, 2, 1, 2); - }); - } + var changes = new List<(DoubletLink, DoubletLink)>(); + options.Query = "((($index: $source $target)) (($index: $source $target)))"; + options.ChangesHandler = (before, after) => + { + changes.Add((new DoubletLink(before), new DoubletLink(after))); + return links.Constants.Continue; + }; + + // Act + ProcessQuery(links, options); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + Assert.Equal(2, changes.Count); + AssertChangeExists(changes, new DoubletLink(1, 1, 1), new DoubletLink(1, 1, 1)); + AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); + }); + } - [Fact] - public void SwapEqualSourceAndTargetUsingVariablesHasAllChangesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "() ((1 1) (2 2))"); - ProcessQuery(links, "((1: 1 1)) ((1: 1 2))"); - - Options options = new Options(); - - var changes = new List<(DoubletLink, DoubletLink)>(); - options.Query = "((($index: $source $target)) (($index: $target $source)))"; - options.ChangesHandler = (before, after) => - { - changes.Add((new DoubletLink(before), new DoubletLink(after))); - return links.Constants.Continue; - }; - - // Act - ProcessQuery(links, options); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 2, 1); - AssertLinkExists(allLinks, 2, 2, 2); - Assert.Equal(2, changes.Count); - AssertChangeExists(changes, new DoubletLink(1, 1, 2), new DoubletLink(1, 2, 1)); - AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); - }); - } + [Fact] + public void SwapSourceAndTargetForSingleLinkUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((1 2)))"); + + // Act + ProcessQuery(links, "(((2: $source $target)) ((2: $target $source)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } - [Fact] - public void MakeAllLinksToGoOutOfFirstLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((2 2) (2 1)))"); - - // Act: make all links to go out of the first link - ProcessQuery(links, "((($index: $source $target)) (($index: 1 $target)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 1, 1); - }); - } + [Fact] + public void SwapSourceAndTargetForAllLinksUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange: create initial links (1: 1 2) and (2: 2 1) + ProcessQuery(links, "(() ((1 2) (2 1)))"); + + // Act: swap source and target for all links + ProcessQuery(links, "((($index: $source $target)) (($index: $target $source)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 2, 1); + AssertLinkExists(allLinks, 2, 1, 2); + }); + } - [Fact] - public void MakeAllLinksToGoIntoFirstLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((2 2) (1 2)))"); - - // Act: make all links to go into the first link - ProcessQuery(links, "((($index: $source $target)) (($index: $source 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 2, 1); - AssertLinkExists(allLinks, 2, 1, 1); - }); - } + [Fact] + public void SwapEqualSourceAndTargetUsingVariablesHasAllChangesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "() ((1 1) (2 2))"); + ProcessQuery(links, "((1: 1 1)) ((1: 1 2))"); - [Fact] - public void MakeAllLinksSelfReferencingUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 1)))"); - - // Act: make all links self-referencing - ProcessQuery(links, "((($index: $source $target)) (($index: $index $index)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + Options options = new Options(); + + var changes = new List<(DoubletLink, DoubletLink)>(); + options.Query = "((($index: $source $target)) (($index: $target $source)))"; + options.ChangesHandler = (before, after) => + { + changes.Add((new DoubletLink(before), new DoubletLink(after))); + return links.Constants.Continue; + }; + + // Act + ProcessQuery(links, options); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 2, 1); + AssertLinkExists(allLinks, 2, 2, 2); + Assert.Equal(2, changes.Count); + AssertChangeExists(changes, new DoubletLink(1, 1, 2), new DoubletLink(1, 2, 1)); + AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); + }); + } + + [Fact] + public void MakeAllLinksToGoOutOfFirstLinkUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((2 2) (2 1)))"); + + // Act: make all links to go out of the first link + ProcessQuery(links, "((($index: $source $target)) (($index: 1 $target)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 1, 1); + }); + } + + [Fact] + public void MakeAllLinksToGoIntoFirstLinkUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((2 2) (1 2)))"); + + // Act: make all links to go into the first link + ProcessQuery(links, "((($index: $source $target)) (($index: $source 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 2, 1); + AssertLinkExists(allLinks, 2, 1, 1); + }); + } + + [Fact] + public void MakeAllLinksSelfReferencingUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 1)))"); + + // Act: make all links self-referencing + ProcessQuery(links, "((($index: $source $target)) (($index: $index $index)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } + + [Fact] + public void MatchSelfReferencingAndMakeThemGoOutFromFirstLinkUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2) (3 1) (4 4)))"); + + // Act: match self-referencing links and make them go out from the first link + ProcessQuery(links, "((($index: $index $index)) (($index: 1 $index)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(4, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 1, 2); + AssertLinkExists(allLinks, 3, 3, 1); + AssertLinkExists(allLinks, 4, 1, 4); + }); + } + + [Fact] + public void MultipleUpdatesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2)))"); + + // Act + ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void MixedMultipleUpdatesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2)))"); + + // Act + ProcessQuery(links, "(((2: 2 2) (1: 1 1)) ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void CreationDuringUpdateTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + + // Act: Add new link with ID '2' by including it only in substitution + ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (2: 2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } + + [Fact] + public void CreationWithEmptySlotDuringUpdateTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + + // Act: Add new link with ID '2' by including it only in substitution + ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (3: 3 3)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 3, 3, 3); + }); + } + + [Fact] + public void DeletionDuringUpdateTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2)))"); + + // Act: Remove link with ID '2' by omitting it in substitution + ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } + + [Fact] + public void DeleteSingleLinkTest_Source1Target2() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2)))"); + ProcessQuery(links, "(() ((2 2)))"); + + // Act + ProcessQuery(links, "(((1 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } + + [Fact] + public void DeleteSingleLinkTest_Source2Target2() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((2 2)))"); + + // Act + ProcessQuery(links, "(((2 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteMultipleLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((1 2) (2 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteLinksByAnyTargetTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((1 *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } + + [Fact] + public void DeleteLinksByAnySourceTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (1 2)))"); + + // Act + ProcessQuery(links, "(((* 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } + + [Fact] + public void DeleteAllLinksBySourceAndTargetTest1() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((* *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void NestedDeleteAllLinksBySourceAndTargetTest1() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "((((* *) (* *))) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteAllLinksBySourceAndTargetTest2() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 1)))"); + + // Act + ProcessQuery(links, "(((* *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteAllLinksByIndexTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((*:)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void CreateNamedFamilyLinksTest() + { + RunTestWithLinks(links => + { + // Prepare query: create (child: father mother) + var query = "(() ((child: father mother)))"; + var options = new Options + { + Query = query, + }; + ProcessQuery(links, options); + + // Assert: links for 'father', 'mother', and 'child' exist and are named + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, fatherId); + Assert.NotEqual(links.Constants.Null, motherId); + Assert.NotEqual(links.Constants.Null, childId); + Assert.Equal("father", links.GetName(fatherId)); + Assert.Equal("mother", links.GetName(motherId)); + Assert.Equal("child", links.GetName(childId)); + + // The child link should have father as source and mother as target + var allLinks = GetAllLinks(links); + var childLink = allLinks.First(l => l.Index == childId); + Assert.Equal(fatherId, childLink.Source); + Assert.Equal(motherId, childLink.Target); + }); + } + + [Fact] + public void CreateTwoNamedLinksTest() + { + RunTestWithLinks(links => + { + Console.WriteLine("[Test] Starting UpdateNamedLinkNameTest"); + + // Create initial link: (child: father mother) + Console.WriteLine("[Test] Step 1: Creating initial link"); + var createOptions = new Options { Query = "(() ((child: father mother)))", Trace = true }; + ProcessQuery(links, createOptions); + Console.WriteLine("[Test] Initial link creation completed"); + + // Verify initial state + Console.WriteLine("[Test] Step 2: Verifying initial state"); + var initialChildId = links.GetByName("child"); + Console.WriteLine($"[Test] Initial child ID: {initialChildId}"); + var initialFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Initial father ID: {initialFatherId}"); + var initialMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Initial mother ID: {initialMotherId}"); + + Assert.NotEqual(links.Constants.Null, initialChildId); + Assert.NotEqual(links.Constants.Null, initialFatherId); + Assert.NotEqual(links.Constants.Null, initialMotherId); + + var initialLinks = GetAllLinks(links); + Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); + var initialChildLink = initialLinks.First(l => l.Index == initialChildId); + Assert.Equal(initialFatherId, initialChildLink.Source); + Assert.Equal(initialMotherId, initialChildLink.Target); + Console.WriteLine("[Test] Initial state verification completed"); + + // Update child link to be named "son" instead + Console.WriteLine("[Test] Step 3: Updating link name"); + // First, let's try to remove the old name + Console.WriteLine("[Test] Removing old name 'child'"); + links.RemoveName(initialChildId); + Console.WriteLine("[Test] Old name removed"); + + // Then create the new link with the new name + Console.WriteLine("[Test] Creating new link with name 'son'"); + var updateOptions = new Options { Query = "(() ((son: father mother)))", Trace = true }; + ProcessQuery(links, updateOptions); + Console.WriteLine("[Test] New link creation completed"); + + // Verify final state + Console.WriteLine("[Test] Step 4: Verifying final state"); + Assert.Equal(links.Constants.Null, links.GetByName("child")); + var finalSonId = links.GetByName("son"); + Console.WriteLine($"[Test] Final son ID: {finalSonId}"); + var finalFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); + var finalMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); + + Assert.NotEqual(links.Constants.Null, finalSonId); + Assert.NotEqual(links.Constants.Null, finalFatherId); + Assert.NotEqual(links.Constants.Null, finalMotherId); + + var finalLinks = GetAllLinks(links); + Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); + var finalSonLink = Assert.Single(finalLinks, l => l.Index == finalSonId); + Assert.Equal(finalFatherId, finalSonLink.Source); + Assert.Equal(finalMotherId, finalSonLink.Target); + Console.WriteLine("[Test] Final state verification completed"); + Console.WriteLine("[Test] UpdateNamedLinkNameTest completed successfully"); + }, enableTracing: true); + } + + [Fact] + public void UpdateNamedLinkNameTest() + { + Console.WriteLine("[Test] ===== Starting UpdateNamedLinkNameTest ====="); + RunTestWithLinks(links => + { + try + { + Console.WriteLine($"[Test] Constants: Null={links.Constants.Null}, Any={links.Constants.Any}, Continue={links.Constants.Continue}"); + // Step 1: Creating initial link + Console.WriteLine("[Test] Step 1: Creating initial link"); + var createQuery = "(() ((child: father mother)))"; + Console.WriteLine($"[Test] Query: {createQuery}"); + + var createOptions = new Options + { + Query = createQuery, + Trace = true + }; + ProcessQuery(links, createOptions); + Console.WriteLine("[Test] Initial link creation completed"); + + // Step 2: Verify initial state + Console.WriteLine("[Test] Step 2: Verifying initial state"); + var childId = links.GetByName("child"); + Console.WriteLine($"[Test] Initial child ID: {childId}"); + var fatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Initial father ID: {fatherId}"); + var motherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Initial mother ID: {motherId}"); + + var initialLinks = links.All().ToList(); + Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); + foreach (var link in initialLinks) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Initial link: Index={link}, Source={source}, Target={target}"); + } + Console.WriteLine("[Test] Initial state verification completed"); + + // Step 3: Update link name + Console.WriteLine("[Test] Step 3: Updating link name from 'child' to 'son'"); + var updateQuery = "(((child: father mother)) ((son: father mother)))"; + Console.WriteLine($"[Test] Query: {updateQuery}"); + + // Log state before update + Console.WriteLine("[Test] Current state before update:"); + Console.WriteLine($"[Test] - child name exists: {links.GetByName("child") != 0}"); + Console.WriteLine($"[Test] - son name exists: {links.GetByName("son") != 0}"); + Console.WriteLine($"[Test] - father name exists: {links.GetByName("father") != 0}"); + Console.WriteLine($"[Test] - mother name exists: {links.GetByName("mother") != 0}"); + + Console.WriteLine("[Test] Starting ProcessQuery for update..."); + Console.WriteLine("[Test] Current links before update:"); + foreach (var link in links.All()) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); + } + + // Add detailed tracing for the update operation + var updateOptions = new Options + { + Query = updateQuery, + Trace = true, + ChangesHandler = (before, after) => + { + Console.WriteLine($"[Test] Update ChangesHandler called:"); + Console.WriteLine($"[Test] - Before state: {before}"); + Console.WriteLine($"[Test] - After state: {after}"); + + // Log name states during change + Console.WriteLine($"[Test] - child name during change: {links.GetByName("child")}"); + Console.WriteLine($"[Test] - son name during change: {links.GetByName("son")}"); + Console.WriteLine($"[Test] - father name during change: {links.GetByName("father")}"); + Console.WriteLine($"[Test] - mother name during change: {links.GetByName("mother")}"); + + // Log all links during change + Console.WriteLine("[Test] - All links during change:"); + foreach (var link in links.All()) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); + } + + // Add detailed tracing for link creation + if (after != null && before == null) + { + var afterLink = new DoubletLink(after); + var source = links.GetSource(after); + var target = links.GetTarget(after); + Console.WriteLine($"[Test] Creating new link: Index={afterLink.Index}, Source={source}, Target={target}"); + Console.WriteLine($"[Test] Checking if link exists: {links.Exists>(afterLink.Index)}"); + Console.WriteLine($"[Test] Checking if source exists: {links.Exists>(source)}"); + Console.WriteLine($"[Test] Checking if target exists: {links.Exists>(target)}"); + + // Log all names before creation + Console.WriteLine("[Test] Names before creation:"); + foreach (var name in new[] { "child", "son", "father", "mother" }) + { + var id = links.GetByName(name); + Console.WriteLine($"[Test] - {name}: {id}"); + } + } + + return links.Constants.Continue; + } + }; + + ProcessQuery(links, updateOptions); + Console.WriteLine("[Test] Update operation completed"); + + // Step 4: Verify final state + Console.WriteLine("[Test] Step 4: Verifying final state"); + var finalChildId = links.GetByName("child"); + Console.WriteLine($"[Test] Final child ID: {finalChildId}"); + var finalSonId = links.GetByName("son"); + Console.WriteLine($"[Test] Final son ID: {finalSonId}"); + var finalFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); + var finalMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); + + var finalLinks = links.All().ToList(); + Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); + foreach (var link in finalLinks) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Final link: Index={link}, Source={source}, Target={target}"); + } + + // Verify the update was successful + Assert.Equal(0, finalChildId); // Old name should be gone + Assert.NotEqual(0, finalSonId); // New name should exist + Assert.Equal(finalFatherId, links.GetSource(finalSonId)); // Source should be father + Assert.Equal(finalMotherId, links.GetTarget(finalSonId)); // Target should be mother + + Console.WriteLine("[Test] ===== UpdateNamedLinkNameTest completed successfully ====="); + } + catch (Exception ex) + { + Console.WriteLine($"[Test] Error in UpdateNamedLinkNameTest: {ex}"); + Console.WriteLine($"[Test] Stack trace: {ex.StackTrace}"); + throw; + } + }, enableTracing: true); + } + + [Fact] + public void DeleteNamedFamilyLinksRemovesNamesTest() + { + RunTestWithLinks(links => + { + // Prepare query: create (child: father mother) + var query = "(() ((child: father mother)))"; + var options = new Options + { + Query = query, + }; + ProcessQuery(links, options); + + // Delete the 'child' link + var childId = links.GetByName("child"); + links.Delete(childId); + + // Assert: 'child' name is removed, 'father' and 'mother' remain + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.NotEqual(links.Constants.Null, links.GetByName("father")); + Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void DeleteNamedLinkTest() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + + ProcessQuery(links, "(((*:)) ())"); + + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.Equal(links.Constants.Null, links.GetByName("father")); + Assert.Equal(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void DeleteByNamesTest() + { + RunTestWithLinks(links => + { + // Create link by name + ProcessQuery(links, "(() ((child: father mother)))"); + + // Delete link by name + ProcessQuery(links, "(((child: father mother)) ())"); + + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.NotEqual(links.Constants.Null, links.GetByName("father")); + Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void NameLookupConsistencyTest() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((x: 1 2)))"); + ProcessQuery(links, "(((x: 1 2)) ((y: 1 2)))"); + ProcessQuery(links, "(((y: 1 2)) ((z: 1 2)))"); + links.Delete(links.GetByName("z")); + Assert.Equal(links.Constants.Null, links.GetByName("x")); + Assert.Equal(links.Constants.Null, links.GetByName("y")); + Assert.Equal(links.Constants.Null, links.GetByName("z")); + }); + } + + [Fact] + public void CreateNamedLinkWithStringId_ShouldCreateSingleLink() + { + RunTestWithLinks(links => + { + var options = new Options { Query = "(() ((link: link link)))", Trace = true }; + ProcessQuery(links, options); + var allLinks = GetAllLinks(links); + // This should only create a single named link with string id 'link' + Assert.Single(allLinks); + var linkId = links.GetByName("link"); + Assert.NotEqual(links.Constants.Null, linkId); + var link = allLinks.First(); + Assert.Equal(linkId, link.Index); + Assert.Equal(linkId, link.Source); + Assert.Equal(linkId, link.Target); + Assert.Equal("link", links.GetName(linkId)); + }, enableTracing: true); + } + + [Fact] + public void CreateLinkWithIntegerId_ShouldCreateSingleLink() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1)))"); + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + var link = allLinks.First(); + Assert.Equal(1u, link.Index); + Assert.Equal(1u, link.Source); + Assert.Equal(1u, link.Target); + }); + } + + [Fact] + public void CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQuery(links, "(() ((2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } - [Fact] - public void MatchSelfReferencingAndMakeThemGoOutFromFirstLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2) (3 1) (4 4)))"); - - // Act: match self-referencing links and make them go out from the first link - ProcessQuery(links, "((($index: $index $index)) (($index: 1 $index)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(4, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 1, 2); - AssertLinkExists(allLinks, 3, 3, 1); - AssertLinkExists(allLinks, 4, 1, 4); - }); - } + [Fact] + public void CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQuery(links, "(() ((2: 1 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 1, 2); + }); + } - [Fact] - public void MultipleUpdatesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Act - ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + [Fact] + public void CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((type: type type)))"); + ProcessQuery(links, "(() ((link: link type)))"); + + // Assert + var allLinks = GetAllLinks(links); + // Expect only two links, but extra self-referential named link for 'link' is created indicating a bug. + Assert.Equal(2, allLinks.Count); + var typeId = links.GetByName("type"); + var linkId = links.GetByName("link"); + AssertLinkExists(allLinks, typeId, typeId, typeId); + AssertLinkExists(allLinks, linkId, linkId, typeId); + }); + } - [Fact] - public void MixedMultipleUpdatesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Act - ProcessQuery(links, "(((2: 2 2) (1: 1 1)) ((1: 1 2) (2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + [Fact] + public void CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((type: type type)))"); + ProcessQuery(links, "(() ((link: type link)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + var typeId = links.GetByName("type"); + var linkId = links.GetByName("link"); + AssertLinkExists(allLinks, typeId, typeId, typeId); + AssertLinkExists(allLinks, linkId, typeId, linkId); + }); + } - [Fact] - public void CreationDuringUpdateTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - - // Act: Add new link with ID '2' by including it only in substitution - ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (2: 2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + // ============================================ + // Link Deduplication Tests + // ============================================ - [Fact] - public void CreationWithEmptySlotDuringUpdateTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - - // Act: Add new link with ID '2' by including it only in substitution - ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (3: 3 3)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 3, 3, 3); - }); - } + [Fact] + public void DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink() + { + // Issue #65: Test deduplication of (m a) (m a) pattern + // Query: () (((m a) (m a))) + // Expected: m, a (named self-refs), link 3 = (m a), link 4 = (3 3) + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((m a) (m a))))"); - [Fact] - public void DeletionDuringUpdateTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Act: Remove link with ID '2' by omitting it in substitution - ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(4, allLinks.Count); - [Fact] - public void DeleteSingleLinkTest_Source1Target2() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2)))"); - ProcessQuery(links, "(() ((2 2)))"); - - // Act - ProcessQuery(links, "(((1 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + // Get the named link IDs + var mId = links.GetByName("m"); + var aId = links.GetByName("a"); - [Fact] - public void DeleteSingleLinkTest_Source2Target2() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((2 2)))"); - - // Act - ProcessQuery(links, "(((2 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } + Assert.NotEqual(links.Constants.Null, mId); + Assert.NotEqual(links.Constants.Null, aId); - [Fact] - public void DeleteMultipleLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((1 2) (2 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } + // m and a should be self-referencing + AssertLinkExists(allLinks, mId, mId, mId); + AssertLinkExists(allLinks, aId, aId, aId); - [Fact] - public void DeleteLinksByAnyTargetTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((1 *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } + // Find the (m a) link + var maLink = allLinks.FirstOrDefault(l => l.Source == mId && l.Target == aId); + Assert.NotEqual(default, maLink); - [Fact] - public void DeleteLinksByAnySourceTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (1 2)))"); - - // Act - ProcessQuery(links, "(((* 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } + // Find the outer link ((m a) (m a)) which should be (maLink.Index maLink.Index) + var outerLink = allLinks.FirstOrDefault(l => l.Source == maLink.Index && l.Target == maLink.Index); + Assert.NotEqual(default, outerLink); - [Fact] - public void DeleteAllLinksBySourceAndTargetTest1() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((* *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } + // Verify deduplication: the outer link's source and target should be the same + Assert.Equal(outerLink.Source, outerLink.Target); + }); + } - [Fact] - public void NestedDeleteAllLinksBySourceAndTargetTest1() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "((((* *) (* *))) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } + [Fact] + public void DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink() + { + // Issue #65: Test deduplication with numeric IDs + // Query: () (((1 2) (1 2))) + // When using numeric IDs directly, they are treated as references (not creating self-refs) + // So (1 2) creates link with source=1, target=2 + // The deduplication still works: ((1 2) (1 2)) creates only one (1 2) link + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 2) (1 2))))"); - [Fact] - public void DeleteAllLinksBySourceAndTargetTest2() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 1)))"); - - // Act - ProcessQuery(links, "(((* *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } + // Assert + var allLinks = GetAllLinks(links); - [Fact] - public void DeleteAllLinksByIndexTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((*:)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } + // Should have 2 links: (1 2) and ((1 2) (1 2)) + Assert.Equal(2, allLinks.Count); - [Fact] - public void CreateNamedFamilyLinksTest() - { - RunTestWithLinks(links => - { - // Prepare query: create (child: father mother) - var query = "(() ((child: father mother)))"; - var options = new Options - { - Query = query, - }; - ProcessQuery(links, options); - - // Assert: links for 'father', 'mother', and 'child' exist and are named - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - Assert.NotEqual(links.Constants.Null, fatherId); - Assert.NotEqual(links.Constants.Null, motherId); - Assert.NotEqual(links.Constants.Null, childId); - Assert.Equal("father", links.GetName(fatherId)); - Assert.Equal("mother", links.GetName(motherId)); - Assert.Equal("child", links.GetName(childId)); - - // The child link should have father as source and mother as target - var allLinks = GetAllLinks(links); - var childLink = allLinks.First(l => l.Index == childId); - Assert.Equal(fatherId, childLink.Source); - Assert.Equal(motherId, childLink.Target); - }); - } + // Link 1 should be (1 2) - the deduplicated sub-link + AssertLinkExists(allLinks, 1, 1, 2); - [Fact] - public void CreateTwoNamedLinksTest() - { - RunTestWithLinks(links => - { - Console.WriteLine("[Test] Starting UpdateNamedLinkNameTest"); - - // Create initial link: (child: father mother) - Console.WriteLine("[Test] Step 1: Creating initial link"); - var createOptions = new Options { Query = "(() ((child: father mother)))", Trace = true }; - ProcessQuery(links, createOptions); - Console.WriteLine("[Test] Initial link creation completed"); - - // Verify initial state - Console.WriteLine("[Test] Step 2: Verifying initial state"); - var initialChildId = links.GetByName("child"); - Console.WriteLine($"[Test] Initial child ID: {initialChildId}"); - var initialFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Initial father ID: {initialFatherId}"); - var initialMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Initial mother ID: {initialMotherId}"); - - Assert.NotEqual(links.Constants.Null, initialChildId); - Assert.NotEqual(links.Constants.Null, initialFatherId); - Assert.NotEqual(links.Constants.Null, initialMotherId); - - var initialLinks = GetAllLinks(links); - Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); - var initialChildLink = initialLinks.First(l => l.Index == initialChildId); - Assert.Equal(initialFatherId, initialChildLink.Source); - Assert.Equal(initialMotherId, initialChildLink.Target); - Console.WriteLine("[Test] Initial state verification completed"); - - // Update child link to be named "son" instead - Console.WriteLine("[Test] Step 3: Updating link name"); - // First, let's try to remove the old name - Console.WriteLine("[Test] Removing old name 'child'"); - links.RemoveName(initialChildId); - Console.WriteLine("[Test] Old name removed"); - - // Then create the new link with the new name - Console.WriteLine("[Test] Creating new link with name 'son'"); - var updateOptions = new Options { Query = "(() ((son: father mother)))", Trace = true }; - ProcessQuery(links, updateOptions); - Console.WriteLine("[Test] New link creation completed"); - - // Verify final state - Console.WriteLine("[Test] Step 4: Verifying final state"); - Assert.Equal(links.Constants.Null, links.GetByName("child")); - var finalSonId = links.GetByName("son"); - Console.WriteLine($"[Test] Final son ID: {finalSonId}"); - var finalFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); - var finalMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); - - Assert.NotEqual(links.Constants.Null, finalSonId); - Assert.NotEqual(links.Constants.Null, finalFatherId); - Assert.NotEqual(links.Constants.Null, finalMotherId); - - var finalLinks = GetAllLinks(links); - Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); - var finalSonLink = Assert.Single(finalLinks, l => l.Index == finalSonId); - Assert.Equal(finalFatherId, finalSonLink.Source); - Assert.Equal(finalMotherId, finalSonLink.Target); - Console.WriteLine("[Test] Final state verification completed"); - Console.WriteLine("[Test] UpdateNamedLinkNameTest completed successfully"); - }, enableTracing: true); - } + // Link 2 should be (1 1) - referencing the same sub-link twice + AssertLinkExists(allLinks, 2, 1, 1); + }); + } - [Fact] - public void UpdateNamedLinkNameTest() - { - Console.WriteLine("[Test] ===== Starting UpdateNamedLinkNameTest ====="); - RunTestWithLinks(links => - { - try - { - Console.WriteLine($"[Test] Constants: Null={links.Constants.Null}, Any={links.Constants.Any}, Continue={links.Constants.Continue}"); - // Step 1: Creating initial link - Console.WriteLine("[Test] Step 1: Creating initial link"); - var createQuery = "(() ((child: father mother)))"; - Console.WriteLine($"[Test] Query: {createQuery}"); - - var createOptions = new Options - { - Query = createQuery, - Trace = true - }; - ProcessQuery(links, createOptions); - Console.WriteLine("[Test] Initial link creation completed"); - - // Step 2: Verify initial state - Console.WriteLine("[Test] Step 2: Verifying initial state"); - var childId = links.GetByName("child"); - Console.WriteLine($"[Test] Initial child ID: {childId}"); - var fatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Initial father ID: {fatherId}"); - var motherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Initial mother ID: {motherId}"); - - var initialLinks = links.All().ToList(); - Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); - foreach (var link in initialLinks) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Initial link: Index={link}, Source={source}, Target={target}"); - } - Console.WriteLine("[Test] Initial state verification completed"); - - // Step 3: Update link name - Console.WriteLine("[Test] Step 3: Updating link name from 'child' to 'son'"); - var updateQuery = "(((child: father mother)) ((son: father mother)))"; - Console.WriteLine($"[Test] Query: {updateQuery}"); - - // Log state before update - Console.WriteLine("[Test] Current state before update:"); - Console.WriteLine($"[Test] - child name exists: {links.GetByName("child") != 0}"); - Console.WriteLine($"[Test] - son name exists: {links.GetByName("son") != 0}"); - Console.WriteLine($"[Test] - father name exists: {links.GetByName("father") != 0}"); - Console.WriteLine($"[Test] - mother name exists: {links.GetByName("mother") != 0}"); - - Console.WriteLine("[Test] Starting ProcessQuery for update..."); - Console.WriteLine("[Test] Current links before update:"); - foreach (var link in links.All()) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); - } + [Fact] + public void DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink() + { + // Test with three identical pairs using named links: (((a b) ((a b) (a b)))) + // The (a b) should only be created once + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((a b) ((a b) (a b)))))"); - // Add detailed tracing for the update operation - var updateOptions = new Options - { - Query = updateQuery, - Trace = true, - ChangesHandler = (before, after) => - { - Console.WriteLine($"[Test] Update ChangesHandler called:"); - Console.WriteLine($"[Test] - Before state: {before}"); - Console.WriteLine($"[Test] - After state: {after}"); - - // Log name states during change - Console.WriteLine($"[Test] - child name during change: {links.GetByName("child")}"); - Console.WriteLine($"[Test] - son name during change: {links.GetByName("son")}"); - Console.WriteLine($"[Test] - father name during change: {links.GetByName("father")}"); - Console.WriteLine($"[Test] - mother name during change: {links.GetByName("mother")}"); - - // Log all links during change - Console.WriteLine("[Test] - All links during change:"); - foreach (var link in links.All()) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); - } - - // Add detailed tracing for link creation - if (after != null && before == null) - { - var afterLink = new DoubletLink(after); - var source = links.GetSource(after); - var target = links.GetTarget(after); - Console.WriteLine($"[Test] Creating new link: Index={afterLink.Index}, Source={source}, Target={target}"); - Console.WriteLine($"[Test] Checking if link exists: {links.Exists>(afterLink.Index)}"); - Console.WriteLine($"[Test] Checking if source exists: {links.Exists>(source)}"); - Console.WriteLine($"[Test] Checking if target exists: {links.Exists>(target)}"); - - // Log all names before creation - Console.WriteLine("[Test] Names before creation:"); - foreach (var name in new[] { "child", "son", "father", "mother" }) - { - var id = links.GetByName(name); - Console.WriteLine($"[Test] - {name}: {id}"); - } - } + // Assert + var allLinks = GetAllLinks(links); - return links.Constants.Continue; - } - }; - - ProcessQuery(links, updateOptions); - Console.WriteLine("[Test] Update operation completed"); - - // Step 4: Verify final state - Console.WriteLine("[Test] Step 4: Verifying final state"); - var finalChildId = links.GetByName("child"); - Console.WriteLine($"[Test] Final child ID: {finalChildId}"); - var finalSonId = links.GetByName("son"); - Console.WriteLine($"[Test] Final son ID: {finalSonId}"); - var finalFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); - var finalMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); - - var finalLinks = links.All().ToList(); - Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); - foreach (var link in finalLinks) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Final link: Index={link}, Source={source}, Target={target}"); - } + var aId = links.GetByName("a"); + var bId = links.GetByName("b"); - // Verify the update was successful - Assert.Equal(0, finalChildId); // Old name should be gone - Assert.NotEqual(0, finalSonId); // New name should exist - Assert.Equal(finalFatherId, links.GetSource(finalSonId)); // Source should be father - Assert.Equal(finalMotherId, links.GetTarget(finalSonId)); // Target should be mother + // a and b should be self-referencing + AssertLinkExists(allLinks, aId, aId, aId); + AssertLinkExists(allLinks, bId, bId, bId); - Console.WriteLine("[Test] ===== UpdateNamedLinkNameTest completed successfully ====="); - } - catch (Exception ex) - { - Console.WriteLine($"[Test] Error in UpdateNamedLinkNameTest: {ex}"); - Console.WriteLine($"[Test] Stack trace: {ex.StackTrace}"); - throw; - } - }, enableTracing: true); - } + // Find (a b) link - the deduplicated sub-link + var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); + Assert.NotEqual(default, abLink); - [Fact] - public void DeleteNamedFamilyLinksRemovesNamesTest() - { - RunTestWithLinks(links => - { - // Prepare query: create (child: father mother) - var query = "(() ((child: father mother)))"; - var options = new Options - { - Query = query, - }; - ProcessQuery(links, options); - - // Delete the 'child' link - var childId = links.GetByName("child"); - links.Delete(childId); - - // Assert: 'child' name is removed, 'father' and 'mother' remain - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.NotEqual(links.Constants.Null, links.GetByName("father")); - Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); - }); - } + // Find ((a b) (a b)) link - should reference abLink twice + var innerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == abLink.Index); + Assert.NotEqual(default, innerLink); - [Fact] - public void DeleteNamedLinkTest() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); + // Find outer link ((a b) ((a b) (a b))) + var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == innerLink.Index); + Assert.NotEqual(default, outerLink); - ProcessQuery(links, "(((*:)) ())"); + Assert.Equal(5, allLinks.Count); + }); + } - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.Equal(links.Constants.Null, links.GetByName("father")); - Assert.Equal(links.Constants.Null, links.GetByName("mother")); - }); - } + [Fact] + public void DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks() + { + // Test that named links are reused across queries + RunTestWithLinks(links => + { + // First query creates (m a) + ProcessQuery(links, "(() ((m a)))"); - [Fact] - public void DeleteByNamesTest() - { - RunTestWithLinks(links => - { - // Create link by name - ProcessQuery(links, "(() ((child: father mother)))"); - - // Delete link by name - ProcessQuery(links, "(((child: father mother)) ())"); - - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.NotEqual(links.Constants.Null, links.GetByName("father")); - Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); - }); - } + var mId = links.GetByName("m"); + var aId = links.GetByName("a"); - [Fact] - public void NameLookupConsistencyTest() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((x: 1 2)))"); - ProcessQuery(links, "(((x: 1 2)) ((y: 1 2)))"); - ProcessQuery(links, "(((y: 1 2)) ((z: 1 2)))"); - links.Delete(links.GetByName("z")); - Assert.Equal(links.Constants.Null, links.GetByName("x")); - Assert.Equal(links.Constants.Null, links.GetByName("y")); - Assert.Equal(links.Constants.Null, links.GetByName("z")); - }); - } + // Second query should reuse existing m and a links + ProcessQuery(links, "(() (((m a) (m a))))"); - [Fact] - public void CreateNamedLinkWithStringId_ShouldCreateSingleLink() - { - RunTestWithLinks(links => - { - var options = new Options { Query = "(() ((link: link link)))", Trace = true }; - ProcessQuery(links, options); - var allLinks = GetAllLinks(links); - // This should only create a single named link with string id 'link' - Assert.Single(allLinks); - var linkId = links.GetByName("link"); - Assert.NotEqual(links.Constants.Null, linkId); - var link = allLinks.First(); - Assert.Equal(linkId, link.Index); - Assert.Equal(linkId, link.Source); - Assert.Equal(linkId, link.Target); - Assert.Equal("link", links.GetName(linkId)); - }, enableTracing: true); - } + // Assert + var allLinks = GetAllLinks(links); - [Fact] - public void CreateLinkWithIntegerId_ShouldCreateSingleLink() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1)))"); - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - var link = allLinks.First(); - Assert.Equal(1u, link.Index); - Assert.Equal(1u, link.Source); - Assert.Equal(1u, link.Target); - }); - } + // m and a should still have the same IDs + Assert.Equal(mId, links.GetByName("m")); + Assert.Equal(aId, links.GetByName("a")); - [Fact] - public void CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - ProcessQuery(links, "(() ((2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + // Should have 4 links total: m, a, (m a), ((m a) (m a)) + Assert.Equal(4, allLinks.Count); + }); + } [Fact] - public void CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - ProcessQuery(links, "(() ((2: 1 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 1, 2); - }); - } + public void DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks() + { + // Test that different pairs are NOT deduplicated + // Query: () (((a b) (b a))) - using named links + // (a b) and (b a) are different and should both be created + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((a b) (b a))))"); - [Fact] - public void CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((type: type type)))"); - ProcessQuery(links, "(() ((link: link type)))"); - - // Assert - var allLinks = GetAllLinks(links); - // Expect only two links, but extra self-referential named link for 'link' is created indicating a bug. - Assert.Equal(2, allLinks.Count); - var typeId = links.GetByName("type"); - var linkId = links.GetByName("link"); - AssertLinkExists(allLinks, typeId, typeId, typeId); - AssertLinkExists(allLinks, linkId, linkId, typeId); - }); - } + // Assert + var allLinks = GetAllLinks(links); - [Fact] - public void CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((type: type type)))"); - ProcessQuery(links, "(() ((link: type link)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - var typeId = links.GetByName("type"); - var linkId = links.GetByName("link"); - AssertLinkExists(allLinks, typeId, typeId, typeId); - AssertLinkExists(allLinks, linkId, typeId, linkId); - }); - } + var aId = links.GetByName("a"); + var bId = links.GetByName("b"); - // ============================================ - // Link Deduplication Tests - // ============================================ + // a and b should be self-referencing + AssertLinkExists(allLinks, aId, aId, aId); + AssertLinkExists(allLinks, bId, bId, bId); - [Fact] - public void DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink() - { - // Issue #65: Test deduplication of (m a) (m a) pattern - // Query: () (((m a) (m a))) - // Expected: m, a (named self-refs), link 3 = (m a), link 4 = (3 3) - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((m a) (m a))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(4, allLinks.Count); - - // Get the named link IDs - var mId = links.GetByName("m"); - var aId = links.GetByName("a"); - - Assert.NotEqual(links.Constants.Null, mId); - Assert.NotEqual(links.Constants.Null, aId); - - // m and a should be self-referencing - AssertLinkExists(allLinks, mId, mId, mId); - AssertLinkExists(allLinks, aId, aId, aId); - - // Find the (m a) link - var maLink = allLinks.FirstOrDefault(l => l.Source == mId && l.Target == aId); - Assert.NotEqual(default, maLink); - - // Find the outer link ((m a) (m a)) which should be (maLink.Index maLink.Index) - var outerLink = allLinks.FirstOrDefault(l => l.Source == maLink.Index && l.Target == maLink.Index); - Assert.NotEqual(default, outerLink); - - // Verify deduplication: the outer link's source and target should be the same - Assert.Equal(outerLink.Source, outerLink.Target); - }); - } + // Find (a b) link + var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); + Assert.NotEqual(default, abLink); - [Fact] - public void DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink() - { - // Issue #65: Test deduplication with numeric IDs - // Query: () (((1 2) (1 2))) - // When using numeric IDs directly, they are treated as references (not creating self-refs) - // So (1 2) creates link with source=1, target=2 - // The deduplication still works: ((1 2) (1 2)) creates only one (1 2) link - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 2) (1 2))))"); - - // Assert - var allLinks = GetAllLinks(links); - - // Should have 2 links: (1 2) and ((1 2) (1 2)) - Assert.Equal(2, allLinks.Count); - - // Link 1 should be (1 2) - the deduplicated sub-link - AssertLinkExists(allLinks, 1, 1, 2); - - // Link 2 should be (1 1) - referencing the same sub-link twice - AssertLinkExists(allLinks, 2, 1, 1); - }); - } + // Find (b a) link + var baLink = allLinks.FirstOrDefault(l => l.Source == bId && l.Target == aId); + Assert.NotEqual(default, baLink); - [Fact] - public void DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink() - { - // Test with three identical pairs using named links: (((a b) ((a b) (a b)))) - // The (a b) should only be created once - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((a b) ((a b) (a b)))))"); + // Find outer link ((a b) (b a)) - should have different source and target + var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == baLink.Index); + Assert.NotEqual(default, outerLink); + Assert.NotEqual(outerLink.Source, outerLink.Target); - // Assert - var allLinks = GetAllLinks(links); + Assert.Equal(5, allLinks.Count); + }); + } - var aId = links.GetByName("a"); - var bId = links.GetByName("b"); + [Fact] + public void DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels() + { + // Test deeply nested deduplication using named links + // Query: () ((((x y) (x y)) ((x y) (x y)))) + // (x y) is duplicated at multiple levels + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((((x y) (x y)) ((x y) (x y)))))"); - // a and b should be self-referencing - AssertLinkExists(allLinks, aId, aId, aId); - AssertLinkExists(allLinks, bId, bId, bId); + // Assert + var allLinks = GetAllLinks(links); - // Find (a b) link - the deduplicated sub-link - var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); - Assert.NotEqual(default, abLink); + var xId = links.GetByName("x"); + var yId = links.GetByName("y"); - // Find ((a b) (a b)) link - should reference abLink twice - var innerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == abLink.Index); - Assert.NotEqual(default, innerLink); + // x and y should be self-referencing + AssertLinkExists(allLinks, xId, xId, xId); + AssertLinkExists(allLinks, yId, yId, yId); - // Find outer link ((a b) ((a b) (a b))) - var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == innerLink.Index); - Assert.NotEqual(default, outerLink); + // Find (x y) - the base link + var xyLink = allLinks.FirstOrDefault(l => l.Source == xId && l.Target == yId); + Assert.NotEqual(default, xyLink); - Assert.Equal(5, allLinks.Count); - }); - } + // Find ((x y) (x y)) - references (x y) twice (deduplicated) + var level1Link = allLinks.FirstOrDefault(l => l.Source == xyLink.Index && l.Target == xyLink.Index); + Assert.NotEqual(default, level1Link); - [Fact] - public void DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks() - { - // Test that named links are reused across queries - RunTestWithLinks(links => - { - // First query creates (m a) - ProcessQuery(links, "(() ((m a)))"); + // Find (((x y) (x y)) ((x y) (x y))) - references level1Link twice (deduplicated) + var level2Link = allLinks.FirstOrDefault(l => l.Source == level1Link.Index && l.Target == level1Link.Index); + Assert.NotEqual(default, level2Link); - var mId = links.GetByName("m"); - var aId = links.GetByName("a"); + // Total: x, y, (x y), ((x y) (x y)), (((x y) (x y)) ((x y) (x y))) + Assert.Equal(5, allLinks.Count); + }); + } - // Second query should reuse existing m and a links - ProcessQuery(links, "(() (((m a) (m a))))"); + [Fact] + public void DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds() + { + // Issue #65: Verify that named links maintain consistent IDs across operations + RunTestWithLinks(links => + { + // First create named links + ProcessQuery(links, "(() ((p: p p)))"); + ProcessQuery(links, "(() ((a: a a)))"); - // Assert - var allLinks = GetAllLinks(links); + var pId = links.GetByName("p"); + var aId = links.GetByName("a"); - // m and a should still have the same IDs - Assert.Equal(mId, links.GetByName("m")); - Assert.Equal(aId, links.GetByName("a")); + // Now create ((p a) (p a)) - should reuse existing p and a + ProcessQuery(links, "(() (((p a) (p a))))"); - // Should have 4 links total: m, a, (m a), ((m a) (m a)) - Assert.Equal(4, allLinks.Count); - }); - } + // Assert + var allLinks = GetAllLinks(links); - [Fact] - public void DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks() - { - // Test that different pairs are NOT deduplicated - // Query: () (((a b) (b a))) - using named links - // (a b) and (b a) are different and should both be created - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((a b) (b a))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var aId = links.GetByName("a"); - var bId = links.GetByName("b"); - - // a and b should be self-referencing - AssertLinkExists(allLinks, aId, aId, aId); - AssertLinkExists(allLinks, bId, bId, bId); - - // Find (a b) link - var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); - Assert.NotEqual(default, abLink); - - // Find (b a) link - var baLink = allLinks.FirstOrDefault(l => l.Source == bId && l.Target == aId); - Assert.NotEqual(default, baLink); - - // Find outer link ((a b) (b a)) - should have different source and target - var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == baLink.Index); - Assert.NotEqual(default, outerLink); - Assert.NotEqual(outerLink.Source, outerLink.Target); - - Assert.Equal(5, allLinks.Count); - }); - } + // p and a should still have the same IDs + Assert.Equal(pId, links.GetByName("p")); + Assert.Equal(aId, links.GetByName("a")); - [Fact] - public void DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels() - { - // Test deeply nested deduplication using named links - // Query: () ((((x y) (x y)) ((x y) (x y)))) - // (x y) is duplicated at multiple levels - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((((x y) (x y)) ((x y) (x y)))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var xId = links.GetByName("x"); - var yId = links.GetByName("y"); - - // x and y should be self-referencing - AssertLinkExists(allLinks, xId, xId, xId); - AssertLinkExists(allLinks, yId, yId, yId); - - // Find (x y) - the base link - var xyLink = allLinks.FirstOrDefault(l => l.Source == xId && l.Target == yId); - Assert.NotEqual(default, xyLink); - - // Find ((x y) (x y)) - references (x y) twice (deduplicated) - var level1Link = allLinks.FirstOrDefault(l => l.Source == xyLink.Index && l.Target == xyLink.Index); - Assert.NotEqual(default, level1Link); - - // Find (((x y) (x y)) ((x y) (x y))) - references level1Link twice (deduplicated) - var level2Link = allLinks.FirstOrDefault(l => l.Source == level1Link.Index && l.Target == level1Link.Index); - Assert.NotEqual(default, level2Link); - - // Total: x, y, (x y), ((x y) (x y)), (((x y) (x y)) ((x y) (x y))) - Assert.Equal(5, allLinks.Count); - }); - } + // Verify the structure + AssertLinkExists(allLinks, pId, pId, pId); + AssertLinkExists(allLinks, aId, aId, aId); - [Fact] - public void DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds() - { - // Issue #65: Verify that named links maintain consistent IDs across operations - RunTestWithLinks(links => - { - // First create named links - ProcessQuery(links, "(() ((p: p p)))"); - ProcessQuery(links, "(() ((a: a a)))"); - - var pId = links.GetByName("p"); - var aId = links.GetByName("a"); - - // Now create ((p a) (p a)) - should reuse existing p and a - ProcessQuery(links, "(() (((p a) (p a))))"); - - // Assert - var allLinks = GetAllLinks(links); - - // p and a should still have the same IDs - Assert.Equal(pId, links.GetByName("p")); - Assert.Equal(aId, links.GetByName("a")); - - // Verify the structure - AssertLinkExists(allLinks, pId, pId, pId); - AssertLinkExists(allLinks, aId, aId, aId); - - // Find (p a) link - var paLink = allLinks.FirstOrDefault(l => l.Source == pId && l.Target == aId); - Assert.NotEqual(default, paLink); - - // Find ((p a) (p a)) link - should reference paLink twice - var outerLink = allLinks.FirstOrDefault(l => l.Source == paLink.Index && l.Target == paLink.Index); - Assert.NotEqual(default, outerLink); - }); - } + // Find (p a) link + var paLink = allLinks.FirstOrDefault(l => l.Source == pId && l.Target == aId); + Assert.NotEqual(default, paLink); - [Fact] - public void StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((father: father father)))"); - ProcessQuery(links, "(() ((mother: mother mother)))"); - ProcessQuery(links, "(() ((child: father mother)))"); - - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - - ProcessQuery(links, "((($id: father mother)) (($id: mother father)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, fatherId, fatherId, fatherId); - AssertLinkExists(allLinks, motherId, motherId, motherId); - AssertLinkExists(allLinks, childId, motherId, fatherId); - }); - } + // Find ((p a) (p a)) link - should reference paLink twice + var outerLink = allLinks.FirstOrDefault(l => l.Source == paLink.Index && l.Target == paLink.Index); + Assert.NotEqual(default, outerLink); + }); + } - [Fact] - public void Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1) (18: 1 21) (19: 1 20) (20: 20 20) (21: 21 21)))"); - - ProcessQuery(links, "((($i: 1 21)) (($i: $s $t) ($i 20)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(6, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 18, 1, 21); - AssertLinkExists(allLinks, 19, 1, 20); - AssertLinkExists(allLinks, 20, 20, 20); - AssertLinkExists(allLinks, 21, 21, 21); - - var outgoingLink = Assert.Single(allLinks, link => link.Source == 18 && link.Target == 20); - Assert.NotEqual(links.Constants.Null, outgoingLink.Index); - Assert.NotEqual(links.Constants.Any, outgoingLink.Index); - Assert.DoesNotContain(allLinks, link => link.Index == links.Constants.Any || link.Source == links.Constants.Any || link.Target == links.Constants.Any); - }); - } + [Fact] + public void StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((father: father father)))"); + ProcessQuery(links, "(() ((mother: mother mother)))"); + ProcessQuery(links, "(() ((child: father mother)))"); + + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + + ProcessQuery(links, "((($id: father mother)) (($id: mother father)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, fatherId, fatherId, fatherId); + AssertLinkExists(allLinks, motherId, motherId, motherId); + AssertLinkExists(allLinks, childId, motherId, fatherId); + }); + } - [Fact] - public void Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((21: 21 21)))"); + [Fact] + public void Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1) (18: 1 21) (19: 1 20) (20: 20 20) (21: 21 21)))"); + + ProcessQuery(links, "((($i: 1 21)) (($i: $s $t) ($i 20)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(6, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 18, 1, 21); + AssertLinkExists(allLinks, 19, 1, 20); + AssertLinkExists(allLinks, 20, 20, 20); + AssertLinkExists(allLinks, 21, 21, 21); + + var outgoingLink = Assert.Single(allLinks, link => link.Source == 18 && link.Target == 20); + Assert.NotEqual(links.Constants.Null, outgoingLink.Index); + Assert.NotEqual(links.Constants.Any, outgoingLink.Index); + Assert.DoesNotContain(allLinks, link => link.Index == links.Constants.Any || link.Source == links.Constants.Any || link.Target == links.Constants.Any); + }); + } - ProcessQuery(links, "(((21: 21 21)) ((21: $s $t)))"); + [Fact] + public void Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((21: 21 21)))"); - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 21, 21, 21); - Assert.DoesNotContain(allLinks, link => link.Source == links.Constants.Any || link.Target == links.Constants.Any); - }); - } + ProcessQuery(links, "(((21: 21 21)) ((21: $s $t)))"); - [Fact] - public void EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException() - { - RunTestWithLinks(links => - { - var exception = Assert.Throws(() => LinksExtensions.EnsureCreated(links, links.Constants.Any)); + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 21, 21, 21); + Assert.DoesNotContain(allLinks, link => link.Source == links.Constants.Any || link.Target == links.Constants.Any); + }); + } - Assert.Contains("unsupported link address", exception.Message); - }); - } + [Fact] + public void EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException() + { + RunTestWithLinks(links => + { + var exception = Assert.Throws(() => LinksExtensions.EnsureCreated(links, links.Constants.Any)); - // Helper methods + Assert.Contains("unsupported link address", exception.Message); + }); + } - /// - /// Wall-clock budget for a single test body. Every test here is expected to finish in - /// milliseconds, so this is a deadlock guard, not a performance assertion: the previous value of - /// one second was tight enough that a loaded CI runner failed tests that were perfectly correct. - /// Override with the LINK_CLI_TEST_TIMEOUT_SECONDS environment variable. - /// - private static TimeSpan TestTimeout - { - get - { - const int defaultTimeoutSeconds = 60; - var configured = Environment.GetEnvironmentVariable("LINK_CLI_TEST_TIMEOUT_SECONDS"); - if (!string.IsNullOrWhiteSpace(configured) - && int.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) - && seconds > 0) - { - return TimeSpan.FromSeconds(seconds); - } - return TimeSpan.FromSeconds(defaultTimeoutSeconds); - } - } + // Helper methods - private static void RunTestWithLinks(Action> testAction, bool enableTracing = false) - { - string tempDbFile = Path.GetTempFileName(); - var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); - try - { - // Disposed at the end of the try block, before the finally deletes the backing files. The - // decorator owns memory-mapped handles for both databases; POSIX tolerates unlinking a file - // that is still mapped, Windows fails the delete with IOException. - using var decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); + /// + /// Wall-clock budget for a single test body. Every test here is expected to finish in + /// milliseconds, so this is a deadlock guard, not a performance assertion: the previous value of + /// one second was tight enough that a loaded CI runner failed tests that were perfectly correct. + /// Override with the LINK_CLI_TEST_TIMEOUT_SECONDS environment variable. + /// + private static TimeSpan TestTimeout + { + get + { + const int defaultTimeoutSeconds = 60; + var configured = Environment.GetEnvironmentVariable("LINK_CLI_TEST_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(configured) + && int.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) + && seconds > 0) + { + return TimeSpan.FromSeconds(seconds); + } + return TimeSpan.FromSeconds(defaultTimeoutSeconds); + } + } - var timeout = TestTimeout; - using var cts = new CancellationTokenSource(timeout); - var task = Task.Run(() => + private static void RunTestWithLinks(Action> testAction, bool enableTracing = false) { - testAction(decoratedLinks); - }, cts.Token); + string tempDbFile = Path.GetTempFileName(); + var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); + try + { + // Disposed at the end of the try block, before the finally deletes the backing files. The + // decorator owns memory-mapped handles for both databases; POSIX tolerates unlinking a file + // that is still mapped, Windows fails the delete with IOException. + using var decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); + + var timeout = TestTimeout; + using var cts = new CancellationTokenSource(timeout); + var task = Task.Run(() => + { + testAction(decoratedLinks); + }, cts.Token); + + try + { + task.Wait(cts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine($"[Test] Test was cancelled after {timeout.TotalSeconds} seconds timeout"); + throw new TimeoutException($"Test exceeded {timeout.TotalSeconds} seconds timeout"); + } + } + finally + { + if (File.Exists(namesDbFile)) + { + File.Delete(namesDbFile); + } + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + } + } - try + private static List GetAllLinks(NamedTypesDecorator links) { - task.Wait(cts.Token); + var any = links.Constants.Any; + var query = new DoubletLink(index: any, source: any, target: any); + var allLinks = links.All(query).Select(doublet => new DoubletLink(doublet)).ToList(); + Console.WriteLine($"[Test] All links: {string.Join(" ", allLinks)}"); + return allLinks; } - catch (OperationCanceledException) + + private static void ProcessQuery(NamedTypesDecorator links, string query) { - Console.WriteLine($"[Test] Test was cancelled after {timeout.TotalSeconds} seconds timeout"); - throw new TimeoutException($"Test exceeded {timeout.TotalSeconds} seconds timeout"); + ProcessQuery(links, new Options { Query = query }); } - } - finally - { - if (File.Exists(namesDbFile)) + + private static void ProcessQuery(NamedTypesDecorator links, Options options) { - File.Delete(namesDbFile); + options.AutoCreateMissingReferences = true; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); } - if (File.Exists(tempDbFile)) + + private static void ProcessQueryStrict(NamedTypesDecorator links, string query) { - File.Delete(tempDbFile); + ProcessQueryStrict(links, new Options { Query = query }); } - } - } - private static List GetAllLinks(NamedTypesDecorator links) - { - var any = links.Constants.Any; - var query = new DoubletLink(index: any, source: any, target: any); - var allLinks = links.All(query).Select(doublet => new DoubletLink(doublet)).ToList(); - Console.WriteLine($"[Test] All links: {string.Join(" ", allLinks)}"); - return allLinks; - } + private static void ProcessQueryStrict(NamedTypesDecorator links, Options options) + { + options.AutoCreateMissingReferences = false; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); + } - private static void ProcessQuery(NamedTypesDecorator links, string query) - { - ProcessQuery(links, new Options { Query = query }); - } + private static void AssertLinkExists(List allLinks, uint index, uint source, uint target) + { + var link = new DoubletLink(index, source, target); + Assert.True(allLinks.Contains(link), $"Link {link} not found in the list of all links ({string.Join(" ", allLinks)})"); + } - private static void ProcessQuery(NamedTypesDecorator links, Options options) - { - options.AutoCreateMissingReferences = true; - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); - } + private static void AssertChangeExists(List<(DoubletLink, DoubletLink)> changes, DoubletLink linkBefore, DoubletLink linkAfter) + { + Assert.Contains(changes, change => change.Item1 == linkBefore && change.Item2 == linkAfter); + } - private static void ProcessQueryStrict(NamedTypesDecorator links, string query) - { - ProcessQueryStrict(links, new Options { Query = query }); - } + // New tests for link reference validation - private static void ProcessQueryStrict(NamedTypesDecorator links, Options options) - { - options.AutoCreateMissingReferences = false; - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); - } + [Fact] + public void CreateLinkWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Act & Assert - should throw exception for referencing non-existent link 10 + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(() ((1: 10 20)))"); + }); - private static void AssertLinkExists(List allLinks, uint index, uint source, uint target) - { - var link = new DoubletLink(index, source, target); - Assert.True(allLinks.Contains(link), $"Link {link} not found in the list of all links ({string.Join(" ", allLinks)})"); - } + Assert.Contains("Invalid reference to non-existent link '10'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); + }); + } - private static void AssertChangeExists(List<(DoubletLink, DoubletLink)> changes, DoubletLink linkBefore, DoubletLink linkAfter) - { - Assert.Contains(changes, change => change.Item1 == linkBefore && change.Item2 == linkAfter); - } + [Fact] + public void CreateLinkWithValidSelfReference_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because link 1 references itself + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } - // New tests for link reference validation + [Fact] + public void CreateMultipleLinksWithCrossReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because both links are created in the same operation + ProcessQueryStrict(links, "(() ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } - [Fact] - public void CreateLinkWithNonExistentReference_ShouldThrowException() - { - RunTestWithLinks(links => - { - // Act & Assert - should throw exception for referencing non-existent link 10 - var exception = Assert.Throws(() => + [Fact] + public void CreateLinkReferencingExistingLink_ShouldSucceed() { - ProcessQueryStrict(links, "(() ((1: 10 20)))"); - }); + RunTestWithLinks(links => + { + // Arrange - create first link + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Act - should succeed because link 1 exists + ProcessQueryStrict(links, "(() ((2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } - Assert.Contains("Invalid reference to non-existent link '10'", exception.Message); - Assert.Contains("--auto-create-missing-references", exception.Message); - }); - } + [Fact] + public void UpdateWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Arrange - create initial link + ProcessQueryStrict(links, "(() ((1: 1 1)))"); - [Fact] - public void CreateLinkWithValidSelfReference_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because link 1 references itself - ProcessQueryStrict(links, "(() ((1: 1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } + // Act & Assert - should throw exception for referencing non-existent link 99 + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(((1: 1 1)) ((1: 1 99)))"); + }); - [Fact] - public void CreateMultipleLinksWithCrossReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because both links are created in the same operation - ProcessQueryStrict(links, "(() ((1: 1 2) (2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + Assert.Contains("Invalid reference to non-existent link '99'", exception.Message); + }); + } - [Fact] - public void CreateLinkReferencingExistingLink_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Arrange - create first link - ProcessQueryStrict(links, "(() ((1: 1 1)))"); - - // Act - should succeed because link 1 exists - ProcessQueryStrict(links, "(() ((2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + [Fact] + public void CreateNamedLinkWithMissingNamedReferences_ShouldThrowException() + { + RunTestWithLinks(links => + { + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(() ((child: father mother)))"); + }); - [Fact] - public void UpdateWithNonExistentReference_ShouldThrowException() - { - RunTestWithLinks(links => - { - // Arrange - create initial link - ProcessQueryStrict(links, "(() ((1: 1 1)))"); + Assert.Contains("Invalid reference to non-existent link 'father'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); + }); + } - // Act & Assert - should throw exception for referencing non-existent link 99 - var exception = Assert.Throws(() => + [Fact] + public void CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks() { - ProcessQueryStrict(links, "(((1: 1 1)) ((1: 1 99)))"); - }); + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((20: 10 20)))"); - Assert.Contains("Invalid reference to non-existent link '99'", exception.Message); - }); - } + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 10, 10, 10); + AssertLinkExists(allLinks, 20, 10, 20); + }); + } - [Fact] - public void CreateNamedLinkWithMissingNamedReferences_ShouldThrowException() - { - RunTestWithLinks(links => - { - var exception = Assert.Throws(() => + [Fact] + public void CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks() { - ProcessQueryStrict(links, "(() ((child: father mother)))"); - }); - - Assert.Contains("Invalid reference to non-existent link 'father'", exception.Message); - Assert.Contains("--auto-create-missing-references", exception.Message); - }); - } + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, fatherId, fatherId, fatherId); + AssertLinkExists(allLinks, motherId, motherId, motherId); + AssertLinkExists(allLinks, childId, fatherId, motherId); + }); + } - [Fact] - public void CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((20: 10 20)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 10, 10, 10); - AssertLinkExists(allLinks, 20, 10, 20); - }); - } + [Fact] + public void CreateLinkWithVariableReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because variables are not validated + ProcessQueryStrict(links, "(() (($link: $source $target)))"); - [Fact] - public void CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); - - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, fatherId, fatherId, fatherId); - AssertLinkExists(allLinks, motherId, motherId, motherId); - AssertLinkExists(allLinks, childId, fatherId, motherId); - }); - } + // Assert - one link should be created with variables resolved + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } - [Fact] - public void CreateLinkWithVariableReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because variables are not validated - ProcessQueryStrict(links, "(() (($link: $source $target)))"); - - // Assert - one link should be created with variables resolved - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - }); - } + [Fact] + public void CreateLinkWithWildcardReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because wildcards are not validated + ProcessQueryStrict(links, "(() ((1: * *)))"); - [Fact] - public void CreateLinkWithWildcardReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because wildcards are not validated - ProcessQueryStrict(links, "(() ((1: * *)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - }); + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs index a0bcfb1..9dec3a6 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs @@ -4,13 +4,13 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class ChangesSimplifierTests - { - [Fact] - public void SimplifyChanges_SpecificExample_RemovesIntermediateStates() + public class ChangesSimplifierTests { - // Arrange - var changes = new List<(Link Before, Link After)> + [Fact] + public void SimplifyChanges_SpecificExample_RemovesIntermediateStates() + { + // Arrange + var changes = new List<(Link Before, Link After)> { // (1: 2 1) ↦ (1: 0 0) (new Link(index: 1, source: 2, target: 1), new Link(index: 1, source: 0, target: 0)), @@ -25,39 +25,39 @@ public void SimplifyChanges_SpecificExample_RemovesIntermediateStates() (new Link(index: 1, source: 0, target: 0), new Link(index: 0, source: 0, target: 0)) }; - // Expected simplified changes: - // (1: 2 1) ↦ (0: 0 0) - // (2: 1 2) ↦ (0: 0 0) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes: + // (1: 2 1) ↦ (0: 0 0) + // (2: 1 2) ↦ (0: 0 0) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 2, target: 1), new Link(index: 0, source: 0, target: 0)), (new Link(index: 2, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_MultipleChainsFromSameBefore_RemovesIntermediateStates() - { - // Arrange - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_MultipleChainsFromSameBefore_RemovesIntermediateStates() + { + // Arrange + var changes = new List<(Link Before, Link After)> { // (0: 0 0) ↦ (1: 0 0) (new Link(index: 0, source: 0, target: 0), new Link(index: 1, source: 0, target: 0)), @@ -72,92 +72,92 @@ public void SimplifyChanges_MultipleChainsFromSameBefore_RemovesIntermediateStat (new Link(index: 2, source: 0, target: 0), new Link(index: 2, source: 2, target: 1)) }; - // Expected simplified changes: - // (0: 0 0) ↦ (1: 1 2) - // (0: 0 0) ↦ (2: 2 1) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes: + // (0: 0 0) ↦ (1: 1 2) + // (0: 0 0) ↦ (2: 2 1) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 0, source: 0, target: 0), new Link(index: 1, source: 1, target: 2)), (new Link(index: 0, source: 0, target: 0), new Link(index: 2, source: 2, target: 1)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_NoChange_StillKeepsFirstAndLastState() - { - // Arrange - // These changes represent a "read" operation: the link is read but not changed. - // (1: 2 1) -> (1: 2 1) - // (2: 1 2) -> (2: 1 2) - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_NoChange_StillKeepsFirstAndLastState() + { + // Arrange + // These changes represent a "read" operation: the link is read but not changed. + // (1: 2 1) -> (1: 2 1) + // (2: 1 2) -> (2: 1 2) + var changes = new List<(Link Before, Link After)> { (new Link(index: 1, source: 2, target: 1), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 1, target: 2), new Link(index: 2, source: 1, target: 2)) }; - // Expected simplified changes: - // They are the same because no actual changes occurred, but we still keep them. - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes: + // They are the same because no actual changes occurred, but we still keep them. + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 2, target: 1), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 1, target: 2), new Link(index: 2, source: 1, target: 2)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates() - { - // Arrange - // These transitions demonstrate several branches starting from (0: 0 0), - // each ultimately reaching (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). - // The intermediate states (1: 0 0), (2: 0 0), (3: 0 0), (4: 0 0) are - // transitions on the way to the final states. - // - // Original transitions (Before -> After): - // (0: 0 0) -> (1: 0 0) - // (1: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 0 0) - // (2: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 0 0) - // (3: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 0 0) - // (4: 0 0) -> (4: 4 4) - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates() + { + // Arrange + // These transitions demonstrate several branches starting from (0: 0 0), + // each ultimately reaching (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). + // The intermediate states (1: 0 0), (2: 0 0), (3: 0 0), (4: 0 0) are + // transitions on the way to the final states. + // + // Original transitions (Before -> After): + // (0: 0 0) -> (1: 0 0) + // (1: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 0 0) + // (2: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 0 0) + // (3: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 0 0) + // (4: 0 0) -> (4: 4 4) + var changes = new List<(Link Before, Link After)> { (new Link(0, 0, 0), new Link(1, 0, 0)), (new Link(1, 0, 0), new Link(1, 1, 1)), @@ -169,12 +169,12 @@ public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinal (new Link(4, 0, 0), new Link(4, 4, 4)) }; - // Expected final transitions (After simplification): - // (0: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 4 4) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected final transitions (After simplification): + // (0: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 4 4) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(0, 0, 0), new Link(1, 1, 1)), (new Link(0, 0, 0), new Link(2, 2, 2)), @@ -182,42 +182,42 @@ public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinal (new Link(0, 0, 0), new Link(4, 4, 4)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - // We expect exactly four final states from (0: 0 0), each linking directly - // to one of the final links (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - - // Check that each expected (Before, After) pair is present in the result. - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates_InCorrectOrder() - { - // Arrange - // Original transitions (Before -> After): - // (0: 0 0) -> (1: 0 0) - // (1: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 0 0) - // (2: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 0 0) - // (3: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 0 0) - // (4: 0 0) -> (4: 4 4) - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + // We expect exactly four final states from (0: 0 0), each linking directly + // to one of the final links (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + + // Check that each expected (Before, After) pair is present in the result. + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates_InCorrectOrder() + { + // Arrange + // Original transitions (Before -> After): + // (0: 0 0) -> (1: 0 0) + // (1: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 0 0) + // (2: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 0 0) + // (3: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 0 0) + // (4: 0 0) -> (4: 4 4) + var changes = new List<(Link Before, Link After)> { (new Link(0, 0, 0), new Link(1, 0, 0)), (new Link(1, 0, 0), new Link(1, 1, 1)), @@ -229,80 +229,80 @@ public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinal (new Link(4, 0, 0), new Link(4, 4, 4)) }; - // Expected final transitions (After simplification): - // (0: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 4 4) - - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - // 1) Ensure we got exactly 4 transitions after simplification. - Assert.Equal(4, simplifiedChanges.Count); - - // 2) Check that each final pair is present (as before), - // AND that they appear in ascending order: - // first => After.Index == 1, - // second => After.Index == 2, - // third => After.Index == 3, - // fourth => After.Index == 4. - - Assert.Collection( - simplifiedChanges, - // 1st final link: (0:0 0) -> (1:1 1) - first => - { - Assert.Equal(0u, first.Before.Index); - Assert.Equal(0u, first.Before.Source); - Assert.Equal(0u, first.Before.Target); - - Assert.Equal(1u, first.After.Index); - Assert.Equal(1u, first.After.Source); - Assert.Equal(1u, first.After.Target); - }, - // 2nd final link: (0:0 0) -> (2:2 2) - second => - { - Assert.Equal(0u, second.Before.Index); - Assert.Equal(0u, second.Before.Source); - Assert.Equal(0u, second.Before.Target); - - Assert.Equal(2u, second.After.Index); - Assert.Equal(2u, second.After.Source); - Assert.Equal(2u, second.After.Target); - }, - // 3rd final link: (0:0 0) -> (3:3 3) - third => - { - Assert.Equal(0u, third.Before.Index); - Assert.Equal(0u, third.Before.Source); - Assert.Equal(0u, third.Before.Target); - - Assert.Equal(3u, third.After.Index); - Assert.Equal(3u, third.After.Source); - Assert.Equal(3u, third.After.Target); - }, - // 4th final link: (0:0 0) -> (4:4 4) - fourth => - { - Assert.Equal(0u, fourth.Before.Index); - Assert.Equal(0u, fourth.Before.Source); - Assert.Equal(0u, fourth.Before.Target); - - Assert.Equal(4u, fourth.After.Index); - Assert.Equal(4u, fourth.After.Source); - Assert.Equal(4u, fourth.After.Target); - } - ); - } - - [Fact] - public void SimplifyChanges_SpecificExample_KeepsUnchangedStates() - { - // Arrange - var changes = new List<(Link Before, Link After)> + // Expected final transitions (After simplification): + // (0: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 4 4) + + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + // 1) Ensure we got exactly 4 transitions after simplification. + Assert.Equal(4, simplifiedChanges.Count); + + // 2) Check that each final pair is present (as before), + // AND that they appear in ascending order: + // first => After.Index == 1, + // second => After.Index == 2, + // third => After.Index == 3, + // fourth => After.Index == 4. + + Assert.Collection( + simplifiedChanges, + // 1st final link: (0:0 0) -> (1:1 1) + first => + { + Assert.Equal(0u, first.Before.Index); + Assert.Equal(0u, first.Before.Source); + Assert.Equal(0u, first.Before.Target); + + Assert.Equal(1u, first.After.Index); + Assert.Equal(1u, first.After.Source); + Assert.Equal(1u, first.After.Target); + }, + // 2nd final link: (0:0 0) -> (2:2 2) + second => + { + Assert.Equal(0u, second.Before.Index); + Assert.Equal(0u, second.Before.Source); + Assert.Equal(0u, second.Before.Target); + + Assert.Equal(2u, second.After.Index); + Assert.Equal(2u, second.After.Source); + Assert.Equal(2u, second.After.Target); + }, + // 3rd final link: (0:0 0) -> (3:3 3) + third => + { + Assert.Equal(0u, third.Before.Index); + Assert.Equal(0u, third.Before.Source); + Assert.Equal(0u, third.Before.Target); + + Assert.Equal(3u, third.After.Index); + Assert.Equal(3u, third.After.Source); + Assert.Equal(3u, third.After.Target); + }, + // 4th final link: (0:0 0) -> (4:4 4) + fourth => + { + Assert.Equal(0u, fourth.Before.Index); + Assert.Equal(0u, fourth.Before.Source); + Assert.Equal(0u, fourth.Before.Target); + + Assert.Equal(4u, fourth.After.Index); + Assert.Equal(4u, fourth.After.Source); + Assert.Equal(4u, fourth.After.Target); + } + ); + } + + [Fact] + public void SimplifyChanges_SpecificExample_KeepsUnchangedStates() + { + // Arrange + var changes = new List<(Link Before, Link After)> { // (1: 1 2) ↦ (1: 2 1) (new Link(index: 1, source: 1, target: 2), new Link(index: 1, source: 2, target: 1)), @@ -311,29 +311,29 @@ public void SimplifyChanges_SpecificExample_KeepsUnchangedStates() (new Link(index: 2, source: 2, target: 2), new Link(index: 2, source: 2, target: 2)) }; - // Expected simplified changes still have (2: 2 2): - // (1: 1 2) ↦ (1: 2 1) - // (2: 2 2) ↦ (2: 2 2) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes still have (2: 2 2): + // (1: 1 2) ↦ (1: 2 1) + // (2: 2 2) ↦ (2: 2 2) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 1, target: 2), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 2, target: 2), new Link(index: 2, source: 2, target: 2)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); - // Assert - AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); - } + // Assert + AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); + } - [Fact] - public void SimplifyChanges_Issue26_UpdateOperationSimplification() - { - // Arrange - This represents the scenario described in GitHub issue #26 - // Where links (1: 1 2) and (2: 2 1) are being updated to swap source and target - // The issue was that intermediate steps were being shown instead of the final transformation - var changes = new List<(Link Before, Link After)> + [Fact] + public void SimplifyChanges_Issue26_UpdateOperationSimplification() + { + // Arrange - This represents the scenario described in GitHub issue #26 + // Where links (1: 1 2) and (2: 2 1) are being updated to swap source and target + // The issue was that intermediate steps were being shown instead of the final transformation + var changes = new List<(Link Before, Link After)> { // Step 1: Link (1: 1 2) is first deleted (becomes null/empty) (new Link(index: 1, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)), @@ -345,27 +345,27 @@ public void SimplifyChanges_Issue26_UpdateOperationSimplification() (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), }; - // Expected - The simplification should show only the initial-to-final transformations - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected - The simplification should show only the initial-to-final transformations + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 1, target: 2), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); - // Assert - AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); - } + // Assert + AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); + } - [Fact] - public void SimplifyChanges_Issue26_AlternativeScenario_NoSimplificationOccurs() - { - // Arrange - This tests a different scenario that might represent the actual issue - // Maybe the problem is that the changes are NOT being chained correctly - // Let's simulate what might happen if the simplifier doesn't work correctly - var changes = new List<(Link Before, Link After)> + [Fact] + public void SimplifyChanges_Issue26_AlternativeScenario_NoSimplificationOccurs() + { + // Arrange - This tests a different scenario that might represent the actual issue + // Maybe the problem is that the changes are NOT being chained correctly + // Let's simulate what might happen if the simplifier doesn't work correctly + var changes = new List<(Link Before, Link After)> { // Let's say we get these individual changes that don't form a proper chain (new Link(index: 1, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)), // delete @@ -373,260 +373,260 @@ public void SimplifyChanges_Issue26_AlternativeScenario_NoSimplificationOccurs() (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), // direct update }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Debug output - Console.WriteLine("=== Debug: Alternative Scenario ==="); - Console.WriteLine("Input changes:"); - for (int i = 0; i < changes.Count; i++) - { - var (b, a) = changes[i]; - Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); - } - - Console.WriteLine("Actual simplified changes:"); - for (int i = 0; i < simplifiedChanges.Count; i++) - { - var (b, a) = simplifiedChanges[i]; - Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); - } - Console.WriteLine($"Count: {simplifiedChanges.Count}"); - Console.WriteLine("=== End Debug ==="); - - // The issue might be that we get 3 changes instead of 2 - // If the simplifier doesn't work, we'd see all 3 changes + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Debug output + Console.WriteLine("=== Debug: Alternative Scenario ==="); + Console.WriteLine("Input changes:"); + for (int i = 0; i < changes.Count; i++) + { + var (b, a) = changes[i]; + Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); + } + + Console.WriteLine("Actual simplified changes:"); + for (int i = 0; i < simplifiedChanges.Count; i++) + { + var (b, a) = simplifiedChanges[i]; + Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); + } + Console.WriteLine($"Count: {simplifiedChanges.Count}"); + Console.WriteLine("=== End Debug ==="); + + // The issue might be that we get 3 changes instead of 2 + // If the simplifier doesn't work, we'd see all 3 changes + } + + private static void AssertChangeSetEqual( + List<(Link Before, Link After)> expectedSimplifiedChanges, + List<(Link Before, Link After)> simplifiedChanges + ) + { + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains( + simplifiedChanges, + actual => + actual.Before.Index == expected.Before.Index + && actual.Before.Source == expected.Before.Source + && actual.Before.Target == expected.Before.Target + && actual.After.Index == expected.After.Index + && actual.After.Source == expected.After.Source + && actual.After.Target == expected.After.Target + ); + } + } + + // [Fact] + // public void SimplifyChanges_NoChanges_ReturnsEmpty() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)>(); + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes); + + // // Assert + // Assert.Empty(simplified); + // } + + // [Fact] + // public void SimplifyChanges_SingleChange_ReturnsSameChange() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Single(simplified); + // Assert.Equal(1u, simplified[0].Before.Index); + // Assert.Equal(0u, simplified[0].Before.Source); // Assuming default values for other fields + // Assert.Equal(0u, simplified[0].Before.Target); + // Assert.Equal(new Link(1, 4, 5), simplified[0].After); + // } + + // [Fact] + // public void SimplifyChanges_MultipleNonOverlappingChanges_ReturnsAllChanges() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(2, 3, 4), new Link(2, 5, 6)), + // (new Link(3, 4, 5), new Link(3, 6, 7)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(3, simplified.Count); + + // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); + // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); + // Assert.Contains(simplified, c => c.Before.Index == 3 && c.After.Index == 3 && c.After.Source == 6 && c.After.Target == 7); + // } + + // [Fact] + // public void SimplifyChanges_MultipleOverlappingChanges_ReturnsInitialToFinal() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 4, 5), new Link(1, 6, 7)), + // (new Link(2, 3, 4), new Link(2, 5, 6)), + // (new Link(2, 5, 6), new Link(2, 0, 0)), + // (new Link(3, 4, 5), new Link(3, 6, 7)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(3, simplified.Count); + + // // Link 1: from (1,2,3) to (1,6,7) + // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 6, 7), link1.After); + + // // Link 2: from (2,3,4) to (2,0,0) + // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); + // Assert.Equal(new Link(2, 3, 4), link2.Before); + // Assert.Equal(new Link(2, 0, 0), link2.After); + + // // Link 3: from (3,4,5) to (3,6,7) + // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); + // Assert.Equal(new Link(3, 4, 5), link3.Before); + // Assert.Equal(new Link(3, 6, 7), link3.After); + // } + + // [Fact] + // public void SimplifyChanges_ComplexScenario_MixedChanges() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // // Link 1: Multiple changes + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 4, 5), new Link(1, 6, 7)), + // (new Link(1, 6, 7), new Link(1, 8, 9)), + + // // Link 2: Single change + // (new Link(2, 3, 4), new Link(2, 5, 6)), + + // // Link 3: Multiple changes + // (new Link(3, 4, 5), new Link(3, 6, 7)), + // (new Link(3, 6, 7), new Link(3, 0, 0)), + + // // Link 4: No changes + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(3, simplified.Count); + + // // Link 1: from (1,2,3) to (1,8,9) + // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 8, 9), link1.After); + + // // Link 2: from (2,3,4) to (2,5,6) + // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); + // Assert.Equal(new Link(2, 3, 4), link2.Before); + // Assert.Equal(new Link(2, 5, 6), link2.After); + + // // Link 3: from (3,4,5) to (3,0,0) + // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); + // Assert.Equal(new Link(3, 4, 5), link3.Before); + // Assert.Equal(new Link(3, 0, 0), link3.After); + // } + + // [Fact] + // public void SimplifyChanges_SameAfterMultipleChanges_ReturnsLastChange() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 4, 5), new Link(1, 6, 7)), + // (new Link(1, 6, 7), new Link(1, 4, 5)) // Reverting back + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Single(simplified); + + // // Link 1: from (1,2,3) to (1,4,5) + // var link1 = simplified.First(); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 4, 5), link1.After); + // } + + // [Fact] + // public void SimplifyChanges_DuplicateChanges_IgnoresDuplicates() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 2, 3), new Link(1, 4, 5)), // Duplicate + // (new Link(2, 3, 4), new Link(2, 5, 6)), + // (new Link(2, 3, 4), new Link(2, 5, 6)) // Duplicate + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(2, simplified.Count); + + // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); + // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); + // } + + // [Fact] + // public void SimplifyChanges_NullChanges_ThrowsException() + // { + // // Arrange + // List<(Link Before, Link After)> changes = null; + + // // Act & Assert + // Assert.Throws(() => ChangesSimplifier.SimplifyChanges(changes).ToList()); + // } + + // [Fact] + // public void SimplifyChanges_ChangesWithSameBeforeDifferentAfter_LastAfterIsRetained() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 2, 3), new Link(1, 6, 7)), + // (new Link(1, 2, 3), new Link(1, 8, 9)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Single(simplified); + + // var link1 = simplified.First(); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 8, 9), link1.After); // Last change is retained + // } } - - private static void AssertChangeSetEqual( - List<(Link Before, Link After)> expectedSimplifiedChanges, - List<(Link Before, Link After)> simplifiedChanges - ) - { - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains( - simplifiedChanges, - actual => - actual.Before.Index == expected.Before.Index - && actual.Before.Source == expected.Before.Source - && actual.Before.Target == expected.Before.Target - && actual.After.Index == expected.After.Index - && actual.After.Source == expected.After.Source - && actual.After.Target == expected.After.Target - ); - } - } - - // [Fact] - // public void SimplifyChanges_NoChanges_ReturnsEmpty() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)>(); - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes); - - // // Assert - // Assert.Empty(simplified); - // } - - // [Fact] - // public void SimplifyChanges_SingleChange_ReturnsSameChange() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Single(simplified); - // Assert.Equal(1u, simplified[0].Before.Index); - // Assert.Equal(0u, simplified[0].Before.Source); // Assuming default values for other fields - // Assert.Equal(0u, simplified[0].Before.Target); - // Assert.Equal(new Link(1, 4, 5), simplified[0].After); - // } - - // [Fact] - // public void SimplifyChanges_MultipleNonOverlappingChanges_ReturnsAllChanges() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(2, 3, 4), new Link(2, 5, 6)), - // (new Link(3, 4, 5), new Link(3, 6, 7)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(3, simplified.Count); - - // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); - // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); - // Assert.Contains(simplified, c => c.Before.Index == 3 && c.After.Index == 3 && c.After.Source == 6 && c.After.Target == 7); - // } - - // [Fact] - // public void SimplifyChanges_MultipleOverlappingChanges_ReturnsInitialToFinal() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 4, 5), new Link(1, 6, 7)), - // (new Link(2, 3, 4), new Link(2, 5, 6)), - // (new Link(2, 5, 6), new Link(2, 0, 0)), - // (new Link(3, 4, 5), new Link(3, 6, 7)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(3, simplified.Count); - - // // Link 1: from (1,2,3) to (1,6,7) - // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 6, 7), link1.After); - - // // Link 2: from (2,3,4) to (2,0,0) - // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); - // Assert.Equal(new Link(2, 3, 4), link2.Before); - // Assert.Equal(new Link(2, 0, 0), link2.After); - - // // Link 3: from (3,4,5) to (3,6,7) - // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); - // Assert.Equal(new Link(3, 4, 5), link3.Before); - // Assert.Equal(new Link(3, 6, 7), link3.After); - // } - - // [Fact] - // public void SimplifyChanges_ComplexScenario_MixedChanges() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // // Link 1: Multiple changes - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 4, 5), new Link(1, 6, 7)), - // (new Link(1, 6, 7), new Link(1, 8, 9)), - - // // Link 2: Single change - // (new Link(2, 3, 4), new Link(2, 5, 6)), - - // // Link 3: Multiple changes - // (new Link(3, 4, 5), new Link(3, 6, 7)), - // (new Link(3, 6, 7), new Link(3, 0, 0)), - - // // Link 4: No changes - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(3, simplified.Count); - - // // Link 1: from (1,2,3) to (1,8,9) - // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 8, 9), link1.After); - - // // Link 2: from (2,3,4) to (2,5,6) - // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); - // Assert.Equal(new Link(2, 3, 4), link2.Before); - // Assert.Equal(new Link(2, 5, 6), link2.After); - - // // Link 3: from (3,4,5) to (3,0,0) - // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); - // Assert.Equal(new Link(3, 4, 5), link3.Before); - // Assert.Equal(new Link(3, 0, 0), link3.After); - // } - - // [Fact] - // public void SimplifyChanges_SameAfterMultipleChanges_ReturnsLastChange() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 4, 5), new Link(1, 6, 7)), - // (new Link(1, 6, 7), new Link(1, 4, 5)) // Reverting back - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Single(simplified); - - // // Link 1: from (1,2,3) to (1,4,5) - // var link1 = simplified.First(); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 4, 5), link1.After); - // } - - // [Fact] - // public void SimplifyChanges_DuplicateChanges_IgnoresDuplicates() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 2, 3), new Link(1, 4, 5)), // Duplicate - // (new Link(2, 3, 4), new Link(2, 5, 6)), - // (new Link(2, 3, 4), new Link(2, 5, 6)) // Duplicate - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(2, simplified.Count); - - // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); - // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); - // } - - // [Fact] - // public void SimplifyChanges_NullChanges_ThrowsException() - // { - // // Arrange - // List<(Link Before, Link After)> changes = null; - - // // Act & Assert - // Assert.Throws(() => ChangesSimplifier.SimplifyChanges(changes).ToList()); - // } - - // [Fact] - // public void SimplifyChanges_ChangesWithSameBeforeDifferentAfter_LastAfterIsRetained() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 2, 3), new Link(1, 6, 7)), - // (new Link(1, 2, 3), new Link(1, 8, 9)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Single(simplified); - - // var link1 = simplified.First(); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 8, 9), link1.After); // Last change is retained - // } - } } \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs index df0430d..8a0a91d 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs @@ -7,90 +7,90 @@ namespace Foundation.Data.Doublets.Cli.Tests { - public class Issue62ReviewCoverageTests - { - [Fact] - public void ExplicitNumericIdUpdate_CanBeReversedWithAnotherUpdate() + public class Issue62ReviewCoverageTests { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1)))"); - AssertLink(links, 1, 1, 1); + [Fact] + public void ExplicitNumericIdUpdate_CanBeReversedWithAnotherUpdate() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1)))"); + AssertLink(links, 1, 1, 1); - ProcessQuery(links, "(((1: 1 1)) ((1: 2 2)))"); - AssertLink(links, 1, 2, 2); + ProcessQuery(links, "(((1: 1 1)) ((1: 2 2)))"); + AssertLink(links, 1, 2, 2); - ProcessQuery(links, "(((1: 2 2)) ((1: 1 1)))"); - AssertLink(links, 1, 1, 1); - }); - } + ProcessQuery(links, "(((1: 2 2)) ((1: 1 1)))"); + AssertLink(links, 1, 1, 1); + }); + } - [Fact] - public void NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); - var firstChild = links.GetByName("child"); - Assert.NotEqual(links.Constants.Null, firstChild); + [Fact] + public void NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + var firstChild = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, firstChild); - ProcessQuery(links, "((child: father mother)) ()"); - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.Null(links.GetName(firstChild)); + ProcessQuery(links, "((child: father mother)) ()"); + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.Null(links.GetName(firstChild)); - ProcessQuery(links, "(() ((child: father mother)))"); - var recreatedChild = links.GetByName("child"); - Assert.NotEqual(links.Constants.Null, recreatedChild); - Assert.Equal("child", links.GetName(recreatedChild)); - }); - } + ProcessQuery(links, "(() ((child: father mother)))"); + var recreatedChild = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, recreatedChild); + Assert.Equal("child", links.GetName(recreatedChild)); + }); + } - private static void RunTestWithLinks(Action> testAction) - { - var tempDbFile = Path.GetTempFileName(); - var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); - try - { - // Disposed at the end of the try block, before the finally deletes the backing files: - // Windows refuses to delete a file that is still memory-mapped. - using var links = new NamedTypesDecorator(tempDbFile); - testAction(links); - } - finally - { - if (File.Exists(namesDbFile)) + private static void RunTestWithLinks(Action> testAction) { - File.Delete(namesDbFile); + var tempDbFile = Path.GetTempFileName(); + var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); + try + { + // Disposed at the end of the try block, before the finally deletes the backing files: + // Windows refuses to delete a file that is still memory-mapped. + using var links = new NamedTypesDecorator(tempDbFile); + testAction(links); + } + finally + { + if (File.Exists(namesDbFile)) + { + File.Delete(namesDbFile); + } + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + } } - if (File.Exists(tempDbFile)) + + private static void ProcessQuery(NamedTypesDecorator links, string query) { - File.Delete(tempDbFile); + AdvancedMixedQueryProcessor.ProcessQuery( + links, + new AdvancedMixedQueryProcessor.Options + { + Query = query, + AutoCreateMissingReferences = true + }); } - } - } - private static void ProcessQuery(NamedTypesDecorator links, string query) - { - AdvancedMixedQueryProcessor.ProcessQuery( - links, - new AdvancedMixedQueryProcessor.Options + private static void AssertLink(NamedTypesDecorator links, uint index, uint source, uint target) { - Query = query, - AutoCreateMissingReferences = true - }); - } + var any = links.Constants.Any; + var allLinks = links.All(new DoubletLink(any, any, any)) + .Select(link => new DoubletLink(link)) + .ToList(); - private static void AssertLink(NamedTypesDecorator links, uint index, uint source, uint target) - { - var any = links.Constants.Any; - var allLinks = links.All(new DoubletLink(any, any, any)) - .Select(link => new DoubletLink(link)) - .ToList(); - - var formattedLinks = string.Join(" ", allLinks.Select(link => $"({link.Index}: {link.Source}->{link.Target})")); - Assert.True( - allLinks.Any(link => link.Index == index && link.Source == source && link.Target == target), - $"Expected link ({index}: {source}->{target}) but found: {formattedLinks}"); + var formattedLinks = string.Join(" ", allLinks.Select(link => $"({link.Index}: {link.Source}->{link.Target})")); + Assert.True( + allLinks.Any(link => link.Index == index && link.Source == source && link.Target == target), + $"Expected link ({index}: {source}->{target}) but found: {formattedLinks}"); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs index 3f88edb..4ac9f16 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs @@ -5,109 +5,109 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class PersistentTransformationDecoratorTests - { - [Fact] - public void AlwaysTriggerIsStoredInLinksAndAppliedAfterWrite() + public class PersistentTransformationDecoratorTests { - RunWithPersistentLinks((links, triggerLinks) => - { - links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); - - var allTriggerLinks = AllLinks(triggerLinks); - var alwaysId = triggerLinks.GetByName("Always"); - Assert.NotEqual(triggerLinks.Constants.Null, alwaysId); - Assert.Contains(allTriggerLinks, link => link.Source == alwaysId && link.Target != alwaysId); - - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + [Fact] + public void AlwaysTriggerIsStoredInLinksAndAppliedAfterWrite() { - Query = "(() ((1: 1 1)))", - AutoCreateMissingReferences = true - }); - - Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 2); - }); - } - - [Fact] - public void OnceTriggerDeletesItselfAfterFirstMatch() - { - RunWithPersistentLinks((links, triggerLinks) => - { - links.StoreTrigger(PersistentTransformationKind.Once, "(((1: 1 1)) ((1: 1 2)))"); - - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); + + var allTriggerLinks = AllLinks(triggerLinks); + var alwaysId = triggerLinks.GetByName("Always"); + Assert.NotEqual(triggerLinks.Constants.Null, alwaysId); + Assert.Contains(allTriggerLinks, link => link.Source == alwaysId && link.Target != alwaysId); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(() ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 2); + }); + } + + [Fact] + public void OnceTriggerDeletesItselfAfterFirstMatch() { - Query = "(() ((1: 1 1)))", - AutoCreateMissingReferences = true - }); - - Assert.DoesNotContain(links.GetTriggers(), trigger => trigger.Kind == PersistentTransformationKind.Once); - - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Once, "(((1: 1 1)) ((1: 1 2)))"); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(() ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.DoesNotContain(links.GetTriggers(), trigger => trigger.Kind == PersistentTransformationKind.Once); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(((1: 1 2)) ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 1); + }); + } + + [Fact] + public void NeverRemovesMatchingStoredTrigger() { - Query = "(((1: 1 2)) ((1: 1 1)))", - AutoCreateMissingReferences = true - }); - - Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 1); - }); - } + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); - [Fact] - public void NeverRemovesMatchingStoredTrigger() - { - RunWithPersistentLinks((links, triggerLinks) => - { - links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); - - var removed = links.RemoveTriggers("(((1: 1 1)) ((1: 1 2)))"); + var removed = links.RemoveTriggers("(((1: 1 1)) ((1: 1 2)))"); - Assert.Equal(1, removed); - Assert.Empty(links.GetTriggers()); - }); - } + Assert.Equal(1, removed); + Assert.Empty(links.GetTriggers()); + }); + } - private static void RunWithPersistentLinks(Action> action) - { - var dataFile = Path.GetTempFileName(); - var triggerFile = Path.GetTempFileName(); - var dataNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile); - var triggerNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(triggerFile); - try - { - // Both decorators are disposed at the end of the try block, before the finally deletes the - // backing files: Windows refuses to delete a file that is still memory-mapped. - using var dataLinks = new NamedTypesDecorator(dataFile); - using var triggerLinks = new NamedTypesDecorator(triggerFile); - var links = new PersistentTransformationDecorator(dataLinks, triggerLinks) + private static void RunWithPersistentLinks(Action> action) { - AutoCreateMissingReferences = true - }; - - action(links, triggerLinks); - } - finally - { - DeleteIfExists(dataFile); - DeleteIfExists(triggerFile); - DeleteIfExists(dataNamesFile); - DeleteIfExists(triggerNamesFile); - } - } - - private static List AllLinks(INamedTypesLinks links) - { - var any = links.Constants.Any; - return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); - } + var dataFile = Path.GetTempFileName(); + var triggerFile = Path.GetTempFileName(); + var dataNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile); + var triggerNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(triggerFile); + try + { + // Both decorators are disposed at the end of the try block, before the finally deletes the + // backing files: Windows refuses to delete a file that is still memory-mapped. + using var dataLinks = new NamedTypesDecorator(dataFile); + using var triggerLinks = new NamedTypesDecorator(triggerFile); + var links = new PersistentTransformationDecorator(dataLinks, triggerLinks) + { + AutoCreateMissingReferences = true + }; + + action(links, triggerLinks); + } + finally + { + DeleteIfExists(dataFile); + DeleteIfExists(triggerFile); + DeleteIfExists(dataNamesFile); + DeleteIfExists(triggerNamesFile); + } + } + + private static List AllLinks(INamedTypesLinks links) + { + var any = links.Constants.Any; + return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); + } - private static void DeleteIfExists(string path) - { - if (File.Exists(path)) - { - File.Delete(path); - } + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs index bcd2fad..ea0d394 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs @@ -84,7 +84,7 @@ public void Should_Validate_Existing_Links() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act var result = new List(); foreach (var type in pinnedTypes.Take(numberOfTypes)) @@ -123,7 +123,7 @@ public void Should_Throw_Exception_For_Invalid_Link_Structure() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act & Assert var exception = Assert.Throws(() => { @@ -155,7 +155,7 @@ public void Should_Reset_Enumerator() var enumerator = pinnedTypes.GetEnumerator(); var allLinks = links.All(); - + // Act enumerator.MoveNext(); var first = enumerator.Current; @@ -192,7 +192,7 @@ public void Should_Validate_Existing_Links_With_Ulong() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act var result = new List(); foreach (var type in pinnedTypes.Take(numberOfTypes)) @@ -223,7 +223,7 @@ public void Should_Create_And_Iterate_Over_Types_With_RealDataStore() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act var result = new List(); foreach (var type in pinnedTypes.Take(numberOfTypes)) diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs index 69888c4..54f4463 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs @@ -79,7 +79,8 @@ public void DeleteAfterGetOrCreate_DoesNotThrow_WithTracing() { var tempDbFile = Path.GetTempFileName(); var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); - try { + try + { using var decorator = new SimpleLinksDecorator(tempDbFile, true); var source = 1u; var target = 1u; @@ -94,4 +95,4 @@ public void DeleteAfterGetOrCreate_DoesNotThrow_WithTracing() } } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs index ef214e9..b7612d8 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs @@ -5,268 +5,268 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class TransactionsDecoratorTests - { - [Fact] - public void AutoTransactionRecordsCreateAndUpdate() + public class TransactionsDecoratorTests { - // CreateAndUpdate is an extension that calls Create then Update on - // the doublets store. Each emits a transition. - RunWithTransactions((tx, _) => - { - var created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - Assert.NotEqual(tx.Constants.Null, created); + [Fact] + public void AutoTransactionRecordsCreateAndUpdate() + { + // CreateAndUpdate is an extension that calls Create then Update on + // the doublets store. Each emits a transition. + RunWithTransactions((tx, _) => + { + var created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + Assert.NotEqual(tx.Constants.Null, created); - var log = tx.Log; - Assert.Equal(2, log.Count); - Assert.Equal(TransitionKind.Create, log[0].Kind); - Assert.Equal(TransitionKind.Update, log[1].Kind); - Assert.Equal(created, log[0].After.Index); - }); - } + var log = tx.Log; + Assert.Equal(2, log.Count); + Assert.Equal(TransitionKind.Create, log[0].Kind); + Assert.Equal(TransitionKind.Update, log[1].Kind); + Assert.Equal(created, log[0].After.Index); + }); + } - [Fact] - public void RollbackUndoesCreate() - { - RunWithTransactions((tx, _) => - { - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void RollbackUndoesCreate() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - Assert.True(tx.Exists(created)); - transaction.Rollback(); - } + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + Assert.True(tx.Exists(created)); + transaction.Rollback(); + } - Assert.False(tx.Exists(created), "Rolled-back create must remove the link."); - }); - } + Assert.False(tx.Exists(created), "Rolled-back create must remove the link."); + }); + } - [Fact] - public void DisposeWithoutCommitRollsBack() - { - RunWithTransactions((tx, _) => - { - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void DisposeWithoutCommitRollsBack() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - } + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } - Assert.False(tx.Exists(created), "Disposing an open transaction must rollback (R10)."); - }); - } + Assert.False(tx.Exists(created), "Disposing an open transaction must rollback (R10)."); + }); + } - [Fact] - public void CommitPersistsCreate() - { - RunWithTransactions((tx, _) => - { - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void CommitPersistsCreate() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - transaction.Commit(); + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + transaction.Commit(); + } + + Assert.True(tx.Exists(created)); + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + }); } - Assert.True(tx.Exists(created)); - Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); - }); - } + [Fact] + public void RollbackUndoesUpdate() + { + RunWithTransactions((tx, _) => + { + var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - [Fact] - public void RollbackUndoesUpdate() - { - RunWithTransactions((tx, _) => - { - var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + using (var transaction = tx.BeginTransaction()) + { + tx.Update( + new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), + new DoubletLink(c, a, b), + null); + var updated = new DoubletLink(tx.GetLink(c)); + Assert.Equal(a, updated.Source); + Assert.Equal(b, updated.Target); + transaction.Rollback(); + } - using (var transaction = tx.BeginTransaction()) + var afterRollback = new DoubletLink(tx.GetLink(c)); + Assert.Equal(c, afterRollback.Index); + Assert.Equal(tx.Constants.Null, afterRollback.Source); + Assert.Equal(tx.Constants.Null, afterRollback.Target); + }); + } + + [Fact] + public void RollbackUndoesDelete() { - tx.Update( + RunWithTransactions((tx, _) => + { + var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + tx.Update( new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), new DoubletLink(c, a, b), null); - var updated = new DoubletLink(tx.GetLink(c)); - Assert.Equal(a, updated.Source); - Assert.Equal(b, updated.Target); - transaction.Rollback(); - } - var afterRollback = new DoubletLink(tx.GetLink(c)); - Assert.Equal(c, afterRollback.Index); - Assert.Equal(tx.Constants.Null, afterRollback.Source); - Assert.Equal(tx.Constants.Null, afterRollback.Target); - }); - } + using (var transaction = tx.BeginTransaction()) + { + tx.Delete(new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), null); + Assert.False(tx.Exists(c)); + transaction.Rollback(); + } - [Fact] - public void RollbackUndoesDelete() - { - RunWithTransactions((tx, _) => - { - var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - tx.Update( - new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), - new DoubletLink(c, a, b), - null); + Assert.True(tx.Exists(c), "Delete must be restored by rollback."); + var restored = new DoubletLink(tx.GetLink(c)); + Assert.Equal(a, restored.Source); + Assert.Equal(b, restored.Target); + }); + } - using (var transaction = tx.BeginTransaction()) + [Fact] + public void SizedRetentionDropsOldestAfterApplied() { - tx.Delete(new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), null); - Assert.False(tx.Exists(c)); - transaction.Rollback(); - } + RunWithTransactions((tx, _) => + { + tx.RetentionPolicy = new LogRetentionPolicy.Sized(MaxTransitions: 3); + for (var i = 0; i < 5; i++) + { + tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } - Assert.True(tx.Exists(c), "Delete must be restored by rollback."); - var restored = new DoubletLink(tx.GetLink(c)); - Assert.Equal(a, restored.Source); - Assert.Equal(b, restored.Target); - }); - } + Assert.True(tx.Log.Count <= 3, $"Sized retention must cap log length; got {tx.Log.Count}."); + }); + } - [Fact] - public void SizedRetentionDropsOldestAfterApplied() - { - RunWithTransactions((tx, _) => - { - tx.RetentionPolicy = new LogRetentionPolicy.Sized(MaxTransitions: 3); - for (var i = 0; i < 5; i++) + [Fact] + public void ChunkedRetentionArchivesOldest() { - tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - } + var archiveDir = Path.Combine(Path.GetTempPath(), $"tx-archive-{Guid.NewGuid():N}"); + try + { + RunWithTransactions((tx, _) => + { + tx.RetentionPolicy = new LogRetentionPolicy.Chunked(ChunkSize: 2, ArchiveDirectory: archiveDir); + for (var i = 0; i < 4; i++) + { + tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } - Assert.True(tx.Log.Count <= 3, $"Sized retention must cap log length; got {tx.Log.Count}."); - }); - } + Assert.True(Directory.Exists(archiveDir)); + var files = Directory.EnumerateFiles(archiveDir, "transitions-chunk-*.log").ToList(); + Assert.NotEmpty(files); + }); + } + finally + { + if (Directory.Exists(archiveDir)) Directory.Delete(archiveDir, recursive: true); + } + } - [Fact] - public void ChunkedRetentionArchivesOldest() - { - var archiveDir = Path.Combine(Path.GetTempPath(), $"tx-archive-{Guid.NewGuid():N}"); - try - { - RunWithTransactions((tx, _) => + [Fact] + public void RetentionPolicyParsesSpecs() { - tx.RetentionPolicy = new LogRetentionPolicy.Chunked(ChunkSize: 2, ArchiveDirectory: archiveDir); - for (var i = 0; i < 4; i++) - { - tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - } + Assert.IsType(LogRetentionPolicy.Parse("infinite")); + Assert.IsType(LogRetentionPolicy.Parse("sized:1000")); + Assert.IsType(LogRetentionPolicy.Parse("chunked:500:/tmp/x")); + Assert.Throws(() => LogRetentionPolicy.Parse("garbage")); + } - Assert.True(Directory.Exists(archiveDir)); - var files = Directory.EnumerateFiles(archiveDir, "transitions-chunk-*.log").ToList(); - Assert.NotEmpty(files); - }); - } - finally - { - if (Directory.Exists(archiveDir)) Directory.Delete(archiveDir, recursive: true); - } - } + [Fact] + public void TransitionRoundTripsThroughSerialize() + { + var t = new Transition( + Guid.NewGuid(), + Sequence: 42, + Timestamp: DateTimeOffset.FromUnixTimeMilliseconds(1234567890000), + Kind: TransitionKind.Update, + Before: new DoubletLink(1, 2, 3), + After: new DoubletLink(1, 4, 5)); - [Fact] - public void RetentionPolicyParsesSpecs() - { - Assert.IsType(LogRetentionPolicy.Parse("infinite")); - Assert.IsType(LogRetentionPolicy.Parse("sized:1000")); - Assert.IsType(LogRetentionPolicy.Parse("chunked:500:/tmp/x")); - Assert.Throws(() => LogRetentionPolicy.Parse("garbage")); - } + Assert.True(Transition.TryParse(t.Serialize(), out var parsed)); + Assert.Equal(t, parsed); + } - [Fact] - public void TransitionRoundTripsThroughSerialize() - { - var t = new Transition( - Guid.NewGuid(), - Sequence: 42, - Timestamp: DateTimeOffset.FromUnixTimeMilliseconds(1234567890000), - Kind: TransitionKind.Update, - Before: new DoubletLink(1, 2, 3), - After: new DoubletLink(1, 4, 5)); + [Fact] + public void AsyncCommitEventuallyMarksApplied() + { + RunWithTransactions((tx, _) => + { + tx.CommitMode = CommitMode.Async; + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + transaction.CommitAsync().GetAwaiter().GetResult(); + } - Assert.True(Transition.TryParse(t.Serialize(), out var parsed)); - Assert.Equal(t, parsed); - } + // Allow background worker time to drain. + var deadline = DateTime.UtcNow.AddSeconds(5); + while (tx.AppliedSequence < tx.LastLoggedSequence && DateTime.UtcNow < deadline) + { + Thread.Sleep(50); + } + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + Assert.True(tx.Exists(created)); + }); + } - [Fact] - public void AsyncCommitEventuallyMarksApplied() - { - RunWithTransactions((tx, _) => - { - tx.CommitMode = CommitMode.Async; - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void NoBehaviourChangeWhenNotOptedIn() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - transaction.CommitAsync().GetAwaiter().GetResult(); + // Acceptance for R8: bare NamedTypesDecorator behaves identically + // whether or not TransactionsDecorator is wrapped above it. + var dataFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + var created = dataLinks.CreateAndUpdate(dataLinks.Constants.Null, dataLinks.Constants.Null); + Assert.True(dataLinks.Exists(created)); + } + finally + { + dataLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + } } - // Allow background worker time to drain. - var deadline = DateTime.UtcNow.AddSeconds(5); - while (tx.AppliedSequence < tx.LastLoggedSequence && DateTime.UtcNow < deadline) + private static void RunWithTransactions(Action> action) { - Thread.Sleep(50); + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + TransactionsDecorator? tx = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + action(tx, dataLinks); + } + finally + { + tx?.Dispose(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + } } - Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); - Assert.True(tx.Exists(created)); - }); - } - - [Fact] - public void NoBehaviourChangeWhenNotOptedIn() - { - // Acceptance for R8: bare NamedTypesDecorator behaves identically - // whether or not TransactionsDecorator is wrapped above it. - var dataFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - try - { - dataLinks = new NamedTypesDecorator(dataFile); - var created = dataLinks.CreateAndUpdate(dataLinks.Constants.Null, dataLinks.Constants.Null); - Assert.True(dataLinks.Exists(created)); - } - finally - { - dataLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - } - } - private static void RunWithTransactions(Action> action) - { - var dataFile = Path.GetTempFileName(); - var logFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - NamedTypesDecorator? logLinks = null; - TransactionsDecorator? tx = null; - try - { - dataLinks = new NamedTypesDecorator(dataFile); - logLinks = new NamedTypesDecorator(logFile); - tx = new TransactionsDecorator(dataLinks, logLinks); - action(tx, dataLinks); - } - finally - { - tx?.Dispose(); - dataLinks?.Dispose(); - logLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(logFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); - } - } - - private static void Cleanup(string path) - { - if (File.Exists(path)) File.Delete(path); + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs index 026e5e7..b4f6eed 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs @@ -5,375 +5,375 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class VersionControlDecoratorTests - { - [Fact] - public void DefaultBranchExistsOnFirstOpen() + public class VersionControlDecoratorTests { - RunWithVc((vc, _, _) => - { - Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); - var branches = vc.ListBranches(); - Assert.Single(branches); - Assert.Equal(VersionControlDecorator.DefaultBranchName, branches[0].Name); - }); - } - - [Fact] - public void NewTransitionsAreAttributedToCurrentBranch() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var head = tx.LastLoggedSequence; - Assert.True(head >= 2, $"CreateAndUpdate must produce at least two transitions (got {head})."); - Assert.Equal(head, vc.CurrentSequence); - }); - } - - [Fact] - public void CheckoutToZeroRewindsEverything() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b)); - - vc.Checkout(0); - - Assert.False(vc.Exists(a), "All links must be rewound after checkout 0."); - Assert.False(vc.Exists(b)); - Assert.Equal(0, vc.CurrentSequence); - }); - } - - [Fact] - public void CheckoutAndForwardReplayRestoresState() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var afterFirst = tx.LastLoggedSequence; - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var afterSecond = tx.LastLoggedSequence; - - vc.Checkout(afterFirst); - Assert.True(vc.Exists(a), "First link must remain after partial rewind."); - Assert.False(vc.Exists(b), "Second link must disappear after partial rewind."); - - vc.Checkout(afterSecond); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b), "Second link must reappear after forward checkout."); - }); - } + [Fact] + public void DefaultBranchExistsOnFirstOpen() + { + RunWithVc((vc, _, _) => + { + Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); + var branches = vc.ListBranches(); + Assert.Single(branches); + Assert.Equal(VersionControlDecorator.DefaultBranchName, branches[0].Name); + }); + } - [Fact] - public void BranchForksFromCurrentHead() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var headBeforeBranch = vc.CurrentSequence; - - vc.Branch("feature"); - Assert.Contains(vc.ListBranches(), b => b.Name == "feature"); - }); - } + [Fact] + public void NewTransitionsAreAttributedToCurrentBranch() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var head = tx.LastLoggedSequence; + Assert.True(head >= 2, $"CreateAndUpdate must produce at least two transitions (got {head})."); + Assert.Equal(head, vc.CurrentSequence); + }); + } - [Fact] - public void SwitchBranchAppliesAndRewindsTransitions() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var headBeforeBranch = vc.CurrentSequence; - - vc.Branch("feature"); - vc.SwitchBranch("feature"); - Assert.Equal("feature", vc.CurrentBranch); - - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - Assert.True(vc.Exists(b)); - var featureHead = vc.CurrentSequence; - - vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); - Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); - Assert.True(vc.Exists(a), "Main-branch link must remain after switching back."); - Assert.False(vc.Exists(b), "Feature-branch link must disappear after switching back to main."); - Assert.Equal(headBeforeBranch, vc.CurrentSequence); - - vc.SwitchBranch("feature"); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b), "Feature-branch link must reappear after switching back to feature."); - Assert.Equal(featureHead, vc.CurrentSequence); - }); - } + [Fact] + public void CheckoutToZeroRewindsEverything() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + + vc.Checkout(0); + + Assert.False(vc.Exists(a), "All links must be rewound after checkout 0."); + Assert.False(vc.Exists(b)); + Assert.Equal(0, vc.CurrentSequence); + }); + } - [Fact] - public void TagPointsToCurrentHead() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Tag("v1"); - Assert.True(vc.TryGetTag("v1", out var seq)); - Assert.Equal(vc.CurrentSequence, seq); - Assert.Contains("v1", vc.ListTags().Keys); - }); - } + [Fact] + public void CheckoutAndForwardReplayRestoresState() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var afterFirst = tx.LastLoggedSequence; + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var afterSecond = tx.LastLoggedSequence; + + vc.Checkout(afterFirst); + Assert.True(vc.Exists(a), "First link must remain after partial rewind."); + Assert.False(vc.Exists(b), "Second link must disappear after partial rewind."); + + vc.Checkout(afterSecond); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b), "Second link must reappear after forward checkout."); + }); + } - [Fact] - public void BranchFromExplicitSeqUsesGivenPoint() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var firstHead = vc.CurrentSequence; - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - - vc.Branch("backport", from: firstHead); - var branchInfo = vc.ListBranches().Single(b => b.Name == "backport"); - Assert.Equal(firstHead, branchInfo.ForkSeq); - }); - } + [Fact] + public void BranchForksFromCurrentHead() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var headBeforeBranch = vc.CurrentSequence; + + vc.Branch("feature"); + Assert.Contains(vc.ListBranches(), b => b.Name == "feature"); + }); + } - [Fact] - public void RecoverRebuildsStateFromBranchesStore() - { - // Recovery is exercised here by attaching a *second* VC decorator - // to the same live branches store, which is equivalent in behaviour - // to reopening the underlying file (the file-mapped store is shared). - RunWithVc((vc, _, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Tag("checkpoint"); - vc.Branch("feature"); - - // Force a fresh decorator over the same in-process VC store. - var branchesStore = GetBranchesStore(vc); - var transactions = GetTransactions(vc); - var reopened = new VersionControlDecorator(transactions, branchesStore); - Assert.Contains(reopened.ListBranches(), b => b.Name == "feature"); - Assert.True(reopened.TryGetTag("checkpoint", out _)); - }); - } + [Fact] + public void SwitchBranchAppliesAndRewindsTransitions() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var headBeforeBranch = vc.CurrentSequence; + + vc.Branch("feature"); + vc.SwitchBranch("feature"); + Assert.Equal("feature", vc.CurrentBranch); + + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.True(vc.Exists(b)); + var featureHead = vc.CurrentSequence; + + vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); + Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); + Assert.True(vc.Exists(a), "Main-branch link must remain after switching back."); + Assert.False(vc.Exists(b), "Feature-branch link must disappear after switching back to main."); + Assert.Equal(headBeforeBranch, vc.CurrentSequence); + + vc.SwitchBranch("feature"); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b), "Feature-branch link must reappear after switching back to feature."); + Assert.Equal(featureHead, vc.CurrentSequence); + }); + } - private static INamedTypesLinks GetBranchesStore(VersionControlDecorator vc) - { - return (INamedTypesLinks)typeof(VersionControlDecorator) - .GetField("_branchesStore", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! - .GetValue(vc)!; - } + [Fact] + public void TagPointsToCurrentHead() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Tag("v1"); + Assert.True(vc.TryGetTag("v1", out var seq)); + Assert.Equal(vc.CurrentSequence, seq); + Assert.Contains("v1", vc.ListTags().Keys); + }); + } - private static TransactionsDecorator GetTransactions(VersionControlDecorator vc) - { - return (TransactionsDecorator)typeof(VersionControlDecorator) - .GetField("_transactions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! - .GetValue(vc)!; - } + [Fact] + public void BranchFromExplicitSeqUsesGivenPoint() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var firstHead = vc.CurrentSequence; + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + + vc.Branch("backport", from: firstHead); + var branchInfo = vc.ListBranches().Single(b => b.Name == "backport"); + Assert.Equal(firstHead, branchInfo.ForkSeq); + }); + } - [Fact] - public void CheckoutOutOfRangeThrows() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - Assert.Throws(() => vc.Checkout(999)); - }); - } + [Fact] + public void RecoverRebuildsStateFromBranchesStore() + { + // Recovery is exercised here by attaching a *second* VC decorator + // to the same live branches store, which is equivalent in behaviour + // to reopening the underlying file (the file-mapped store is shared). + RunWithVc((vc, _, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Tag("checkpoint"); + vc.Branch("feature"); + + // Force a fresh decorator over the same in-process VC store. + var branchesStore = GetBranchesStore(vc); + var transactions = GetTransactions(vc); + var reopened = new VersionControlDecorator(transactions, branchesStore); + Assert.Contains(reopened.ListBranches(), b => b.Name == "feature"); + Assert.True(reopened.TryGetTag("checkpoint", out _)); + }); + } - [Fact] - public void DuplicateBranchThrows() - { - RunWithVc((vc, _, _) => - { - vc.Branch("feature"); - Assert.Throws(() => vc.Branch("feature")); - }); - } + private static INamedTypesLinks GetBranchesStore(VersionControlDecorator vc) + { + return (INamedTypesLinks)typeof(VersionControlDecorator) + .GetField("_branchesStore", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(vc)!; + } - [Fact] - public void FullStackAcidRollbackIsAtomicAndIsolated() - { - RunWithVc((vc, _, _) => - { - var baseline = Snapshot(vc); - var initialSequence = vc.CurrentSequence; + private static TransactionsDecorator GetTransactions(VersionControlDecorator vc) + { + return (TransactionsDecorator)typeof(VersionControlDecorator) + .GetField("_transactions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(vc)!; + } - using (var transaction = vc.BeginTransaction()) + [Fact] + public void CheckoutOutOfRangeThrows() { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Update( - new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), - new DoubletLink(a, b, b), - null); - - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b)); - Assert.Throws(() => vc.BeginTransaction()); - Assert.Throws(() => vc.Branch("blocked")); - - transaction.Rollback(); + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.Throws(() => vc.Checkout(999)); + }); } - Assert.Equal(initialSequence, vc.CurrentSequence); - Assert.Equal(initialSequence, vc.ListBranches().Single(b => b.Name == VersionControlDecorator.DefaultBranchName).Head); - Assert.Equal(baseline, Snapshot(vc)); - }); - } + [Fact] + public void DuplicateBranchThrows() + { + RunWithVc((vc, _, _) => + { + vc.Branch("feature"); + Assert.Throws(() => vc.Branch("feature")); + }); + } - [Fact] - public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() - { - var dataFile = Path.GetTempFileName(); - var logFile = Path.GetTempFileName(); - var vcFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - NamedTypesDecorator? logLinks = null; - NamedTypesDecorator? vcLinks = null; - TransactionsDecorator? tx = null; - NamedTypesDecorator? reopenedDataLinks = null; - NamedTypesDecorator? reopenedLogLinks = null; - NamedTypesDecorator? reopenedVcLinks = null; - TransactionsDecorator? reopenedTx = null; - - try - { - uint a; - uint b; - long committedSequence; - - dataLinks = new NamedTypesDecorator(dataFile); - logLinks = new NamedTypesDecorator(logFile); - vcLinks = new NamedTypesDecorator(vcFile); - tx = new TransactionsDecorator(dataLinks, logLinks); - using var vc = new VersionControlDecorator(tx, vcLinks); - - using (var transaction = vc.BeginTransaction()) + [Fact] + public void FullStackAcidRollbackIsAtomicAndIsolated() { - a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Update( - new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), - new DoubletLink(a, b, b), - null); - transaction.Commit(); + RunWithVc((vc, _, _) => + { + var baseline = Snapshot(vc); + var initialSequence = vc.CurrentSequence; + + using (var transaction = vc.BeginTransaction()) + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Update( + new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), + new DoubletLink(a, b, b), + null); + + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + Assert.Throws(() => vc.BeginTransaction()); + Assert.Throws(() => vc.Branch("blocked")); + + transaction.Rollback(); + } + + Assert.Equal(initialSequence, vc.CurrentSequence); + Assert.Equal(initialSequence, vc.ListBranches().Single(b => b.Name == VersionControlDecorator.DefaultBranchName).Head); + Assert.Equal(baseline, Snapshot(vc)); + }); } - committedSequence = vc.CurrentSequence; - Assert.True(committedSequence >= 5); - Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); - Assert.Equal(committedSequence, vc.ListBranches().Single(branch => branch.Name == VersionControlDecorator.DefaultBranchName).Head); - - vc.Tag("acid-commit"); - vc.Branch("audit"); - vc.SwitchBranch("audit"); - vc.Delete(new DoubletLink(b, vc.Constants.Any, vc.Constants.Any), null); - Assert.False(vc.Exists(b)); - - vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b)); - var restored = new DoubletLink(vc.GetLink(a)); - Assert.Equal(b, restored.Source); - Assert.Equal(b, restored.Target); - - tx.Dispose(); - tx = null; - dataLinks.Dispose(); - dataLinks = null; - logLinks.Dispose(); - logLinks = null; - vcLinks.Dispose(); - vcLinks = null; - - reopenedDataLinks = new NamedTypesDecorator(dataFile); - reopenedLogLinks = new NamedTypesDecorator(logFile); - reopenedVcLinks = new NamedTypesDecorator(vcFile); - reopenedTx = new TransactionsDecorator(reopenedDataLinks, reopenedLogLinks); - using var reopened = new VersionControlDecorator(reopenedTx, reopenedVcLinks); - - Assert.True(reopened.TryGetTag("acid-commit", out var tagSequence)); - Assert.Equal(committedSequence, tagSequence); - Assert.Contains(reopened.ListBranches(), branch => branch.Name == "audit"); - Assert.Equal(VersionControlDecorator.DefaultBranchName, reopened.CurrentBranch); - Assert.True(reopened.Exists(a)); - Assert.True(reopened.Exists(b)); - restored = new DoubletLink(reopened.GetLink(a)); - Assert.Equal(b, restored.Source); - Assert.Equal(b, restored.Target); - Assert.Equal(reopenedTx.LastLoggedSequence, reopenedTx.AppliedSequence); - } - finally - { - tx?.Dispose(); - reopenedTx?.Dispose(); - dataLinks?.Dispose(); - logLinks?.Dispose(); - vcLinks?.Dispose(); - reopenedDataLinks?.Dispose(); - reopenedLogLinks?.Dispose(); - reopenedVcLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(logFile); - Cleanup(vcFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); - } - } + [Fact] + public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() + { + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + var vcFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + NamedTypesDecorator? vcLinks = null; + TransactionsDecorator? tx = null; + NamedTypesDecorator? reopenedDataLinks = null; + NamedTypesDecorator? reopenedLogLinks = null; + NamedTypesDecorator? reopenedVcLinks = null; + TransactionsDecorator? reopenedTx = null; + + try + { + uint a; + uint b; + long committedSequence; + + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + vcLinks = new NamedTypesDecorator(vcFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + using var vc = new VersionControlDecorator(tx, vcLinks); + + using (var transaction = vc.BeginTransaction()) + { + a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Update( + new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), + new DoubletLink(a, b, b), + null); + transaction.Commit(); + } + + committedSequence = vc.CurrentSequence; + Assert.True(committedSequence >= 5); + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + Assert.Equal(committedSequence, vc.ListBranches().Single(branch => branch.Name == VersionControlDecorator.DefaultBranchName).Head); + + vc.Tag("acid-commit"); + vc.Branch("audit"); + vc.SwitchBranch("audit"); + vc.Delete(new DoubletLink(b, vc.Constants.Any, vc.Constants.Any), null); + Assert.False(vc.Exists(b)); + + vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + var restored = new DoubletLink(vc.GetLink(a)); + Assert.Equal(b, restored.Source); + Assert.Equal(b, restored.Target); + + tx.Dispose(); + tx = null; + dataLinks.Dispose(); + dataLinks = null; + logLinks.Dispose(); + logLinks = null; + vcLinks.Dispose(); + vcLinks = null; + + reopenedDataLinks = new NamedTypesDecorator(dataFile); + reopenedLogLinks = new NamedTypesDecorator(logFile); + reopenedVcLinks = new NamedTypesDecorator(vcFile); + reopenedTx = new TransactionsDecorator(reopenedDataLinks, reopenedLogLinks); + using var reopened = new VersionControlDecorator(reopenedTx, reopenedVcLinks); + + Assert.True(reopened.TryGetTag("acid-commit", out var tagSequence)); + Assert.Equal(committedSequence, tagSequence); + Assert.Contains(reopened.ListBranches(), branch => branch.Name == "audit"); + Assert.Equal(VersionControlDecorator.DefaultBranchName, reopened.CurrentBranch); + Assert.True(reopened.Exists(a)); + Assert.True(reopened.Exists(b)); + restored = new DoubletLink(reopened.GetLink(a)); + Assert.Equal(b, restored.Source); + Assert.Equal(b, restored.Target); + Assert.Equal(reopenedTx.LastLoggedSequence, reopenedTx.AppliedSequence); + } + finally + { + tx?.Dispose(); + reopenedTx?.Dispose(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + vcLinks?.Dispose(); + reopenedDataLinks?.Dispose(); + reopenedLogLinks?.Dispose(); + reopenedVcLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(vcFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); + } + } - private static void RunWithVc(Action> action) - { - var dataFile = Path.GetTempFileName(); - var logFile = Path.GetTempFileName(); - var vcFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - NamedTypesDecorator? logLinks = null; - NamedTypesDecorator? vcLinks = null; - TransactionsDecorator? tx = null; - try - { - dataLinks = new NamedTypesDecorator(dataFile); - logLinks = new NamedTypesDecorator(logFile); - vcLinks = new NamedTypesDecorator(vcFile); - tx = new TransactionsDecorator(dataLinks, logLinks); - using var vc = new VersionControlDecorator(tx, vcLinks); - action(vc, tx, dataLinks); - } - finally - { - tx?.Dispose(); - dataLinks?.Dispose(); - logLinks?.Dispose(); - vcLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(logFile); - Cleanup(vcFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); - } - } + private static void RunWithVc(Action> action) + { + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + var vcFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + NamedTypesDecorator? vcLinks = null; + TransactionsDecorator? tx = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + vcLinks = new NamedTypesDecorator(vcFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + using var vc = new VersionControlDecorator(tx, vcLinks); + action(vc, tx, dataLinks); + } + finally + { + tx?.Dispose(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + vcLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(vcFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); + } + } - private static void Cleanup(string path) - { - if (File.Exists(path)) File.Delete(path); - } + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } - private static IReadOnlyList Snapshot(ILinks links) - { - var any = links.Constants.Any; - var query = new DoubletLink(any, any, any); - return links.All(query) - .Select(link => new DoubletLink(link)) - .OrderBy(link => link.Index) - .ThenBy(link => link.Source) - .ThenBy(link => link.Target) - .ToArray(); + private static IReadOnlyList Snapshot(ILinks links) + { + var any = links.Constants.Any; + var query = new DoubletLink(any, any, any); + return links.All(query) + .Select(link => new DoubletLink(link)) + .OrderBy(link => link.Index) + .ThenBy(link => link.Source) + .ThenBy(link => link.Target) + .ToArray(); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index a65b64b..04bf1ed 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -11,169 +11,169 @@ var dbOption = new Option("--db", "--data-source", "--data", "-d") { - Description = "Path to the links database file", - DefaultValueFactory = _ => defaultDatabaseFilename + Description = "Path to the links database file", + DefaultValueFactory = _ => defaultDatabaseFilename }; var queryOption = new Option("--query", "--apply", "--do", "-q") { - Description = "LiNo query for CRUD operation" + Description = "LiNo query for CRUD operation" }; var queryArgument = new Argument("query") { - Description = "LiNo query for CRUD operation", - Arity = ArgumentArity.ZeroOrOne + Description = "LiNo query for CRUD operation", + Arity = ArgumentArity.ZeroOrOne }; var traceOption = new Option("--trace", "-t") { - Description = "Enable trace (verbose output)", - DefaultValueFactory = _ => false + Description = "Enable trace (verbose output)", + DefaultValueFactory = _ => false }; var autoCreateMissingReferencesOption = new Option("--auto-create-missing-references") { - Description = "Create missing numeric and named references as self-referential point links", - DefaultValueFactory = _ => false + Description = "Create missing numeric and named references as self-referential point links", + DefaultValueFactory = _ => false }; var structureOption = new Option("--structure", "-s") { - Description = "ID of the link to format its structure" + Description = "ID of the link to format its structure" }; var beforeOption = new Option("--before", "-b") { - Description = "Print the state of the database before applying changes", - DefaultValueFactory = _ => false + Description = "Print the state of the database before applying changes", + DefaultValueFactory = _ => false }; var changesOption = new Option("--changes", "-c") { - Description = "Print the changes applied by the query", - DefaultValueFactory = _ => false + Description = "Print the changes applied by the query", + DefaultValueFactory = _ => false }; var afterOption = new Option("--after", "--links", "-a") { - Description = "Print the state of the database after applying changes", - DefaultValueFactory = _ => false + Description = "Print the state of the database after applying changes", + DefaultValueFactory = _ => false }; var outputOption = new Option("--out", "--lino-output", "--export") { - Description = "Path to write the complete database as a LiNo file" + Description = "Path to write the complete database as a LiNo file" }; var alwaysOption = new Option("--always") { - Description = "Store the query as an always-on persistent transformation trigger", - DefaultValueFactory = _ => false + Description = "Store the query as an always-on persistent transformation trigger", + DefaultValueFactory = _ => false }; var onceOption = new Option("--once") { - Description = "Store the query as a persistent transformation trigger that deletes itself after it fires", - DefaultValueFactory = _ => false + Description = "Store the query as a persistent transformation trigger that deletes itself after it fires", + DefaultValueFactory = _ => false }; var neverOption = new Option("--never") { - Description = "Remove stored persistent transformation triggers matching the query", - DefaultValueFactory = _ => false + Description = "Remove stored persistent transformation triggers matching the query", + DefaultValueFactory = _ => false }; var triggersOption = new Option("--triggers") { - Description = "Enable persistent transformation triggers for this command", - DefaultValueFactory = _ => false + Description = "Enable persistent transformation triggers for this command", + DefaultValueFactory = _ => false }; var triggersFileOption = new Option("--triggers-file") { - Description = "Path to the persistent transformation trigger links database" + Description = "Path to the persistent transformation trigger links database" }; var embedTriggersOption = new Option("--embed-triggers") { - Description = "Store persistent transformation triggers directly in the main links database", - DefaultValueFactory = _ => false + Description = "Store persistent transformation triggers directly in the main links database", + DefaultValueFactory = _ => false }; var inputOption = new Option("--in", "--lino-input", "--import") { - Description = "Path to read and import a LiNo file into the database" + Description = "Path to read and import a LiNo file into the database" }; var transactionsOption = new Option("--transactions") { - Description = "Enable the transactions layer (default log path: .transitions.links)", - DefaultValueFactory = _ => false + Description = "Enable the transactions layer (default log path: .transitions.links)", + DefaultValueFactory = _ => false }; var transactionsFileOption = new Option("--transactions-file") { - Description = "Path to the transitions log store (default: .transitions.links). Implies --transactions." + Description = "Path to the transitions log store (default: .transitions.links). Implies --transactions." }; var commitModeOption = new Option("--commit-mode") { - Description = "Choose 'sync' or 'async' commits (default: sync). Implies --transactions." + Description = "Choose 'sync' or 'async' commits (default: sync). Implies --transactions." }; var retentionOption = new Option("--retention") { - Description = "Log retention policy: 'infinite', 'sized:', or 'chunked::'. Implies --transactions." + Description = "Log retention policy: 'infinite', 'sized:', or 'chunked::'. Implies --transactions." }; var vcOption = new Option("--vc") { - Description = "Enable the version-control decorator (implies --transactions)", - DefaultValueFactory = _ => false + Description = "Enable the version-control decorator (implies --transactions)", + DefaultValueFactory = _ => false }; var vcFileOption = new Option("--vc-file") { - Description = "Path to the version-control branches store (default: .versioncontrol.links)" + Description = "Path to the version-control branches store (default: .versioncontrol.links)" }; var branchOption = new Option("--branch") { - Description = "Switch to a branch (creating it if --branch-from is also passed). Implies --vc." + Description = "Switch to a branch (creating it if --branch-from is also passed). Implies --vc." }; var branchFromOption = new Option("--branch-from") { - Description = "When creating a branch with --branch, fork from this sequence point." + Description = "When creating a branch with --branch, fork from this sequence point." }; var checkoutOption = new Option("--checkout") { - Description = "Time-travel to a specific transition sequence or named tag. Implies --vc." + Description = "Time-travel to a specific transition sequence or named tag. Implies --vc." }; var tagOption = new Option("--tag") { - Description = "Create a tag in the form 'name' (at current head) or 'name='. Implies --vc." + Description = "Create a tag in the form 'name' (at current head) or 'name='. Implies --vc." }; var listBranchesOption = new Option("--list-branches") { - Description = "List version-control branches and exit.", - DefaultValueFactory = _ => false + Description = "List version-control branches and exit.", + DefaultValueFactory = _ => false }; var listTagsOption = new Option("--list-tags") { - Description = "List version-control tags and exit.", - DefaultValueFactory = _ => false + Description = "List version-control tags and exit.", + DefaultValueFactory = _ => false }; var logOption = new Option("--log") { - Description = "Print the transitions log and exit. Implies --transactions.", - DefaultValueFactory = _ => false + Description = "Print the transitions log and exit. Implies --transactions.", + DefaultValueFactory = _ => false }; var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store"); @@ -211,404 +211,404 @@ rootCommand.SetAction( parseResult => { - var db = parseResult.GetValue(dbOption)!; - var queryOptionValue = parseResult.GetValue(queryOption) ?? ""; - var queryArgumentValue = parseResult.GetValue(queryArgument) ?? ""; - var trace = parseResult.GetValue(traceOption); - var autoCreateMissingReferences = parseResult.GetValue(autoCreateMissingReferencesOption); - var structure = parseResult.GetValue(structureOption); - var before = parseResult.GetValue(beforeOption); - var changes = parseResult.GetValue(changesOption); - var after = parseResult.GetValue(afterOption); - var always = parseResult.GetValue(alwaysOption); - var once = parseResult.GetValue(onceOption); - var never = parseResult.GetValue(neverOption); - var triggers = parseResult.GetValue(triggersOption); - var triggersFile = parseResult.GetValue(triggersFileOption); - var embedTriggers = parseResult.GetValue(embedTriggersOption); - var inputPath = parseResult.GetValue(inputOption); - var outputPath = parseResult.GetValue(outputOption); - var transactionsFlag = parseResult.GetValue(transactionsOption); - var transactionsPathRaw = parseResult.GetValue(transactionsFileOption); - var commitModeRaw = parseResult.GetValue(commitModeOption); - var retentionRaw = parseResult.GetValue(retentionOption); - var vc = parseResult.GetValue(vcOption); - var vcFile = parseResult.GetValue(vcFileOption); - var branchName = parseResult.GetValue(branchOption); - var branchFrom = parseResult.GetValue(branchFromOption); - var checkoutPoint = parseResult.GetValue(checkoutOption); - var tagSpec = parseResult.GetValue(tagOption); - var listBranches = parseResult.GetValue(listBranchesOption); - var listTags = parseResult.GetValue(listTagsOption); - var showLog = parseResult.GetValue(logOption); - - var triggerCommandCount = new[] { always, once, never }.Count(value => value); - if (triggerCommandCount > 1) - { - Console.Error.WriteLine("Only one of --always, --once, or --never can be used at a time."); - return 1; - } - - var vcRequested = vc - || !string.IsNullOrWhiteSpace(vcFile) - || !string.IsNullOrWhiteSpace(branchName) - || branchFrom.HasValue - || !string.IsNullOrWhiteSpace(checkoutPoint) - || !string.IsNullOrWhiteSpace(tagSpec) - || listBranches - || listTags; - - var transactionsRequested = transactionsFlag - || !string.IsNullOrWhiteSpace(transactionsPathRaw) - || !string.IsNullOrWhiteSpace(commitModeRaw) - || !string.IsNullOrWhiteSpace(retentionRaw) - || showLog - || vcRequested; - - CommitMode commitMode = CommitMode.Sync; - if (!string.IsNullOrWhiteSpace(commitModeRaw)) - { - if (commitModeRaw.Equals("sync", StringComparison.OrdinalIgnoreCase)) - { - commitMode = CommitMode.Sync; - } - else if (commitModeRaw.Equals("async", StringComparison.OrdinalIgnoreCase)) - { - commitMode = CommitMode.Async; - } - else - { - Console.Error.WriteLine($"Invalid --commit-mode value '{commitModeRaw}'. Use 'sync' or 'async'."); - return 1; - } - } - - LogRetentionPolicy? retentionPolicy = null; - if (!string.IsNullOrWhiteSpace(retentionRaw)) - { - try - { - retentionPolicy = LogRetentionPolicy.Parse(retentionRaw); - } - catch (ArgumentException ex) - { - Console.Error.WriteLine($"Invalid --retention value: {ex.Message}"); - return 1; - } - } - - var baseLinks = new NamedTypesDecorator(db, trace); - INamedTypesLinks decoratedLinks = baseLinks; - NamedTypesDecorator? transitionsStore = null; - NamedTypesDecorator? vcBranchesStore = null; - TransactionsDecorator? transactionsLinks = null; - VersionControlDecorator? vcLinks = null; - - if (transactionsRequested) - { - var effectiveTransactionsFile = !string.IsNullOrWhiteSpace(transactionsPathRaw) - ? transactionsPathRaw - : TransactionsDecorator.MakeTransitionsDatabaseFilename(db); - transitionsStore = new NamedTypesDecorator(effectiveTransactionsFile, trace); - transactionsLinks = new TransactionsDecorator( - baseLinks, - transitionsStore, - retentionPolicy, - commitMode, - trace); - decoratedLinks = transactionsLinks; - } - - if (vcRequested) - { - if (transactionsLinks is null) + var db = parseResult.GetValue(dbOption)!; + var queryOptionValue = parseResult.GetValue(queryOption) ?? ""; + var queryArgumentValue = parseResult.GetValue(queryArgument) ?? ""; + var trace = parseResult.GetValue(traceOption); + var autoCreateMissingReferences = parseResult.GetValue(autoCreateMissingReferencesOption); + var structure = parseResult.GetValue(structureOption); + var before = parseResult.GetValue(beforeOption); + var changes = parseResult.GetValue(changesOption); + var after = parseResult.GetValue(afterOption); + var always = parseResult.GetValue(alwaysOption); + var once = parseResult.GetValue(onceOption); + var never = parseResult.GetValue(neverOption); + var triggers = parseResult.GetValue(triggersOption); + var triggersFile = parseResult.GetValue(triggersFileOption); + var embedTriggers = parseResult.GetValue(embedTriggersOption); + var inputPath = parseResult.GetValue(inputOption); + var outputPath = parseResult.GetValue(outputOption); + var transactionsFlag = parseResult.GetValue(transactionsOption); + var transactionsPathRaw = parseResult.GetValue(transactionsFileOption); + var commitModeRaw = parseResult.GetValue(commitModeOption); + var retentionRaw = parseResult.GetValue(retentionOption); + var vc = parseResult.GetValue(vcOption); + var vcFile = parseResult.GetValue(vcFileOption); + var branchName = parseResult.GetValue(branchOption); + var branchFrom = parseResult.GetValue(branchFromOption); + var checkoutPoint = parseResult.GetValue(checkoutOption); + var tagSpec = parseResult.GetValue(tagOption); + var listBranches = parseResult.GetValue(listBranchesOption); + var listTags = parseResult.GetValue(listTagsOption); + var showLog = parseResult.GetValue(logOption); + + var triggerCommandCount = new[] { always, once, never }.Count(value => value); + if (triggerCommandCount > 1) { - Console.Error.WriteLine("--vc requires the transactions layer (this should have been auto-enabled)."); - return 1; + Console.Error.WriteLine("Only one of --always, --once, or --never can be used at a time."); + return 1; } - var effectiveVcFile = !string.IsNullOrWhiteSpace(vcFile) - ? vcFile - : VersionControlDecorator.MakeVersionControlDatabaseFilename(db); - vcBranchesStore = new NamedTypesDecorator(effectiveVcFile, trace); - vcLinks = new VersionControlDecorator(transactionsLinks, vcBranchesStore, trace); - decoratedLinks = vcLinks; - } - PersistentTransformationDecorator? persistentLinks = null; - var defaultTriggersFile = PersistentTransformationDecorator.MakeTriggersDatabaseFilename(db); - var effectiveTriggersFile = string.IsNullOrWhiteSpace(triggersFile) ? defaultTriggersFile : triggersFile; - var persistentTransformationsEnabled = always - || once - || never - || triggers - || embedTriggers - || !string.IsNullOrWhiteSpace(triggersFile) - || File.Exists(effectiveTriggersFile); - - if (persistentTransformationsEnabled) - { - var triggerLinks = embedTriggers - ? (INamedTypesLinks)baseLinks - : new NamedTypesDecorator(effectiveTriggersFile, trace); - persistentLinks = new PersistentTransformationDecorator(decoratedLinks, triggerLinks, trace) + var vcRequested = vc + || !string.IsNullOrWhiteSpace(vcFile) + || !string.IsNullOrWhiteSpace(branchName) + || branchFrom.HasValue + || !string.IsNullOrWhiteSpace(checkoutPoint) + || !string.IsNullOrWhiteSpace(tagSpec) + || listBranches + || listTags; + + var transactionsRequested = transactionsFlag + || !string.IsNullOrWhiteSpace(transactionsPathRaw) + || !string.IsNullOrWhiteSpace(commitModeRaw) + || !string.IsNullOrWhiteSpace(retentionRaw) + || showLog + || vcRequested; + + CommitMode commitMode = CommitMode.Sync; + if (!string.IsNullOrWhiteSpace(commitModeRaw)) { - AutoCreateMissingReferences = autoCreateMissingReferences - }; - decoratedLinks = persistentLinks; - } - - try - { - return RunCli(); - } - finally - { - transactionsLinks?.Shutdown(); - } - - int RunCli() - { - if (vcLinks is not null) - { - if (!string.IsNullOrWhiteSpace(checkoutPoint)) - { - if (!TryResolveSequence(vcLinks, checkoutPoint, out var seq)) + if (commitModeRaw.Equals("sync", StringComparison.OrdinalIgnoreCase)) { - Console.Error.WriteLine($"Unknown checkout point '{checkoutPoint}'."); - return 1; + commitMode = CommitMode.Sync; } - try + else if (commitModeRaw.Equals("async", StringComparison.OrdinalIgnoreCase)) { - vcLinks.Checkout(seq); - if (trace) Console.WriteLine($"Checked out seq {seq} on branch '{vcLinks.CurrentBranch}'."); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error during --checkout: {ex.Message}"); - return 1; - } - } - - if (!string.IsNullOrWhiteSpace(branchName)) - { - var existing = vcLinks.ListBranches().Any(b => b.Name == branchName); - if (!existing) - { - try - { - vcLinks.Branch(branchName, branchFrom); - if (trace) Console.WriteLine($"Created branch '{branchName}'."); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error creating branch '{branchName}': {ex.Message}"); - return 1; - } - } - try - { - vcLinks.SwitchBranch(branchName); - if (trace) Console.WriteLine($"Switched to branch '{branchName}'."); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error switching to branch '{branchName}': {ex.Message}"); - return 1; - } - } - - if (!string.IsNullOrWhiteSpace(tagSpec)) - { - var eq = tagSpec.IndexOf('='); - string tagName; - long? tagSeq = null; - if (eq < 0) - { - tagName = tagSpec; + commitMode = CommitMode.Async; } else { - tagName = tagSpec.Substring(0, eq); - var point = tagSpec.Substring(eq + 1); - if (!TryResolveSequence(vcLinks, point, out var resolved)) - { - Console.Error.WriteLine($"Unknown tag point '{point}'."); + Console.Error.WriteLine($"Invalid --commit-mode value '{commitModeRaw}'. Use 'sync' or 'async'."); return 1; - } - tagSeq = resolved; } + } + + LogRetentionPolicy? retentionPolicy = null; + if (!string.IsNullOrWhiteSpace(retentionRaw)) + { try { - vcLinks.Tag(tagName, tagSeq); - if (trace) Console.WriteLine($"Tagged '{tagName}' at seq {tagSeq ?? vcLinks.CurrentSequence}."); + retentionPolicy = LogRetentionPolicy.Parse(retentionRaw); } - catch (Exception ex) + catch (ArgumentException ex) { - Console.Error.WriteLine($"Error creating tag '{tagName}': {ex.Message}"); - return 1; + Console.Error.WriteLine($"Invalid --retention value: {ex.Message}"); + return 1; } - } + } - if (listBranches) - { - foreach (var info in vcLinks.ListBranches()) - { - var marker = info.Name == vcLinks.CurrentBranch ? "*" : " "; - var parent = info.Parent ?? "-"; - Console.WriteLine($"{marker} {info.Name}\tparent={parent}\tfork={info.ForkSeq}\thead={info.Head}"); - } - return 0; - } + var baseLinks = new NamedTypesDecorator(db, trace); + INamedTypesLinks decoratedLinks = baseLinks; + NamedTypesDecorator? transitionsStore = null; + NamedTypesDecorator? vcBranchesStore = null; + TransactionsDecorator? transactionsLinks = null; + VersionControlDecorator? vcLinks = null; - if (listTags) - { - foreach (var tag in vcLinks.ListTags().OrderBy(t => t.Key, StringComparer.Ordinal)) + if (transactionsRequested) + { + var effectiveTransactionsFile = !string.IsNullOrWhiteSpace(transactionsPathRaw) + ? transactionsPathRaw + : TransactionsDecorator.MakeTransitionsDatabaseFilename(db); + transitionsStore = new NamedTypesDecorator(effectiveTransactionsFile, trace); + transactionsLinks = new TransactionsDecorator( + baseLinks, + transitionsStore, + retentionPolicy, + commitMode, + trace); + decoratedLinks = transactionsLinks; + } + + if (vcRequested) + { + if (transactionsLinks is null) { - Console.WriteLine($"{tag.Key}\t{tag.Value}"); + Console.Error.WriteLine("--vc requires the transactions layer (this should have been auto-enabled)."); + return 1; } - return 0; - } + var effectiveVcFile = !string.IsNullOrWhiteSpace(vcFile) + ? vcFile + : VersionControlDecorator.MakeVersionControlDatabaseFilename(db); + vcBranchesStore = new NamedTypesDecorator(effectiveVcFile, trace); + vcLinks = new VersionControlDecorator(transactionsLinks, vcBranchesStore, trace); + decoratedLinks = vcLinks; } - if (showLog) + PersistentTransformationDecorator? persistentLinks = null; + var defaultTriggersFile = PersistentTransformationDecorator.MakeTriggersDatabaseFilename(db); + var effectiveTriggersFile = string.IsNullOrWhiteSpace(triggersFile) ? defaultTriggersFile : triggersFile; + var persistentTransformationsEnabled = always + || once + || never + || triggers + || embedTriggers + || !string.IsNullOrWhiteSpace(triggersFile) + || File.Exists(effectiveTriggersFile); + + if (persistentTransformationsEnabled) { - if (transactionsLinks is null) - { - Console.Error.WriteLine("--log requires the transactions layer."); - return 1; - } - foreach (var transition in transactionsLinks.Log) - { - Console.WriteLine($"{transition.Sequence}\t{transition.Timestamp:O}\t{transition.Kind}\t{transition.TransactionId:N}\t({transition.Before.Index},{transition.Before.Source},{transition.Before.Target}) -> ({transition.After.Index},{transition.After.Source},{transition.After.Target})"); - } - return 0; + var triggerLinks = embedTriggers + ? (INamedTypesLinks)baseLinks + : new NamedTypesDecorator(effectiveTriggersFile, trace); + persistentLinks = new PersistentTransformationDecorator(decoratedLinks, triggerLinks, trace) + { + AutoCreateMissingReferences = autoCreateMissingReferences + }; + decoratedLinks = persistentLinks; } - return RunQueryPipeline(); - } - - bool TryResolveSequence(VersionControlDecorator vc, string point, out long sequence) - { - sequence = 0; - if (string.IsNullOrWhiteSpace(point)) return false; - if (long.TryParse(point, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var direct)) + try { - sequence = direct; - return true; + return RunCli(); } - if (vc.TryGetTag(point, out var tagSeq)) + finally { - sequence = tagSeq; - return true; + transactionsLinks?.Shutdown(); } - return false; - } - int RunQueryPipeline() - { - - if (before) - { - PrintAllLinks(decoratedLinks); - } + int RunCli() + { + if (vcLinks is not null) + { + if (!string.IsNullOrWhiteSpace(checkoutPoint)) + { + if (!TryResolveSequence(vcLinks, checkoutPoint, out var seq)) + { + Console.Error.WriteLine($"Unknown checkout point '{checkoutPoint}'."); + return 1; + } + try + { + vcLinks.Checkout(seq); + if (trace) Console.WriteLine($"Checked out seq {seq} on branch '{vcLinks.CurrentBranch}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error during --checkout: {ex.Message}"); + return 1; + } + } + + if (!string.IsNullOrWhiteSpace(branchName)) + { + var existing = vcLinks.ListBranches().Any(b => b.Name == branchName); + if (!existing) + { + try + { + vcLinks.Branch(branchName, branchFrom); + if (trace) Console.WriteLine($"Created branch '{branchName}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error creating branch '{branchName}': {ex.Message}"); + return 1; + } + } + try + { + vcLinks.SwitchBranch(branchName); + if (trace) Console.WriteLine($"Switched to branch '{branchName}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error switching to branch '{branchName}': {ex.Message}"); + return 1; + } + } + + if (!string.IsNullOrWhiteSpace(tagSpec)) + { + var eq = tagSpec.IndexOf('='); + string tagName; + long? tagSeq = null; + if (eq < 0) + { + tagName = tagSpec; + } + else + { + tagName = tagSpec.Substring(0, eq); + var point = tagSpec.Substring(eq + 1); + if (!TryResolveSequence(vcLinks, point, out var resolved)) + { + Console.Error.WriteLine($"Unknown tag point '{point}'."); + return 1; + } + tagSeq = resolved; + } + try + { + vcLinks.Tag(tagName, tagSeq); + if (trace) Console.WriteLine($"Tagged '{tagName}' at seq {tagSeq ?? vcLinks.CurrentSequence}."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error creating tag '{tagName}': {ex.Message}"); + return 1; + } + } + + if (listBranches) + { + foreach (var info in vcLinks.ListBranches()) + { + var marker = info.Name == vcLinks.CurrentBranch ? "*" : " "; + var parent = info.Parent ?? "-"; + Console.WriteLine($"{marker} {info.Name}\tparent={parent}\tfork={info.ForkSeq}\thead={info.Head}"); + } + return 0; + } + + if (listTags) + { + foreach (var tag in vcLinks.ListTags().OrderBy(t => t.Key, StringComparer.Ordinal)) + { + Console.WriteLine($"{tag.Key}\t{tag.Value}"); + } + return 0; + } + } - if (!TryReadLinoInput(decoratedLinks, inputPath)) - { - return 1; - } + if (showLog) + { + if (transactionsLinks is null) + { + Console.Error.WriteLine("--log requires the transactions layer."); + return 1; + } + foreach (var transition in transactionsLinks.Log) + { + Console.WriteLine($"{transition.Sequence}\t{transition.Timestamp:O}\t{transition.Kind}\t{transition.TransactionId:N}\t({transition.Before.Index},{transition.Before.Source},{transition.Before.Target}) -> ({transition.After.Index},{transition.After.Source},{transition.After.Target})"); + } + return 0; + } - if (structure.HasValue) - { - var linkId = structure.Value; - try - { - var structureFormatted = LinoDatabaseOutput.FormatStructure(decoratedLinks, linkId); - Console.WriteLine(structureFormatted); + return RunQueryPipeline(); } - catch (Exception ex) + + bool TryResolveSequence(VersionControlDecorator vc, string point, out long sequence) { - Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}"); - return 1; + sequence = 0; + if (string.IsNullOrWhiteSpace(point)) return false; + if (long.TryParse(point, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var direct)) + { + sequence = direct; + return true; + } + if (vc.TryGetTag(point, out var tagSeq)) + { + sequence = tagSeq; + return true; + } + return false; } - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + int RunQueryPipeline() + { - var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; + if (before) + { + PrintAllLinks(decoratedLinks); + } - if ((always || once || never) && string.IsNullOrWhiteSpace(effectiveQuery)) - { - Console.Error.WriteLine("--always, --once, and --never require a query."); - return 1; - } + if (!TryReadLinoInput(decoratedLinks, inputPath)) + { + return 1; + } - if (persistentLinks is not null && (always || once)) - { - var kind = always ? PersistentTransformationKind.Always : PersistentTransformationKind.Once; - var trigger = persistentLinks.StoreTrigger(kind, effectiveQuery); - Console.WriteLine($"{kind} persistent transformation trigger stored: {trigger}"); - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + if (structure.HasValue) + { + var linkId = structure.Value; + try + { + var structureFormatted = LinoDatabaseOutput.FormatStructure(decoratedLinks, linkId); + Console.WriteLine(structureFormatted); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}"); + return 1; + } + + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } - if (persistentLinks is not null && never) - { - var removed = persistentLinks.RemoveTriggers(effectiveQuery); - Console.WriteLine($"Persistent transformation triggers removed: {removed}"); - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; - var changesList = new List<(DoubletLink Before, DoubletLink After)>(); + if ((always || once || never) && string.IsNullOrWhiteSpace(effectiveQuery)) + { + Console.Error.WriteLine("--always, --once, and --never require a query."); + return 1; + } - if (!string.IsNullOrWhiteSpace(effectiveQuery)) - { - var options = new QueryProcessor.Options - { - Query = effectiveQuery, - Trace = trace, - AutoCreateMissingReferences = autoCreateMissingReferences, - ChangesHandler = (beforeLink, afterLink) => - { - changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); - return decoratedLinks.Constants.Continue; - } - }; - - QueryProcessor.ProcessQuery(decoratedLinks, options); - } + if (persistentLinks is not null && (always || once)) + { + var kind = always ? PersistentTransformationKind.Always : PersistentTransformationKind.Once; + var trigger = persistentLinks.StoreTrigger(kind, effectiveQuery); + Console.WriteLine($"{kind} persistent transformation trigger stored: {trigger}"); + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } - if (changes && changesList.Any()) - { - if (trace) - { - Console.WriteLine("[DEBUG] Raw changes before simplification:"); - for (int i = 0; i < changesList.Count; i++) - { - var (beforeLink, afterLink) = changesList[i]; - Console.WriteLine($"[DEBUG] {i + 1}. ({beforeLink.Index}: {beforeLink.Source} {beforeLink.Target}) -> ({afterLink.Index}: {afterLink.Source} {afterLink.Target})"); - } - Console.WriteLine($"[DEBUG] Total raw changes: {changesList.Count}"); - } + if (persistentLinks is not null && never) + { + var removed = persistentLinks.RemoveTriggers(effectiveQuery); + Console.WriteLine($"Persistent transformation triggers removed: {removed}"); + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } - var simplifiedChanges = SimplifyChanges(changesList); + var changesList = new List<(DoubletLink Before, DoubletLink After)>(); - if (trace) - { - Console.WriteLine($"[DEBUG] Simplified changes count: {simplifiedChanges.Count()}"); - } + if (!string.IsNullOrWhiteSpace(effectiveQuery)) + { + var options = new QueryProcessor.Options + { + Query = effectiveQuery, + Trace = trace, + AutoCreateMissingReferences = autoCreateMissingReferences, + ChangesHandler = (beforeLink, afterLink) => + { + changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); + return decoratedLinks.Constants.Continue; + } + }; + + QueryProcessor.ProcessQuery(decoratedLinks, options); + } - foreach (var (linkBefore, linkAfter) in simplifiedChanges) - { - PrintChange(decoratedLinks, linkBefore, linkAfter); - } - } + if (changes && changesList.Any()) + { + if (trace) + { + Console.WriteLine("[DEBUG] Raw changes before simplification:"); + for (int i = 0; i < changesList.Count; i++) + { + var (beforeLink, afterLink) = changesList[i]; + Console.WriteLine($"[DEBUG] {i + 1}. ({beforeLink.Index}: {beforeLink.Source} {beforeLink.Target}) -> ({afterLink.Index}: {afterLink.Source} {afterLink.Target})"); + } + Console.WriteLine($"[DEBUG] Total raw changes: {changesList.Count}"); + } + + var simplifiedChanges = SimplifyChanges(changesList); + + if (trace) + { + Console.WriteLine($"[DEBUG] Simplified changes count: {simplifiedChanges.Count()}"); + } + + foreach (var (linkBefore, linkAfter) in simplifiedChanges) + { + PrintChange(decoratedLinks, linkBefore, linkAfter); + } + } - if (after) - { - PrintAllLinks(decoratedLinks); - } + if (after) + { + PrintAllLinks(decoratedLinks); + } - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } } ); @@ -616,48 +616,48 @@ int RunQueryPipeline() static void PrintAllLinks(INamedTypesLinks links) { - LinoDatabaseOutput.WriteDatabase(links, Console.Out); + LinoDatabaseOutput.WriteDatabase(links, Console.Out); } static void PrintChange(INamedTypesLinks links, DoubletLink linkBefore, DoubletLink linkAfter) { - Console.WriteLine(LinoDatabaseOutput.FormatChange(links, linkBefore, linkAfter)); + Console.WriteLine(LinoDatabaseOutput.FormatChange(links, linkBefore, linkAfter)); } static bool TryWriteLinoOutput(INamedTypesLinks links, string? outputPath) { - if (string.IsNullOrWhiteSpace(outputPath)) - { - return true; - } + if (string.IsNullOrWhiteSpace(outputPath)) + { + return true; + } - try - { - LinoDatabaseOutput.WriteToFile(links, outputPath); - return true; - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException) - { - Console.Error.WriteLine($"Error writing LiNo output file '{outputPath}': {ex.Message}"); - return false; - } + try + { + LinoDatabaseOutput.WriteToFile(links, outputPath); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException) + { + Console.Error.WriteLine($"Error writing LiNo output file '{outputPath}': {ex.Message}"); + return false; + } } static bool TryReadLinoInput(INamedTypesLinks links, string? inputPath) { - if (string.IsNullOrWhiteSpace(inputPath)) - { - return true; - } + if (string.IsNullOrWhiteSpace(inputPath)) + { + return true; + } - try - { - LinoDatabaseInput.ReadFromFile(links, inputPath); - return true; - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException || ex is FormatException) - { - Console.Error.WriteLine($"Error reading LiNo input file '{inputPath}': {ex.Message}"); - return false; - } + try + { + LinoDatabaseInput.ReadFromFile(links, inputPath); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException || ex is FormatException) + { + Console.Error.WriteLine($"Error reading LiNo input file '{inputPath}': {ex.Message}"); + return false; + } } From f5a325837be583fa74fdb6d6acb6df0811ef1e41 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:33:46 +0000 Subject: [PATCH 06/21] ci(issue-96): stop masking Windows failures and harden the C# workflow - removes 'continue-on-error: windows-latest' from the test matrix: the file-locking failures it hid are fixed, so a Windows regression is now a red build instead of a green one (the false negative of issue #96), - adds 'dotnet format --verify-no-changes' and the file-size check to the lint job, matching csharp-ai-driven-development-pipeline-template, - adds per-job timeout-minutes so a hung job fails instead of burning the 6h default, - adds workflow-level least-privilege 'permissions: contents: read', - replaces the redundant 'always() && !cancelled()' with '!cancelled()', - keeps cancel-in-progress for PR runs but disables it on main and gives the publishing jobs a non-cancellable writer concurrency group. --- .github/workflows/csharp.yml | 45 +++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index f90ae2d..a48c5d3 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -33,9 +33,16 @@ on: required: false type: string +# Reader runs (PR validation) are cancelled when superseded; runs on main are +# writers - they publish to NuGet and push version commits, so cancelling one +# mid-flight would leave a half-published release behind (issue #96). concurrency: group: csharp-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# Least privilege by default; the publishing jobs opt into more below. +permissions: + contents: read env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true @@ -51,6 +58,7 @@ jobs: detect-changes: name: Detect Changes runs-on: ubuntu-latest + timeout-minutes: 10 if: github.event_name != 'workflow_dispatch' outputs: cs-changed: ${{ steps.changes.outputs.cs-changed }} @@ -85,6 +93,7 @@ jobs: changeset-check: name: Changeset Validation runs-on: ubuntu-latest + timeout-minutes: 10 needs: [detect-changes] if: github.event_name == 'pull_request' && needs.detect-changes.outputs.csharp-code-changed == 'true' steps: @@ -122,9 +131,10 @@ jobs: lint: name: Lint and Format Check runs-on: ubuntu-latest + timeout-minutes: 20 needs: [detect-changes] if: | - always() && !cancelled() && ( + !cancelled() && ( github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.cs-changed == 'true' || @@ -155,15 +165,23 @@ jobs: - name: Restore dependencies run: dotnet restore + - name: Verify formatting + run: dotnet format --verify-no-changes --verbosity diagnostic + - name: Build run: dotnet build --no-restore --configuration Release + - name: Check file sizes + working-directory: . + run: node csharp/scripts/check-file-size.mjs + # === TEST ON MULTIPLE OS === test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 30 needs: [detect-changes, changeset-check] - if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') + if: ${{ !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -183,8 +201,6 @@ jobs: run: dotnet build --no-restore --configuration Release - name: Run tests - # Windows has pre-existing file locking issues with some tests - continue-on-error: ${{ matrix.os == 'windows-latest' }} run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" - name: Upload coverage to Codecov @@ -197,8 +213,9 @@ jobs: build: name: Build Package runs-on: ubuntu-latest + timeout-minutes: 20 needs: [lint, test] - if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} steps: - uses: actions/checkout@v6 @@ -226,8 +243,14 @@ jobs: release: name: Release needs: [lint, test, build] - if: always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 45 + # Writer group: never cancel a release that may already have pushed a + # package or a version commit. + concurrency: + group: csharp-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write packages: write @@ -408,8 +431,14 @@ jobs: instant-release: name: Instant Release needs: [lint, test, build] - if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 45 + # Writer group: never cancel a release that may already have pushed a + # package or a version commit. + concurrency: + group: csharp-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write packages: write From 19a95a79902d3dff43d9721a5de1cd3fdac03b15 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:37:39 +0000 Subject: [PATCH 07/21] refactor(issue-96): split the two oversized AdvancedMixedQueryProcessor files Both were over the 1000-line limit enforced by csharp/scripts/check-file-size.mjs, which is why the check had never been wired into CI. Splitting them into partial classes is a pure move of existing code - no member was added, removed or edited: - Library: matching, mutations and reference validation each move into their own partial file (1678 -> 484/425/483/330 lines), - Tests: the suite splits into two test partials plus a helpers partial (1848 -> 704/897/277 lines). All 222 tests still pass and check-file-size.mjs now exits 0. --- .../AdvancedMixedQueryProcessor.Matching.cs | 424 ++++++ .../AdvancedMixedQueryProcessor.Mutations.cs | 482 +++++++ .../AdvancedMixedQueryProcessor.References.cs | 329 +++++ .../AdvancedMixedQueryProcessor.cs | 1196 +---------------- .../AdvancedMixedQueryProcessor.Helpers.cs | 276 ++++ .../AdvancedMixedQueryProcessor.More.cs | 896 ++++++++++++ .../AdvancedMixedQueryProcessor.cs | 1146 +--------------- 7 files changed, 2409 insertions(+), 2340 deletions(-) create mode 100644 csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Matching.cs create mode 100644 csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Mutations.cs create mode 100644 csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.References.cs create mode 100644 csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.Helpers.cs create mode 100644 csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.More.cs diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Matching.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Matching.cs new file mode 100644 index 0000000..22bb248 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Matching.cs @@ -0,0 +1,424 @@ +// Part of the partial AdvancedMixedQueryProcessor class. +// Pattern matching: turning restriction patterns into concrete variable solutions. +using System; +using Platform.Delegates; +using Platform.Data; +using Platform.Data.Doublets; +using Link.Foundation.Links.Notation; +using LinoLink = Link.Foundation.Links.Notation.Link; +using DoubletLink = Platform.Data.Doublets.Link; +namespace Foundation.Data.Doublets.Cli +{ + public static partial class AdvancedMixedQueryProcessor + { + private static List> FindAllSolutions(INamedTypesLinks links, List patterns) + { + var partialSolutions = new List> { new Dictionary() }; + + for (int i = 0; i < patterns.Count; i++) + { + var pattern = patterns[i]; + var newSolutions = new List>(); + foreach (var solution in partialSolutions) + { + var matches = MatchPattern(links, pattern, solution).ToList(); + foreach (var match in matches) + { + if (AreSolutionsCompatible(solution, match)) + { + var combinedSolution = new Dictionary(solution); + foreach (var assignment in match) + { + combinedSolution[assignment.Key] = assignment.Value; + } + newSolutions.Add(combinedSolution); + } + } + } + partialSolutions = newSolutions; + if (partialSolutions.Count == 0) break; + } + + return partialSolutions; + } + + private static bool AreSolutionsCompatible( + Dictionary existingSolution, + Dictionary newAssignments) + { + foreach (var assignment in newAssignments) + { + if (existingSolution.TryGetValue(assignment.Key, out var existingValue)) + { + if (existingValue != assignment.Value) + { + return false; + } + } + } + return true; + } + + private static IEnumerable> MatchPattern( + INamedTypesLinks links, + Pattern pattern, + Dictionary currentSolution) + { + var anyConstant = links.Constants.Any; + if (pattern.IsLeaf) + { + uint leafIndex = ResolveId(links, pattern.Index, currentSolution); + var candidates = links.All(new DoubletLink(leafIndex, anyConstant, anyConstant)); + foreach (var link in candidates) + { + var candidateLink = new DoubletLink(link); + var assignments = new Dictionary(); + AssignVariableIfNeeded(pattern.Index, candidateLink.Index, assignments); + yield return assignments; + } + yield break; + } + + bool indexIsVariable = IsVariable(pattern.Index); + bool indexIsAny = pattern.Index == "*"; + uint resolvedIndex = ResolveId(links, pattern.Index, currentSolution); + + // If idxResolved is a known link => skip enumerating everything + if (!indexIsVariable && !indexIsAny && resolvedIndex != anyConstant && resolvedIndex != 0 && links.Exists(resolvedIndex)) + { + var link = new DoubletLink(links.GetLink(resolvedIndex)); + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, resolvedIndex, combined); + yield return combined; + } + } + } + else + { + // Otherwise we iterate over all links + var allLinks = links.All(new DoubletLink(anyConstant, anyConstant, anyConstant)); + foreach (var raw in allLinks) + { + var candidateLink = new DoubletLink(raw); + if (!CheckIdMatch(links, pattern.Index, candidateLink.Index, currentSolution)) + continue; + + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, candidateLink.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, candidateLink.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, candidateLink.Index, combined); + yield return combined; + } + } + } + } + } + + private static IEnumerable> RecursiveMatchSubPattern( + INamedTypesLinks links, + Pattern? pattern, + uint linkId, + Dictionary currentSolution) + { + if (pattern == null) + { + yield return currentSolution; + yield break; + } + + if (pattern.IsLeaf) + { + if (CheckIdMatch(links, pattern.Index, linkId, currentSolution)) + { + var newSolution = new Dictionary(currentSolution); + AssignVariableIfNeeded(pattern.Index, linkId, newSolution); + yield return newSolution; + } + yield break; + } + + if (!links.Exists(linkId)) yield break; + + var link = new DoubletLink(links.GetLink(linkId)); + if (!CheckIdMatch(links, pattern.Index, link.Index, currentSolution)) + { + yield break; + } + + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, link.Index, combined); + yield return combined; + } + } + } + + private static bool CheckIdMatch( + INamedTypesLinks links, + string patternId, + uint candidateId, + Dictionary currentSolution) + { + if (string.IsNullOrEmpty(patternId)) return true; + if (patternId == "*") return true; + + if (IsVariable(patternId)) + { + if (currentSolution.TryGetValue(patternId, out var existingVal)) + { + return existingVal == candidateId; + } + return true; + } + + uint parsed = links.Constants.Any; + if (TryParseLinkId(patternId, links, ref parsed)) + { + if (parsed == links.Constants.Any) return true; + return parsed == candidateId; + } + return true; + } + + private static void AssignVariableIfNeeded(string id, uint value, Dictionary assignments) + { + if (IsVariable(id)) + { + assignments[id] = value; + } + } + + private static bool IsVariable(string identifier) + { + return !string.IsNullOrEmpty(identifier) && identifier.StartsWith("$"); + } + + private static uint ResolveId( + INamedTypesLinks links, + string identifier, + Dictionary currentSolution) + { + var anyConstant = links.Constants.Any; + if (string.IsNullOrEmpty(identifier)) return anyConstant; + if (currentSolution.TryGetValue(identifier, out var value)) + { + return value; + } + if (IsVariable(identifier)) + { + return anyConstant; + } + uint parsedValue = anyConstant; + if (TryParseLinkId(identifier, links, ref parsedValue)) + { + return parsedValue; + } + return anyConstant; + } + + private static bool DetermineIfSolutionIsNoOperation( + Dictionary solution, + List restrictions, + List substitutions, + INamedTypesLinks links) + { + var substitutedRestrictions = restrictions + .Select(r => ApplySolutionToPattern(links, solution, r, isSubstitution: false)) + .Where(link => link != null) + .Select(link => new DoubletLink(link!)) + .ToList(); + + var substitutedSubstitutions = ApplySolutionToPatterns(links, solution, substitutions, isSubstitution: true); + + substitutedRestrictions.Sort((a, b) => a.Index.CompareTo(b.Index)); + substitutedSubstitutions.Sort((a, b) => a.Index.CompareTo(b.Index)); + + if (substitutedRestrictions.Count != substitutedSubstitutions.Count) return false; + for (int i = 0; i < substitutedRestrictions.Count; i++) + { + if (!substitutedRestrictions[i].Equals(substitutedSubstitutions[i])) + { + return false; + } + } + return true; + } + + private static List ExtractMatchedLinks( + INamedTypesLinks links, + Dictionary solution, + List patterns) + { + var matchedLinks = new List(); + foreach (var pattern in patterns) + { + var applied = ApplySolutionToPattern(links, solution, pattern); + if (applied != null) + { + var matches = links.All(applied); + foreach (var match in matches) + { + matchedLinks.Add(new DoubletLink(match)); + } + } + } + return matchedLinks.Distinct().ToList(); + } + + private static DoubletLink? ApplySolutionToPattern( + INamedTypesLinks links, + Dictionary solution, + Pattern? pattern, + bool isSubstitution = false, + HashSet? visitedIndexes = null) + { + if (pattern == null) return null; + visitedIndexes ??= new HashSet(); + + // Retrieve the ANY constant once for both leaf and composite cases + var anyConstant = links.Constants.Any; + + if (pattern.IsLeaf) + { + uint resolvedIndex = ResolveId(links, pattern.Index, solution); + return new DoubletLink(resolvedIndex, anyConstant, anyConstant); + } + else + { + uint resolvedIndex = ResolvePatternIndex(links, pattern.Index, solution, isSubstitution); + var sourceLink = ApplySolutionToPattern(links, solution, pattern.Source, isSubstitution, visitedIndexes); + var targetLink = ApplySolutionToPattern(links, solution, pattern.Target, isSubstitution, visitedIndexes); + + uint resolvedSource = sourceLink?.Index ?? anyConstant; + uint resolvedTarget = targetLink?.Index ?? anyConstant; + + PreserveExistingSubstitutionParts(links, solution, pattern, resolvedIndex, ref resolvedSource, ref resolvedTarget, isSubstitution, visitedIndexes); + + if (resolvedSource == 0) resolvedSource = anyConstant; + if (resolvedTarget == 0) resolvedTarget = anyConstant; + + return new DoubletLink(resolvedIndex, resolvedSource, resolvedTarget); + } + } + + private static uint ResolvePatternIndex( + INamedTypesLinks links, + string identifier, + Dictionary solution, + bool isSubstitution) + { + if (isSubstitution && string.IsNullOrEmpty(identifier)) + { + return links.Constants.Null; + } + + if (isSubstitution && IsVariable(identifier) && !solution.ContainsKey(identifier)) + { + return links.Constants.Null; + } + + return ResolveId(links, identifier, solution); + } + + private static List ApplySolutionToPatterns( + INamedTypesLinks links, + Dictionary solution, + List patterns, + bool isSubstitution) + { + var workingSolution = isSubstitution ? new Dictionary(solution) : solution; + return patterns + .Select(pattern => ApplySolutionToPattern(links, workingSolution, pattern, isSubstitution)) + .Where(link => link != null) + .Select(link => new DoubletLink(link!)) + .ToList(); + } + + private static void PreserveExistingSubstitutionParts( + INamedTypesLinks links, + Dictionary solution, + Pattern pattern, + uint resolvedIndex, + ref uint resolvedSource, + ref uint resolvedTarget, + bool isSubstitution, + HashSet visitedIndexes) + { + if (!isSubstitution || resolvedIndex == links.Constants.Null || resolvedIndex == links.Constants.Any || !links.Exists(resolvedIndex)) + { + return; + } + + if (!visitedIndexes.Add(resolvedIndex)) + { + return; + } + + try + { + var existingLink = new DoubletLink(links.GetLink(resolvedIndex)); + + if (ShouldPreserveExistingPart(pattern.Source, solution) && CanPreserveExistingPart(existingLink, existingLink.Source, visitedIndexes)) + { + resolvedSource = existingLink.Source; + AssignVariableIfNeeded(pattern.Source!.Index, resolvedSource, solution); + } + else if (TryResolveVariablePart(pattern.Source, solution, out var boundSource)) + { + resolvedSource = boundSource; + } + + if (ShouldPreserveExistingPart(pattern.Target, solution) && CanPreserveExistingPart(existingLink, existingLink.Target, visitedIndexes)) + { + resolvedTarget = existingLink.Target; + AssignVariableIfNeeded(pattern.Target!.Index, resolvedTarget, solution); + } + else if (TryResolveVariablePart(pattern.Target, solution, out var boundTarget)) + { + resolvedTarget = boundTarget; + } + } + finally + { + visitedIndexes.Remove(resolvedIndex); + } + } + + private static bool ShouldPreserveExistingPart(Pattern? partPattern, Dictionary solution) + { + return partPattern?.IsLeaf == true + && IsVariable(partPattern.Index) + && !solution.ContainsKey(partPattern.Index); + } + + private static bool TryResolveVariablePart(Pattern? partPattern, Dictionary solution, out uint value) + { + value = default; + return partPattern?.IsLeaf == true + && IsVariable(partPattern.Index) + && solution.TryGetValue(partPattern.Index, out value); + } + + private static bool CanPreserveExistingPart(DoubletLink existingLink, uint part, HashSet visitedIndexes) + { + return existingLink.IsFullPoint() + || existingLink.IsPartialPoint() + || !visitedIndexes.Contains(part); + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Mutations.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Mutations.cs new file mode 100644 index 0000000..48719a0 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Mutations.cs @@ -0,0 +1,482 @@ +// Part of the partial AdvancedMixedQueryProcessor class. +// Applying a solution to the store: creating, updating and removing doublets. +using System; +using Platform.Delegates; +using Platform.Data; +using Platform.Data.Doublets; +using Link.Foundation.Links.Notation; +using LinoLink = Link.Foundation.Links.Notation.Link; +using DoubletLink = Platform.Data.Doublets.Link; +namespace Foundation.Data.Doublets.Cli +{ + public static partial class AdvancedMixedQueryProcessor + { + private static void CreateOrUpdateLink(INamedTypesLinks links, DoubletLink linkDefinition, Options options) + { + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + // Wildcard substitution rename: delegate to nested creation with proper naming + if (linkDefinition.Index == anyConstant) + { + TraceIfEnabled(options, "[CreateOrUpdateLink] Detected wildcard substitution => nested create & name."); + var parsed = new Parser().Parse(options.Query ?? string.Empty); + if (parsed.Count > 0) + { + var outer = parsed[0]; + if (outer.Values != null && outer.Values.Count > 1) + { + var substitutionLinoLink = outer.Values[1]; + if (substitutionLinoLink.Values != null) + { + foreach (var composite in substitutionLinoLink.Values) + { + EnsureNestedLinkCreatedRecursively(links, composite, options); + } + } + } + } + return; + } + + if (linkDefinition.Index != nullConstant) + { + // update existing link + if (!links.Exists(linkDefinition.Index)) + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} doesn't exist => ensuring creation."); + LinksExtensions.EnsureCreated(links, linkDefinition.Index); + } + var existingLinkRecord = links.GetLink(linkDefinition.Index); + var existingDoublet = new DoubletLink(existingLinkRecord); + + if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) + { + TraceIfEnabled(options, + $"[CreateOrUpdateLink] Updating link #{linkDefinition.Index}: {existingDoublet.Source}->{linkDefinition.Source}, {existingDoublet.Target}->{linkDefinition.Target}."); + LinksExtensions.EnsureCreated(links, linkDefinition.Index); + links.Update( + new DoubletLink(linkDefinition.Index, anyConstant, anyConstant), + linkDefinition, + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + else + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} is already S={linkDefinition.Source}, T={linkDefinition.Target} => no change."); + options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); + } + } + else + { + // create new link + var existingLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); + if (existingLinkIndex == default) + { + uint newLinkIndex = 0; + TraceIfEnabled(options, + $"[CreateOrUpdateLink] Creating new link => (S={linkDefinition.Source},T={linkDefinition.Target})."); + links.CreateAndUpdate(linkDefinition.Source, linkDefinition.Target, (beforeState, afterState) => + { + var afterLinkRecord = new DoubletLink(afterState); + if (newLinkIndex == 0 && afterLinkRecord.Index != 0 && afterLinkRecord.Index != anyConstant) + { + newLinkIndex = afterLinkRecord.Index; + TraceIfEnabled(options, $"[CreateOrUpdateLink] => assigned new ID={newLinkIndex}"); + } + return options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue; + }); + + if (newLinkIndex == 0 || newLinkIndex == anyConstant) + { + newLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); + } + } + else + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link already found => ID={existingLinkIndex}, no changes."); + var existingLink = new DoubletLink(existingLinkIndex, linkDefinition.Source, linkDefinition.Target); + options.ChangesHandler?.Invoke(existingLink, existingLink); + } + } + } + + private static void RemoveLinks( + INamedTypesLinks links, + DoubletLink restriction, + Options options) + { + var linksToRemove = links.All(restriction) + .Where(l => l != null) + .Select(l => new DoubletLink(l)) + .ToList(); + + TraceIfEnabled(options, + $"[RemoveLinks] Found {linksToRemove.Count} link(s) matching (ID={restriction.Index}, S={restriction.Source}, T={restriction.Target})."); + + foreach (var link in linksToRemove) + { + if (links.Exists(link.Index)) + { + // Remove the name before deleting + links.RemoveName(link.Index); + TraceIfEnabled(options, $"[RemoveLinks] Deleting link => ID={link.Index}, S={link.Source}, T={link.Target}"); + links.Delete(link, (before, after) => + options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + } + } + } + + private static DoubletLink ConvertToDoubletLink(INamedTypesLinks links, LinoLink linoLink, uint defaultValue) + { + uint index = defaultValue; + uint source = defaultValue; + uint target = defaultValue; + TryParseLinkId(linoLink.Id, links, ref index); + if (linoLink.Values?.Count == 2) + { + var sourceLink = linoLink.Values[0]; + TryParseLinkId(sourceLink.Id, links, ref source); + var targetLink = linoLink.Values[1]; + TryParseLinkId(targetLink.Id, links, ref target); + } + return new DoubletLink(index, source, target); + } + + private static bool TryParseLinkId(string? id, INamedTypesLinks links, ref uint parsedValue) + { + if (string.IsNullOrEmpty(id)) return false; + if (id == "*") + { + parsedValue = links.Constants.Any; + return true; + } + else if (id.EndsWith(":")) + { + var trimmed = id.TrimEnd(':'); + if (uint.TryParse(trimmed, out uint linkId)) + { + parsedValue = linkId; + return true; + } + // Try to resolve as string alias + var aliasId = links.GetByName(trimmed); + if (aliasId != links.Constants.Null) + { + parsedValue = aliasId; + return true; + } + } + else if (uint.TryParse(id, out uint linkVal)) + { + parsedValue = linkVal; + return true; + } + else + { + // Try to resolve as string alias + var aliasId = links.GetByName(id); + if (aliasId != links.Constants.Null) + { + parsedValue = aliasId; + return true; + } + } + return false; + } + + public class Pattern + { + public string Index; + public Pattern? Source; + public Pattern? Target; + + public Pattern(string index, Pattern? source = null, Pattern? target = null) + { + Index = index ?? ""; + Source = source; + Target = target; + } + + public bool IsLeaf => Source == null && Target == null; + } + + private static Pattern CreatePatternFromLino(LinoLink linkNode) + { + if (linkNode.Values == null || linkNode.Values.Count == 0) + { + return new Pattern(linkNode.Id ?? ""); + } + + if (linkNode.Values.Count == 2) + { + var sourcePattern = CreatePatternFromLino(linkNode.Values[0]); + var targetPattern = CreatePatternFromLino(linkNode.Values[1]); + return new Pattern(linkNode.Id ?? "", sourcePattern, targetPattern); + } + + // If more than 2 => treat similarly to leaf with ID + return new Pattern(linkNode.Id ?? ""); + } + + private static uint EnsureLinkCreated(INamedTypesLinks links, DoubletLink link, Options options) + { + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + if (link.Index == nullConstant) + { + // If no index => search or create + var existingIndex = links.SearchOrDefault(link.Source, link.Target); + if (existingIndex == default) + { + uint createdIndex = 0; + TraceIfEnabled(options, $"[EnsureLinkCreated] Creating link for (S={link.Source}, T={link.Target})."); + links.CreateAndUpdate(link.Source, link.Target, (before, after) => + { + var afterLink = new DoubletLink(after); + if (createdIndex == 0 && afterLink.Index != 0 && afterLink.Index != anyConstant) + { + createdIndex = afterLink.Index; + TraceIfEnabled(options, $"[EnsureLinkCreated] => assigned new ID={createdIndex}"); + } + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + + if (createdIndex == 0 || createdIndex == anyConstant) + { + createdIndex = links.SearchOrDefault(link.Source, link.Target); + } + return createdIndex; + } + else + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link already found => ID={existingIndex} => no-op."); + var existing = new DoubletLink(existingIndex, link.Source, link.Target); + options.ChangesHandler?.Invoke(existing, existing); + return existingIndex; + } + } + else + { + // We have an index => ensure created or updated + if (!links.Exists(link.Index)) + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} doesn't exist => ensuring creation."); + LinksExtensions.EnsureCreated(links, link.Index); + } + var stored = links.GetLink(link.Index); + var storedD = new DoubletLink(stored); + if (storedD.Source != link.Source || storedD.Target != link.Target) + { + TraceIfEnabled(options, + $"[EnsureLinkCreated] Updating link #{link.Index} => {storedD.Source}->{link.Source}, {storedD.Target}->{link.Target}."); + uint finalIndex = link.Index; + links.Update(new DoubletLink(link.Index, anyConstant, anyConstant), link, (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); + return finalIndex; + } + else + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); + options.ChangesHandler?.Invoke(storedD, storedD); + return link.Index; + } + } + } + + // Helper for link naming logic + private static bool IsNumericOrStar(string? id) + { + if (string.IsNullOrEmpty(id)) return false; + if (id == "*") return true; + uint dummy; + return uint.TryParse(id, out dummy); + } + + private static void TraceIfEnabled(Options options, string message) + { + if (options.Trace) + { + Console.WriteLine(message); + } + } + + // Consolidates getting or creating a named link (leaf) without setting its relationships + private static uint EnsureNamedLeafLink(INamedTypesLinks links, string name, Options options) + { + var existing = links.GetByName(name); + if (existing != links.Constants.Null) return existing; + var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created named leaf '{name}' => ID={newId}"); + links.SetName(newId, name); + return newId; + } + + // Applies a single structural update to an existing link: sets its source and target + private static void ApplyCompositeUpdate(INamedTypesLinks links, uint id, uint source, uint target, Options options) + { + var restriction = new DoubletLink(id, links.Constants.Null, links.Constants.Null); + var substitution = new DoubletLink(id, source, target); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link ID={id} => Source={source}, Target={target}"); + links.Update(restriction, substitution, (before, after) => + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={before}, after={after}"); + return links.Constants.Continue; + }); + } + + /// + /// Detects a two-child composite pattern where at least one child matches the composite identifier. + /// + private static bool TryGetTwoChildCompositePattern( + LinoLink pattern, + out string compositeIdentifier, + out LinoLink leftPattern, + out LinoLink rightPattern) + { + compositeIdentifier = pattern.Id ?? string.Empty; + leftPattern = default!; + rightPattern = default!; + if (!string.IsNullOrEmpty(compositeIdentifier) + && pattern.Values != null + && pattern.Values.Count == 2) + { + leftPattern = pattern.Values[0]; + rightPattern = pattern.Values[1]; + // Only detect composites when one or both children share the identifier + if (leftPattern.Id == compositeIdentifier || rightPattern.Id == compositeIdentifier) + { + return true; + } + } + return false; + } + + private enum CompositeCase { Self, LeftMix, RightMix } + + private static CompositeCase ClassifyCompositeCase(string name, LinoLink left, LinoLink right) + { + if (left.Id == name && right.Id == name) return CompositeCase.Self; + if (left.Id == name && right.Id != name) return CompositeCase.LeftMix; + if (left.Id != name && right.Id == name) return CompositeCase.RightMix; + throw new InvalidOperationException($"Invalid composite pattern for name '{name}'"); + } + + private static uint HandleStringComposite(string name, LinoLink left, LinoLink right, INamedTypesLinks links, Options options) + { + var id = EnsureNamedLeafLink(links, name, options); + var caseType = ClassifyCompositeCase(name, left, right); + switch (caseType) + { + case CompositeCase.Self: + ApplyCompositeUpdate(links, id, id, id, options); + return id; + case CompositeCase.LeftMix: + { + var otherId = EnsureNestedLinkCreatedRecursively(links, right, options); + ApplyCompositeUpdate(links, id, id, otherId, options); + return id; + } + case CompositeCase.RightMix: + { + var otherId = EnsureNestedLinkCreatedRecursively(links, left, options); + ApplyCompositeUpdate(links, id, otherId, id, options); + return id; + } + default: + throw new InvalidOperationException($"Unhandled composite case {caseType}"); + } + } + + /// + /// Resolves a single leaf pattern into its numeric or named link ID. + /// + private static uint ResolveLeaf(LinoLink pattern, INamedTypesLinks links, Options options) + { + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + if (string.IsNullOrEmpty(pattern.Id)) + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with empty ID => returning ANY."); + return anyConstant; + } + if (pattern.Id == "*") + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with '*' => returning ANY."); + return anyConstant; + } + if (pattern.Id.StartsWith("$")) + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Variable leaf => returning ANY."); + return anyConstant; + } + if (uint.TryParse(pattern.Id, out uint parsedNumber)) + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Leaf parse => returning {parsedNumber}."); + return parsedNumber; + } + var existingId = links.GetByName(pattern.Id); + if (existingId != links.Constants.Null) + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Found existing named leaf '{pattern.Id}' => ID={existingId}"); + return existingId; + } + var newLeafId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] SetName({newLeafId}, '{pattern.Id}')"); + links.SetName(newLeafId, pattern.Id); + var restriction = new DoubletLink(newLeafId, links.Constants.Null, links.Constants.Null); + var substitution = new DoubletLink(newLeafId, newLeafId, newLeafId); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link {newLeafId} to be self-referential"); + links.Update(restriction, substitution, (beforeState, afterState) => + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={beforeState}, after={afterState}"); + return links.Constants.Continue; + }); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created new self-referential named leaf '{pattern.Id}' => ID={newLeafId}"); + return newLeafId; + } + + /// + /// Ensures a composite link exists with the given index or named identifier and child IDs. + /// + private static uint CreateCompositeLink( + string? literalIdentifier, + uint sourceLinkId, + uint targetLinkId, + INamedTypesLinks links, + Options options) + { + // Determine the numeric index for the composite: default 0, wildcard, or parsed from identifier + uint compositeIndex = 0; + var wildcardIndex = links.Constants.Any; + if (!string.IsNullOrEmpty(literalIdentifier)) + { + if (literalIdentifier == "*") + { + compositeIndex = wildcardIndex; + } + else + { + var identifierClean = literalIdentifier.Replace(":", string.Empty); + if (uint.TryParse(identifierClean, out var parsedIndex)) + { + compositeIndex = parsedIndex; + } + } + } + // Build the composite link structure and ensure it exists + var compositeLinkDefinition = new DoubletLink(compositeIndex, sourceLinkId, targetLinkId); + var compositeLinkId = EnsureLinkCreated(links, compositeLinkDefinition, options); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created or ensured composite link => Index={compositeIndex}, Source={sourceLinkId}, Target={targetLinkId} => Actual ID={compositeLinkId}"); + // Assign the name for non-numeric identifiers + if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier) && !literalIdentifier.StartsWith("$")) + { + links.SetName(compositeLinkId, literalIdentifier); + } + return compositeLinkId; + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.References.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.References.cs new file mode 100644 index 0000000..7b5a408 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.References.cs @@ -0,0 +1,329 @@ +// Part of the partial AdvancedMixedQueryProcessor class. +// Reference validation and auto-creation of links a substitution refers to. +using System; +using Platform.Delegates; +using Platform.Data; +using Platform.Data.Doublets; +using Link.Foundation.Links.Notation; +using LinoLink = Link.Foundation.Links.Notation.Link; +using DoubletLink = Platform.Data.Doublets.Link; +namespace Foundation.Data.Doublets.Cli +{ + public static partial class AdvancedMixedQueryProcessor + { + private static void ValidateLinksExistOrWillBeCreated( + INamedTypesLinks links, + IList restrictionPatterns, + IList substitutionPatterns, + Options options) + { + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Starting validation"); + + var plan = BuildLinkReferencePlan(links, substitutionPatterns); + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {string.Join(", ", plan.NumericIdsToBeCreated.OrderBy(id => id))}"); + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Named links to be created: {string.Join(", ", plan.NamesToBeCreated.OrderBy(name => name, StringComparer.Ordinal))}"); + + CollectMissingReferences(restrictionPatterns, links, plan, false, "restriction", options); + CollectMissingReferences(substitutionPatterns, links, plan, true, "substitution", options); + + if (plan.MissingReferences.Count > 0) + { + if (!options.AutoCreateMissingReferences) + { + var missing = plan.MissingReferences[0]; + throw new InvalidOperationException( + $"Invalid reference to non-existent link '{missing.Identifier}' in {missing.PatternType} pattern. " + + $"Link '{missing.Identifier}' does not exist and will not be created by this operation. " + + "Use --auto-create-missing-references to create missing references as point links." + ); + } + + AutoCreateMissingReferences(links, plan, options); + } + + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); + } + + private sealed class LinkReferencePlan + { + public HashSet NumericIdsToBeCreated { get; } = new(); + public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); + public HashSet<(uint Source, uint Target)> CompositePairsToBeCreated { get; } = new(); + public List MissingReferences { get; } = new(); + private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); + + public void AddMissingReference(MissingLinkReference reference) + { + if (_missingReferenceKeys.Add(reference.Key)) + { + MissingReferences.Add(reference); + } + } + } + + private sealed class MissingLinkReference + { + public required string Identifier { get; init; } + public required string PatternType { get; init; } + public required uint? NumericId { get; init; } + public string Key => NumericId.HasValue ? $"id:{NumericId.Value}" : $"name:{Identifier}"; + } + + private static LinkReferencePlan BuildLinkReferencePlan(INamedTypesLinks links, IList substitutionPatterns) + { + var plan = new LinkReferencePlan(); + var reservedNumericIds = new HashSet(); + + foreach (var pattern in substitutionPatterns) + { + CollectExplicitDefinitions(pattern, plan, reservedNumericIds); + } + + foreach (var pattern in substitutionPatterns) + { + CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); + } + + foreach (var pattern in substitutionPatterns) + { + CollectCompositePairs(pattern, plan); + } + + return plan; + } + + private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePlan plan, HashSet reservedNumericIds) + { + if (IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + if (uint.TryParse(identifier, out var linkId)) + { + plan.NumericIdsToBeCreated.Add(linkId); + reservedNumericIds.Add(linkId); + } + else + { + plan.NamesToBeCreated.Add(identifier); + } + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectExplicitDefinitions(subPattern, plan, reservedNumericIds); + } + } + } + + private static void CollectCompositePairs(LinoLink pattern, LinkReferencePlan plan) + { + if (IsComposite(pattern) && + TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier) && + pattern.Values != null && + TryGetConcreteNumericIdentifier(pattern.Values[0].Id, out var source) && + TryGetConcreteNumericIdentifier(pattern.Values[1].Id, out var target)) + { + plan.CompositePairsToBeCreated.Add((source, target)); + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectCompositePairs(subPattern, plan); + } + } + } + + private static void CollectImplicitDefinitions( + LinoLink pattern, + INamedTypesLinks links, + LinkReferencePlan plan, + HashSet reservedNumericIds) + { + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectImplicitDefinitions(subPattern, links, plan, reservedNumericIds); + } + } + + if (IsComposite(pattern) && !TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier)) + { + var nextId = GetNextAvailableLinkId(links, reservedNumericIds); + reservedNumericIds.Add(nextId); + plan.NumericIdsToBeCreated.Add(nextId); + } + } + + private static uint GetNextAvailableLinkId(INamedTypesLinks links, HashSet reservedNumericIds) + { + uint nextId = 1; + while (links.Exists(nextId) || reservedNumericIds.Contains(nextId)) + { + nextId++; + } + return nextId; + } + + private static void CollectMissingReferences( + IList patterns, + INamedTypesLinks links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, + Options options) + { + foreach (var pattern in patterns) + { + CollectMissingReferences(pattern, links, plan, isSubstitution, patternType, options); + } + } + + private static void CollectMissingReferences( + LinoLink pattern, + INamedTypesLinks links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, + Options options) + { + var patternIdIsDefinition = isSubstitution && IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier); + + if (!patternIdIsDefinition && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + ValidateReferenceIdentifier(identifier, links, plan, patternType, options); + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectMissingReferences(subPattern, links, plan, isSubstitution, patternType, options); + } + } + } + + private static void ValidateReferenceIdentifier( + string identifier, + INamedTypesLinks links, + LinkReferencePlan plan, + string patternType, + Options options) + { + if (uint.TryParse(identifier, out var linkId)) + { + if (!links.Exists(linkId) && !plan.NumericIdsToBeCreated.Contains(linkId)) + { + plan.AddMissingReference(new MissingLinkReference + { + Identifier = identifier, + PatternType = patternType, + NumericId = linkId + }); + return; + } + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); + return; + } + + if (links.GetByName(identifier) == links.Constants.Null && !plan.NamesToBeCreated.Contains(identifier)) + { + plan.AddMissingReference(new MissingLinkReference + { + Identifier = identifier, + PatternType = patternType, + NumericId = null + }); + return; + } + + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {patternType} pattern"); + } + + private static void AutoCreateMissingReferences( + INamedTypesLinks links, + LinkReferencePlan plan, + Options options) + { + foreach (var missing in plan.MissingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) + { + var linkId = missing.NumericId!.Value; + if (links.Exists(linkId)) + { + continue; + } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId}."); + LinksExtensions.EnsureCreated(links, linkId); + if (plan.CompositePairsToBeCreated.Contains((linkId, linkId))) + { + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Link {linkId} exists as a placeholder because ({linkId}, {linkId}) is defined by the substitution."); + continue; + } + links.Update( + new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), + new DoubletLink(linkId, linkId, linkId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + + foreach (var missing in plan.MissingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) + { + if (links.GetByName(missing.Identifier) != links.Constants.Null) + { + continue; + } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{missing.Identifier}' as point link."); + EnsureNamedPointLink(links, missing.Identifier, options); + } + } + + private static void EnsureNamedPointLink(INamedTypesLinks links, string name, Options options) + { + if (links.GetByName(name) != links.Constants.Null) + { + return; + } + + var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(newId, name); + links.Update( + new DoubletLink(newId, links.Constants.Null, links.Constants.Null), + new DoubletLink(newId, newId, newId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + + private static bool IsComposite(LinoLink pattern) => pattern.Values?.Count == 2; + + private static bool TryGetConcreteIdentifier(string? id, out string identifier) + { + identifier = string.Empty; + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } + + identifier = id.TrimEnd(':'); + if (identifier.Length == 0 || identifier == "*" || identifier.StartsWith("$")) + { + return false; + } + + return true; + } + + private static bool TryGetConcreteNumericIdentifier(string? id, out uint linkId) + { + linkId = 0; + return TryGetConcreteIdentifier(id, out var identifier) && uint.TryParse(identifier, out linkId); + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs index c515815..27105fc 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs @@ -5,10 +5,9 @@ using Link.Foundation.Links.Notation; using LinoLink = Link.Foundation.Links.Notation.Link; using DoubletLink = Platform.Data.Doublets.Link; - namespace Foundation.Data.Doublets.Cli { - public static class AdvancedMixedQueryProcessor + public static partial class AdvancedMixedQueryProcessor { public class Options { @@ -480,1198 +479,5 @@ private static void ApplyAllPlannedOperations( } } } - - private static List> FindAllSolutions(INamedTypesLinks links, List patterns) - { - var partialSolutions = new List> { new Dictionary() }; - - for (int i = 0; i < patterns.Count; i++) - { - var pattern = patterns[i]; - var newSolutions = new List>(); - foreach (var solution in partialSolutions) - { - var matches = MatchPattern(links, pattern, solution).ToList(); - foreach (var match in matches) - { - if (AreSolutionsCompatible(solution, match)) - { - var combinedSolution = new Dictionary(solution); - foreach (var assignment in match) - { - combinedSolution[assignment.Key] = assignment.Value; - } - newSolutions.Add(combinedSolution); - } - } - } - partialSolutions = newSolutions; - if (partialSolutions.Count == 0) break; - } - - return partialSolutions; - } - - private static bool AreSolutionsCompatible( - Dictionary existingSolution, - Dictionary newAssignments) - { - foreach (var assignment in newAssignments) - { - if (existingSolution.TryGetValue(assignment.Key, out var existingValue)) - { - if (existingValue != assignment.Value) - { - return false; - } - } - } - return true; - } - - private static IEnumerable> MatchPattern( - INamedTypesLinks links, - Pattern pattern, - Dictionary currentSolution) - { - var anyConstant = links.Constants.Any; - if (pattern.IsLeaf) - { - uint leafIndex = ResolveId(links, pattern.Index, currentSolution); - var candidates = links.All(new DoubletLink(leafIndex, anyConstant, anyConstant)); - foreach (var link in candidates) - { - var candidateLink = new DoubletLink(link); - var assignments = new Dictionary(); - AssignVariableIfNeeded(pattern.Index, candidateLink.Index, assignments); - yield return assignments; - } - yield break; - } - - bool indexIsVariable = IsVariable(pattern.Index); - bool indexIsAny = pattern.Index == "*"; - uint resolvedIndex = ResolveId(links, pattern.Index, currentSolution); - - // If idxResolved is a known link => skip enumerating everything - if (!indexIsVariable && !indexIsAny && resolvedIndex != anyConstant && resolvedIndex != 0 && links.Exists(resolvedIndex)) - { - var link = new DoubletLink(links.GetLink(resolvedIndex)); - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); - foreach (var targetSolution in targetMatches) - { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, resolvedIndex, combined); - yield return combined; - } - } - } - else - { - // Otherwise we iterate over all links - var allLinks = links.All(new DoubletLink(anyConstant, anyConstant, anyConstant)); - foreach (var raw in allLinks) - { - var candidateLink = new DoubletLink(raw); - if (!CheckIdMatch(links, pattern.Index, candidateLink.Index, currentSolution)) - continue; - - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, candidateLink.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, candidateLink.Target, sourceSolution); - foreach (var targetSolution in targetMatches) - { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, candidateLink.Index, combined); - yield return combined; - } - } - } - } - } - - private static IEnumerable> RecursiveMatchSubPattern( - INamedTypesLinks links, - Pattern? pattern, - uint linkId, - Dictionary currentSolution) - { - if (pattern == null) - { - yield return currentSolution; - yield break; - } - - if (pattern.IsLeaf) - { - if (CheckIdMatch(links, pattern.Index, linkId, currentSolution)) - { - var newSolution = new Dictionary(currentSolution); - AssignVariableIfNeeded(pattern.Index, linkId, newSolution); - yield return newSolution; - } - yield break; - } - - if (!links.Exists(linkId)) yield break; - - var link = new DoubletLink(links.GetLink(linkId)); - if (!CheckIdMatch(links, pattern.Index, link.Index, currentSolution)) - { - yield break; - } - - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); - foreach (var targetSolution in targetMatches) - { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, link.Index, combined); - yield return combined; - } - } - } - - private static bool CheckIdMatch( - INamedTypesLinks links, - string patternId, - uint candidateId, - Dictionary currentSolution) - { - if (string.IsNullOrEmpty(patternId)) return true; - if (patternId == "*") return true; - - if (IsVariable(patternId)) - { - if (currentSolution.TryGetValue(patternId, out var existingVal)) - { - return existingVal == candidateId; - } - return true; - } - - uint parsed = links.Constants.Any; - if (TryParseLinkId(patternId, links, ref parsed)) - { - if (parsed == links.Constants.Any) return true; - return parsed == candidateId; - } - return true; - } - - private static void AssignVariableIfNeeded(string id, uint value, Dictionary assignments) - { - if (IsVariable(id)) - { - assignments[id] = value; - } - } - - private static bool IsVariable(string identifier) - { - return !string.IsNullOrEmpty(identifier) && identifier.StartsWith("$"); - } - - private static uint ResolveId( - INamedTypesLinks links, - string identifier, - Dictionary currentSolution) - { - var anyConstant = links.Constants.Any; - if (string.IsNullOrEmpty(identifier)) return anyConstant; - if (currentSolution.TryGetValue(identifier, out var value)) - { - return value; - } - if (IsVariable(identifier)) - { - return anyConstant; - } - uint parsedValue = anyConstant; - if (TryParseLinkId(identifier, links, ref parsedValue)) - { - return parsedValue; - } - return anyConstant; - } - - private static bool DetermineIfSolutionIsNoOperation( - Dictionary solution, - List restrictions, - List substitutions, - INamedTypesLinks links) - { - var substitutedRestrictions = restrictions - .Select(r => ApplySolutionToPattern(links, solution, r, isSubstitution: false)) - .Where(link => link != null) - .Select(link => new DoubletLink(link!)) - .ToList(); - - var substitutedSubstitutions = ApplySolutionToPatterns(links, solution, substitutions, isSubstitution: true); - - substitutedRestrictions.Sort((a, b) => a.Index.CompareTo(b.Index)); - substitutedSubstitutions.Sort((a, b) => a.Index.CompareTo(b.Index)); - - if (substitutedRestrictions.Count != substitutedSubstitutions.Count) return false; - for (int i = 0; i < substitutedRestrictions.Count; i++) - { - if (!substitutedRestrictions[i].Equals(substitutedSubstitutions[i])) - { - return false; - } - } - return true; - } - - private static List ExtractMatchedLinks( - INamedTypesLinks links, - Dictionary solution, - List patterns) - { - var matchedLinks = new List(); - foreach (var pattern in patterns) - { - var applied = ApplySolutionToPattern(links, solution, pattern); - if (applied != null) - { - var matches = links.All(applied); - foreach (var match in matches) - { - matchedLinks.Add(new DoubletLink(match)); - } - } - } - return matchedLinks.Distinct().ToList(); - } - - private static DoubletLink? ApplySolutionToPattern( - INamedTypesLinks links, - Dictionary solution, - Pattern? pattern, - bool isSubstitution = false, - HashSet? visitedIndexes = null) - { - if (pattern == null) return null; - visitedIndexes ??= new HashSet(); - - // Retrieve the ANY constant once for both leaf and composite cases - var anyConstant = links.Constants.Any; - - if (pattern.IsLeaf) - { - uint resolvedIndex = ResolveId(links, pattern.Index, solution); - return new DoubletLink(resolvedIndex, anyConstant, anyConstant); - } - else - { - uint resolvedIndex = ResolvePatternIndex(links, pattern.Index, solution, isSubstitution); - var sourceLink = ApplySolutionToPattern(links, solution, pattern.Source, isSubstitution, visitedIndexes); - var targetLink = ApplySolutionToPattern(links, solution, pattern.Target, isSubstitution, visitedIndexes); - - uint resolvedSource = sourceLink?.Index ?? anyConstant; - uint resolvedTarget = targetLink?.Index ?? anyConstant; - - PreserveExistingSubstitutionParts(links, solution, pattern, resolvedIndex, ref resolvedSource, ref resolvedTarget, isSubstitution, visitedIndexes); - - if (resolvedSource == 0) resolvedSource = anyConstant; - if (resolvedTarget == 0) resolvedTarget = anyConstant; - - return new DoubletLink(resolvedIndex, resolvedSource, resolvedTarget); - } - } - - private static uint ResolvePatternIndex( - INamedTypesLinks links, - string identifier, - Dictionary solution, - bool isSubstitution) - { - if (isSubstitution && string.IsNullOrEmpty(identifier)) - { - return links.Constants.Null; - } - - if (isSubstitution && IsVariable(identifier) && !solution.ContainsKey(identifier)) - { - return links.Constants.Null; - } - - return ResolveId(links, identifier, solution); - } - - private static List ApplySolutionToPatterns( - INamedTypesLinks links, - Dictionary solution, - List patterns, - bool isSubstitution) - { - var workingSolution = isSubstitution ? new Dictionary(solution) : solution; - return patterns - .Select(pattern => ApplySolutionToPattern(links, workingSolution, pattern, isSubstitution)) - .Where(link => link != null) - .Select(link => new DoubletLink(link!)) - .ToList(); - } - - private static void PreserveExistingSubstitutionParts( - INamedTypesLinks links, - Dictionary solution, - Pattern pattern, - uint resolvedIndex, - ref uint resolvedSource, - ref uint resolvedTarget, - bool isSubstitution, - HashSet visitedIndexes) - { - if (!isSubstitution || resolvedIndex == links.Constants.Null || resolvedIndex == links.Constants.Any || !links.Exists(resolvedIndex)) - { - return; - } - - if (!visitedIndexes.Add(resolvedIndex)) - { - return; - } - - try - { - var existingLink = new DoubletLink(links.GetLink(resolvedIndex)); - - if (ShouldPreserveExistingPart(pattern.Source, solution) && CanPreserveExistingPart(existingLink, existingLink.Source, visitedIndexes)) - { - resolvedSource = existingLink.Source; - AssignVariableIfNeeded(pattern.Source!.Index, resolvedSource, solution); - } - else if (TryResolveVariablePart(pattern.Source, solution, out var boundSource)) - { - resolvedSource = boundSource; - } - - if (ShouldPreserveExistingPart(pattern.Target, solution) && CanPreserveExistingPart(existingLink, existingLink.Target, visitedIndexes)) - { - resolvedTarget = existingLink.Target; - AssignVariableIfNeeded(pattern.Target!.Index, resolvedTarget, solution); - } - else if (TryResolveVariablePart(pattern.Target, solution, out var boundTarget)) - { - resolvedTarget = boundTarget; - } - } - finally - { - visitedIndexes.Remove(resolvedIndex); - } - } - - private static bool ShouldPreserveExistingPart(Pattern? partPattern, Dictionary solution) - { - return partPattern?.IsLeaf == true - && IsVariable(partPattern.Index) - && !solution.ContainsKey(partPattern.Index); - } - - private static bool TryResolveVariablePart(Pattern? partPattern, Dictionary solution, out uint value) - { - value = default; - return partPattern?.IsLeaf == true - && IsVariable(partPattern.Index) - && solution.TryGetValue(partPattern.Index, out value); - } - - private static bool CanPreserveExistingPart(DoubletLink existingLink, uint part, HashSet visitedIndexes) - { - return existingLink.IsFullPoint() - || existingLink.IsPartialPoint() - || !visitedIndexes.Contains(part); - } - - private static void CreateOrUpdateLink(INamedTypesLinks links, DoubletLink linkDefinition, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - // Wildcard substitution rename: delegate to nested creation with proper naming - if (linkDefinition.Index == anyConstant) - { - TraceIfEnabled(options, "[CreateOrUpdateLink] Detected wildcard substitution => nested create & name."); - var parsed = new Parser().Parse(options.Query ?? string.Empty); - if (parsed.Count > 0) - { - var outer = parsed[0]; - if (outer.Values != null && outer.Values.Count > 1) - { - var substitutionLinoLink = outer.Values[1]; - if (substitutionLinoLink.Values != null) - { - foreach (var composite in substitutionLinoLink.Values) - { - EnsureNestedLinkCreatedRecursively(links, composite, options); - } - } - } - } - return; - } - - if (linkDefinition.Index != nullConstant) - { - // update existing link - if (!links.Exists(linkDefinition.Index)) - { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} doesn't exist => ensuring creation."); - LinksExtensions.EnsureCreated(links, linkDefinition.Index); - } - var existingLinkRecord = links.GetLink(linkDefinition.Index); - var existingDoublet = new DoubletLink(existingLinkRecord); - - if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) - { - TraceIfEnabled(options, - $"[CreateOrUpdateLink] Updating link #{linkDefinition.Index}: {existingDoublet.Source}->{linkDefinition.Source}, {existingDoublet.Target}->{linkDefinition.Target}."); - LinksExtensions.EnsureCreated(links, linkDefinition.Index); - links.Update( - new DoubletLink(linkDefinition.Index, anyConstant, anyConstant), - linkDefinition, - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } - else - { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} is already S={linkDefinition.Source}, T={linkDefinition.Target} => no change."); - options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); - } - } - else - { - // create new link - var existingLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); - if (existingLinkIndex == default) - { - uint newLinkIndex = 0; - TraceIfEnabled(options, - $"[CreateOrUpdateLink] Creating new link => (S={linkDefinition.Source},T={linkDefinition.Target})."); - links.CreateAndUpdate(linkDefinition.Source, linkDefinition.Target, (beforeState, afterState) => - { - var afterLinkRecord = new DoubletLink(afterState); - if (newLinkIndex == 0 && afterLinkRecord.Index != 0 && afterLinkRecord.Index != anyConstant) - { - newLinkIndex = afterLinkRecord.Index; - TraceIfEnabled(options, $"[CreateOrUpdateLink] => assigned new ID={newLinkIndex}"); - } - return options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue; - }); - - if (newLinkIndex == 0 || newLinkIndex == anyConstant) - { - newLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); - } - } - else - { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link already found => ID={existingLinkIndex}, no changes."); - var existingLink = new DoubletLink(existingLinkIndex, linkDefinition.Source, linkDefinition.Target); - options.ChangesHandler?.Invoke(existingLink, existingLink); - } - } - } - - private static void RemoveLinks( - INamedTypesLinks links, - DoubletLink restriction, - Options options) - { - var linksToRemove = links.All(restriction) - .Where(l => l != null) - .Select(l => new DoubletLink(l)) - .ToList(); - - TraceIfEnabled(options, - $"[RemoveLinks] Found {linksToRemove.Count} link(s) matching (ID={restriction.Index}, S={restriction.Source}, T={restriction.Target})."); - - foreach (var link in linksToRemove) - { - if (links.Exists(link.Index)) - { - // Remove the name before deleting - links.RemoveName(link.Index); - TraceIfEnabled(options, $"[RemoveLinks] Deleting link => ID={link.Index}, S={link.Source}, T={link.Target}"); - links.Delete(link, (before, after) => - options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); - } - } - } - - private static DoubletLink ConvertToDoubletLink(INamedTypesLinks links, LinoLink linoLink, uint defaultValue) - { - uint index = defaultValue; - uint source = defaultValue; - uint target = defaultValue; - TryParseLinkId(linoLink.Id, links, ref index); - if (linoLink.Values?.Count == 2) - { - var sourceLink = linoLink.Values[0]; - TryParseLinkId(sourceLink.Id, links, ref source); - var targetLink = linoLink.Values[1]; - TryParseLinkId(targetLink.Id, links, ref target); - } - return new DoubletLink(index, source, target); - } - - private static bool TryParseLinkId(string? id, INamedTypesLinks links, ref uint parsedValue) - { - if (string.IsNullOrEmpty(id)) return false; - if (id == "*") - { - parsedValue = links.Constants.Any; - return true; - } - else if (id.EndsWith(":")) - { - var trimmed = id.TrimEnd(':'); - if (uint.TryParse(trimmed, out uint linkId)) - { - parsedValue = linkId; - return true; - } - // Try to resolve as string alias - var aliasId = links.GetByName(trimmed); - if (aliasId != links.Constants.Null) - { - parsedValue = aliasId; - return true; - } - } - else if (uint.TryParse(id, out uint linkVal)) - { - parsedValue = linkVal; - return true; - } - else - { - // Try to resolve as string alias - var aliasId = links.GetByName(id); - if (aliasId != links.Constants.Null) - { - parsedValue = aliasId; - return true; - } - } - return false; - } - - public class Pattern - { - public string Index; - public Pattern? Source; - public Pattern? Target; - - public Pattern(string index, Pattern? source = null, Pattern? target = null) - { - Index = index ?? ""; - Source = source; - Target = target; - } - - public bool IsLeaf => Source == null && Target == null; - } - - private static Pattern CreatePatternFromLino(LinoLink linkNode) - { - if (linkNode.Values == null || linkNode.Values.Count == 0) - { - return new Pattern(linkNode.Id ?? ""); - } - - if (linkNode.Values.Count == 2) - { - var sourcePattern = CreatePatternFromLino(linkNode.Values[0]); - var targetPattern = CreatePatternFromLino(linkNode.Values[1]); - return new Pattern(linkNode.Id ?? "", sourcePattern, targetPattern); - } - - // If more than 2 => treat similarly to leaf with ID - return new Pattern(linkNode.Id ?? ""); - } - - private static uint EnsureLinkCreated(INamedTypesLinks links, DoubletLink link, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - if (link.Index == nullConstant) - { - // If no index => search or create - var existingIndex = links.SearchOrDefault(link.Source, link.Target); - if (existingIndex == default) - { - uint createdIndex = 0; - TraceIfEnabled(options, $"[EnsureLinkCreated] Creating link for (S={link.Source}, T={link.Target})."); - links.CreateAndUpdate(link.Source, link.Target, (before, after) => - { - var afterLink = new DoubletLink(after); - if (createdIndex == 0 && afterLink.Index != 0 && afterLink.Index != anyConstant) - { - createdIndex = afterLink.Index; - TraceIfEnabled(options, $"[EnsureLinkCreated] => assigned new ID={createdIndex}"); - } - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - - if (createdIndex == 0 || createdIndex == anyConstant) - { - createdIndex = links.SearchOrDefault(link.Source, link.Target); - } - return createdIndex; - } - else - { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link already found => ID={existingIndex} => no-op."); - var existing = new DoubletLink(existingIndex, link.Source, link.Target); - options.ChangesHandler?.Invoke(existing, existing); - return existingIndex; - } - } - else - { - // We have an index => ensure created or updated - if (!links.Exists(link.Index)) - { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} doesn't exist => ensuring creation."); - LinksExtensions.EnsureCreated(links, link.Index); - } - var stored = links.GetLink(link.Index); - var storedD = new DoubletLink(stored); - if (storedD.Source != link.Source || storedD.Target != link.Target) - { - TraceIfEnabled(options, - $"[EnsureLinkCreated] Updating link #{link.Index} => {storedD.Source}->{link.Source}, {storedD.Target}->{link.Target}."); - uint finalIndex = link.Index; - links.Update(new DoubletLink(link.Index, anyConstant, anyConstant), link, (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); - return finalIndex; - } - else - { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); - options.ChangesHandler?.Invoke(storedD, storedD); - return link.Index; - } - } - } - - // Helper for link naming logic - private static bool IsNumericOrStar(string? id) - { - if (string.IsNullOrEmpty(id)) return false; - if (id == "*") return true; - uint dummy; - return uint.TryParse(id, out dummy); - } - - private static void TraceIfEnabled(Options options, string message) - { - if (options.Trace) - { - Console.WriteLine(message); - } - } - - // Consolidates getting or creating a named link (leaf) without setting its relationships - private static uint EnsureNamedLeafLink(INamedTypesLinks links, string name, Options options) - { - var existing = links.GetByName(name); - if (existing != links.Constants.Null) return existing; - var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created named leaf '{name}' => ID={newId}"); - links.SetName(newId, name); - return newId; - } - - // Applies a single structural update to an existing link: sets its source and target - private static void ApplyCompositeUpdate(INamedTypesLinks links, uint id, uint source, uint target, Options options) - { - var restriction = new DoubletLink(id, links.Constants.Null, links.Constants.Null); - var substitution = new DoubletLink(id, source, target); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link ID={id} => Source={source}, Target={target}"); - links.Update(restriction, substitution, (before, after) => - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={before}, after={after}"); - return links.Constants.Continue; - }); - } - - /// - /// Detects a two-child composite pattern where at least one child matches the composite identifier. - /// - private static bool TryGetTwoChildCompositePattern( - LinoLink pattern, - out string compositeIdentifier, - out LinoLink leftPattern, - out LinoLink rightPattern) - { - compositeIdentifier = pattern.Id ?? string.Empty; - leftPattern = default!; - rightPattern = default!; - if (!string.IsNullOrEmpty(compositeIdentifier) - && pattern.Values != null - && pattern.Values.Count == 2) - { - leftPattern = pattern.Values[0]; - rightPattern = pattern.Values[1]; - // Only detect composites when one or both children share the identifier - if (leftPattern.Id == compositeIdentifier || rightPattern.Id == compositeIdentifier) - { - return true; - } - } - return false; - } - - private enum CompositeCase { Self, LeftMix, RightMix } - - private static CompositeCase ClassifyCompositeCase(string name, LinoLink left, LinoLink right) - { - if (left.Id == name && right.Id == name) return CompositeCase.Self; - if (left.Id == name && right.Id != name) return CompositeCase.LeftMix; - if (left.Id != name && right.Id == name) return CompositeCase.RightMix; - throw new InvalidOperationException($"Invalid composite pattern for name '{name}'"); - } - - private static uint HandleStringComposite(string name, LinoLink left, LinoLink right, INamedTypesLinks links, Options options) - { - var id = EnsureNamedLeafLink(links, name, options); - var caseType = ClassifyCompositeCase(name, left, right); - switch (caseType) - { - case CompositeCase.Self: - ApplyCompositeUpdate(links, id, id, id, options); - return id; - case CompositeCase.LeftMix: - { - var otherId = EnsureNestedLinkCreatedRecursively(links, right, options); - ApplyCompositeUpdate(links, id, id, otherId, options); - return id; - } - case CompositeCase.RightMix: - { - var otherId = EnsureNestedLinkCreatedRecursively(links, left, options); - ApplyCompositeUpdate(links, id, otherId, id, options); - return id; - } - default: - throw new InvalidOperationException($"Unhandled composite case {caseType}"); - } - } - - /// - /// Resolves a single leaf pattern into its numeric or named link ID. - /// - private static uint ResolveLeaf(LinoLink pattern, INamedTypesLinks links, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - if (string.IsNullOrEmpty(pattern.Id)) - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with empty ID => returning ANY."); - return anyConstant; - } - if (pattern.Id == "*") - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with '*' => returning ANY."); - return anyConstant; - } - if (pattern.Id.StartsWith("$")) - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Variable leaf => returning ANY."); - return anyConstant; - } - if (uint.TryParse(pattern.Id, out uint parsedNumber)) - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Leaf parse => returning {parsedNumber}."); - return parsedNumber; - } - var existingId = links.GetByName(pattern.Id); - if (existingId != links.Constants.Null) - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Found existing named leaf '{pattern.Id}' => ID={existingId}"); - return existingId; - } - var newLeafId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] SetName({newLeafId}, '{pattern.Id}')"); - links.SetName(newLeafId, pattern.Id); - var restriction = new DoubletLink(newLeafId, links.Constants.Null, links.Constants.Null); - var substitution = new DoubletLink(newLeafId, newLeafId, newLeafId); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link {newLeafId} to be self-referential"); - links.Update(restriction, substitution, (beforeState, afterState) => - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={beforeState}, after={afterState}"); - return links.Constants.Continue; - }); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created new self-referential named leaf '{pattern.Id}' => ID={newLeafId}"); - return newLeafId; - } - - /// - /// Ensures a composite link exists with the given index or named identifier and child IDs. - /// - private static uint CreateCompositeLink( - string? literalIdentifier, - uint sourceLinkId, - uint targetLinkId, - INamedTypesLinks links, - Options options) - { - // Determine the numeric index for the composite: default 0, wildcard, or parsed from identifier - uint compositeIndex = 0; - var wildcardIndex = links.Constants.Any; - if (!string.IsNullOrEmpty(literalIdentifier)) - { - if (literalIdentifier == "*") - { - compositeIndex = wildcardIndex; - } - else - { - var identifierClean = literalIdentifier.Replace(":", string.Empty); - if (uint.TryParse(identifierClean, out var parsedIndex)) - { - compositeIndex = parsedIndex; - } - } - } - // Build the composite link structure and ensure it exists - var compositeLinkDefinition = new DoubletLink(compositeIndex, sourceLinkId, targetLinkId); - var compositeLinkId = EnsureLinkCreated(links, compositeLinkDefinition, options); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created or ensured composite link => Index={compositeIndex}, Source={sourceLinkId}, Target={targetLinkId} => Actual ID={compositeLinkId}"); - // Assign the name for non-numeric identifiers - if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier) && !literalIdentifier.StartsWith("$")) - { - links.SetName(compositeLinkId, literalIdentifier); - } - return compositeLinkId; - } - - private static void ValidateLinksExistOrWillBeCreated( - INamedTypesLinks links, - IList restrictionPatterns, - IList substitutionPatterns, - Options options) - { - TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Starting validation"); - - var plan = BuildLinkReferencePlan(links, substitutionPatterns); - - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {string.Join(", ", plan.NumericIdsToBeCreated.OrderBy(id => id))}"); - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Named links to be created: {string.Join(", ", plan.NamesToBeCreated.OrderBy(name => name, StringComparer.Ordinal))}"); - - CollectMissingReferences(restrictionPatterns, links, plan, false, "restriction", options); - CollectMissingReferences(substitutionPatterns, links, plan, true, "substitution", options); - - if (plan.MissingReferences.Count > 0) - { - if (!options.AutoCreateMissingReferences) - { - var missing = plan.MissingReferences[0]; - throw new InvalidOperationException( - $"Invalid reference to non-existent link '{missing.Identifier}' in {missing.PatternType} pattern. " + - $"Link '{missing.Identifier}' does not exist and will not be created by this operation. " + - "Use --auto-create-missing-references to create missing references as point links." - ); - } - - AutoCreateMissingReferences(links, plan, options); - } - - TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); - } - - private sealed class LinkReferencePlan - { - public HashSet NumericIdsToBeCreated { get; } = new(); - public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); - public HashSet<(uint Source, uint Target)> CompositePairsToBeCreated { get; } = new(); - public List MissingReferences { get; } = new(); - private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); - - public void AddMissingReference(MissingLinkReference reference) - { - if (_missingReferenceKeys.Add(reference.Key)) - { - MissingReferences.Add(reference); - } - } - } - - private sealed class MissingLinkReference - { - public required string Identifier { get; init; } - public required string PatternType { get; init; } - public required uint? NumericId { get; init; } - public string Key => NumericId.HasValue ? $"id:{NumericId.Value}" : $"name:{Identifier}"; - } - - private static LinkReferencePlan BuildLinkReferencePlan(INamedTypesLinks links, IList substitutionPatterns) - { - var plan = new LinkReferencePlan(); - var reservedNumericIds = new HashSet(); - - foreach (var pattern in substitutionPatterns) - { - CollectExplicitDefinitions(pattern, plan, reservedNumericIds); - } - - foreach (var pattern in substitutionPatterns) - { - CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); - } - - foreach (var pattern in substitutionPatterns) - { - CollectCompositePairs(pattern, plan); - } - - return plan; - } - - private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePlan plan, HashSet reservedNumericIds) - { - if (IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var identifier)) - { - if (uint.TryParse(identifier, out var linkId)) - { - plan.NumericIdsToBeCreated.Add(linkId); - reservedNumericIds.Add(linkId); - } - else - { - plan.NamesToBeCreated.Add(identifier); - } - } - - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectExplicitDefinitions(subPattern, plan, reservedNumericIds); - } - } - } - - private static void CollectCompositePairs(LinoLink pattern, LinkReferencePlan plan) - { - if (IsComposite(pattern) && - TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier) && - pattern.Values != null && - TryGetConcreteNumericIdentifier(pattern.Values[0].Id, out var source) && - TryGetConcreteNumericIdentifier(pattern.Values[1].Id, out var target)) - { - plan.CompositePairsToBeCreated.Add((source, target)); - } - - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectCompositePairs(subPattern, plan); - } - } - } - - private static void CollectImplicitDefinitions( - LinoLink pattern, - INamedTypesLinks links, - LinkReferencePlan plan, - HashSet reservedNumericIds) - { - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectImplicitDefinitions(subPattern, links, plan, reservedNumericIds); - } - } - - if (IsComposite(pattern) && !TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier)) - { - var nextId = GetNextAvailableLinkId(links, reservedNumericIds); - reservedNumericIds.Add(nextId); - plan.NumericIdsToBeCreated.Add(nextId); - } - } - - private static uint GetNextAvailableLinkId(INamedTypesLinks links, HashSet reservedNumericIds) - { - uint nextId = 1; - while (links.Exists(nextId) || reservedNumericIds.Contains(nextId)) - { - nextId++; - } - return nextId; - } - - private static void CollectMissingReferences( - IList patterns, - INamedTypesLinks links, - LinkReferencePlan plan, - bool isSubstitution, - string patternType, - Options options) - { - foreach (var pattern in patterns) - { - CollectMissingReferences(pattern, links, plan, isSubstitution, patternType, options); - } - } - - private static void CollectMissingReferences( - LinoLink pattern, - INamedTypesLinks links, - LinkReferencePlan plan, - bool isSubstitution, - string patternType, - Options options) - { - var patternIdIsDefinition = isSubstitution && IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier); - - if (!patternIdIsDefinition && TryGetConcreteIdentifier(pattern.Id, out var identifier)) - { - ValidateReferenceIdentifier(identifier, links, plan, patternType, options); - } - - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectMissingReferences(subPattern, links, plan, isSubstitution, patternType, options); - } - } - } - - private static void ValidateReferenceIdentifier( - string identifier, - INamedTypesLinks links, - LinkReferencePlan plan, - string patternType, - Options options) - { - if (uint.TryParse(identifier, out var linkId)) - { - if (!links.Exists(linkId) && !plan.NumericIdsToBeCreated.Contains(linkId)) - { - plan.AddMissingReference(new MissingLinkReference - { - Identifier = identifier, - PatternType = patternType, - NumericId = linkId - }); - return; - } - TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); - return; - } - - if (links.GetByName(identifier) == links.Constants.Null && !plan.NamesToBeCreated.Contains(identifier)) - { - plan.AddMissingReference(new MissingLinkReference - { - Identifier = identifier, - PatternType = patternType, - NumericId = null - }); - return; - } - - TraceIfEnabled(options, $"[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {patternType} pattern"); - } - - private static void AutoCreateMissingReferences( - INamedTypesLinks links, - LinkReferencePlan plan, - Options options) - { - foreach (var missing in plan.MissingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) - { - var linkId = missing.NumericId!.Value; - if (links.Exists(linkId)) - { - continue; - } - - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId}."); - LinksExtensions.EnsureCreated(links, linkId); - if (plan.CompositePairsToBeCreated.Contains((linkId, linkId))) - { - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Link {linkId} exists as a placeholder because ({linkId}, {linkId}) is defined by the substitution."); - continue; - } - links.Update( - new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), - new DoubletLink(linkId, linkId, linkId), - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } - - foreach (var missing in plan.MissingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) - { - if (links.GetByName(missing.Identifier) != links.Constants.Null) - { - continue; - } - - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{missing.Identifier}' as point link."); - EnsureNamedPointLink(links, missing.Identifier, options); - } - } - - private static void EnsureNamedPointLink(INamedTypesLinks links, string name, Options options) - { - if (links.GetByName(name) != links.Constants.Null) - { - return; - } - - var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - links.SetName(newId, name); - links.Update( - new DoubletLink(newId, links.Constants.Null, links.Constants.Null), - new DoubletLink(newId, newId, newId), - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } - - private static bool IsComposite(LinoLink pattern) => pattern.Values?.Count == 2; - - private static bool TryGetConcreteIdentifier(string? id, out string identifier) - { - identifier = string.Empty; - if (string.IsNullOrWhiteSpace(id)) - { - return false; - } - - identifier = id.TrimEnd(':'); - if (identifier.Length == 0 || identifier == "*" || identifier.StartsWith("$")) - { - return false; - } - - return true; - } - - private static bool TryGetConcreteNumericIdentifier(string? id, out uint linkId) - { - linkId = 0; - return TryGetConcreteIdentifier(id, out var identifier) && uint.TryParse(identifier, out linkId); - } } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.Helpers.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.Helpers.cs new file mode 100644 index 0000000..440e901 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.Helpers.cs @@ -0,0 +1,276 @@ +// Shared fixtures and assertions for the AdvancedMixedQueryProcessor tests. +using System.Globalization; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory.United.Generic; + +using DoubletLink = Platform.Data.Doublets.Link; + +using static Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + public partial class AdvancedMixedQueryProcessor + { + /// + private static TimeSpan TestTimeout + { + get + { + const int defaultTimeoutSeconds = 60; + var configured = Environment.GetEnvironmentVariable("LINK_CLI_TEST_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(configured) + && int.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) + && seconds > 0) + { + return TimeSpan.FromSeconds(seconds); + } + return TimeSpan.FromSeconds(defaultTimeoutSeconds); + } + } + + private static void RunTestWithLinks(Action> testAction, bool enableTracing = false) + { + string tempDbFile = Path.GetTempFileName(); + var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); + try + { + // Disposed at the end of the try block, before the finally deletes the backing files. The + // decorator owns memory-mapped handles for both databases; POSIX tolerates unlinking a file + // that is still mapped, Windows fails the delete with IOException. + using var decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); + + var timeout = TestTimeout; + using var cts = new CancellationTokenSource(timeout); + var task = Task.Run(() => + { + testAction(decoratedLinks); + }, cts.Token); + + try + { + task.Wait(cts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine($"[Test] Test was cancelled after {timeout.TotalSeconds} seconds timeout"); + throw new TimeoutException($"Test exceeded {timeout.TotalSeconds} seconds timeout"); + } + } + finally + { + if (File.Exists(namesDbFile)) + { + File.Delete(namesDbFile); + } + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + } + } + + private static List GetAllLinks(NamedTypesDecorator links) + { + var any = links.Constants.Any; + var query = new DoubletLink(index: any, source: any, target: any); + var allLinks = links.All(query).Select(doublet => new DoubletLink(doublet)).ToList(); + Console.WriteLine($"[Test] All links: {string.Join(" ", allLinks)}"); + return allLinks; + } + + private static void ProcessQuery(NamedTypesDecorator links, string query) + { + ProcessQuery(links, new Options { Query = query }); + } + + private static void ProcessQuery(NamedTypesDecorator links, Options options) + { + options.AutoCreateMissingReferences = true; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); + } + + private static void ProcessQueryStrict(NamedTypesDecorator links, string query) + { + ProcessQueryStrict(links, new Options { Query = query }); + } + + private static void ProcessQueryStrict(NamedTypesDecorator links, Options options) + { + options.AutoCreateMissingReferences = false; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); + } + + private static void AssertLinkExists(List allLinks, uint index, uint source, uint target) + { + var link = new DoubletLink(index, source, target); + Assert.True(allLinks.Contains(link), $"Link {link} not found in the list of all links ({string.Join(" ", allLinks)})"); + } + + private static void AssertChangeExists(List<(DoubletLink, DoubletLink)> changes, DoubletLink linkBefore, DoubletLink linkAfter) + { + Assert.Contains(changes, change => change.Item1 == linkBefore && change.Item2 == linkAfter); + } + + // New tests for link reference validation + + [Fact] + public void CreateLinkWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Act & Assert - should throw exception for referencing non-existent link 10 + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(() ((1: 10 20)))"); + }); + + Assert.Contains("Invalid reference to non-existent link '10'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); + }); + } + + [Fact] + public void CreateLinkWithValidSelfReference_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because link 1 references itself + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } + + [Fact] + public void CreateMultipleLinksWithCrossReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because both links are created in the same operation + ProcessQueryStrict(links, "(() ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void CreateLinkReferencingExistingLink_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Arrange - create first link + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Act - should succeed because link 1 exists + ProcessQueryStrict(links, "(() ((2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void UpdateWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Arrange - create initial link + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Act & Assert - should throw exception for referencing non-existent link 99 + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(((1: 1 1)) ((1: 1 99)))"); + }); + + Assert.Contains("Invalid reference to non-existent link '99'", exception.Message); + }); + } + + [Fact] + public void CreateNamedLinkWithMissingNamedReferences_ShouldThrowException() + { + RunTestWithLinks(links => + { + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(() ((child: father mother)))"); + }); + + Assert.Contains("Invalid reference to non-existent link 'father'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); + }); + } + + [Fact] + public void CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((20: 10 20)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 10, 10, 10); + AssertLinkExists(allLinks, 20, 10, 20); + }); + } + + [Fact] + public void CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, fatherId, fatherId, fatherId); + AssertLinkExists(allLinks, motherId, motherId, motherId); + AssertLinkExists(allLinks, childId, fatherId, motherId); + }); + } + + [Fact] + public void CreateLinkWithVariableReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because variables are not validated + ProcessQueryStrict(links, "(() (($link: $source $target)))"); + + // Assert - one link should be created with variables resolved + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } + + [Fact] + public void CreateLinkWithWildcardReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because wildcards are not validated + ProcessQueryStrict(links, "(() ((1: * *)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.More.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.More.cs new file mode 100644 index 0000000..5d12fde --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.More.cs @@ -0,0 +1,896 @@ +// Continuation of the AdvancedMixedQueryProcessor test suite. +using System.Globalization; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory.United.Generic; + +using DoubletLink = Platform.Data.Doublets.Link; + +using static Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + public partial class AdvancedMixedQueryProcessor + { + [Fact] + public void DeleteMultipleLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((1 2) (2 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteLinksByAnyTargetTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((1 *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } + + [Fact] + public void DeleteLinksByAnySourceTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (1 2)))"); + + // Act + ProcessQuery(links, "(((* 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } + + [Fact] + public void DeleteAllLinksBySourceAndTargetTest1() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((* *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void NestedDeleteAllLinksBySourceAndTargetTest1() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "((((* *) (* *))) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteAllLinksBySourceAndTargetTest2() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 1)))"); + + // Act + ProcessQuery(links, "(((* *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteAllLinksByIndexTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((*:)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void CreateNamedFamilyLinksTest() + { + RunTestWithLinks(links => + { + // Prepare query: create (child: father mother) + var query = "(() ((child: father mother)))"; + var options = new Options + { + Query = query, + }; + ProcessQuery(links, options); + + // Assert: links for 'father', 'mother', and 'child' exist and are named + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, fatherId); + Assert.NotEqual(links.Constants.Null, motherId); + Assert.NotEqual(links.Constants.Null, childId); + Assert.Equal("father", links.GetName(fatherId)); + Assert.Equal("mother", links.GetName(motherId)); + Assert.Equal("child", links.GetName(childId)); + + // The child link should have father as source and mother as target + var allLinks = GetAllLinks(links); + var childLink = allLinks.First(l => l.Index == childId); + Assert.Equal(fatherId, childLink.Source); + Assert.Equal(motherId, childLink.Target); + }); + } + + [Fact] + public void CreateTwoNamedLinksTest() + { + RunTestWithLinks(links => + { + Console.WriteLine("[Test] Starting UpdateNamedLinkNameTest"); + + // Create initial link: (child: father mother) + Console.WriteLine("[Test] Step 1: Creating initial link"); + var createOptions = new Options { Query = "(() ((child: father mother)))", Trace = true }; + ProcessQuery(links, createOptions); + Console.WriteLine("[Test] Initial link creation completed"); + + // Verify initial state + Console.WriteLine("[Test] Step 2: Verifying initial state"); + var initialChildId = links.GetByName("child"); + Console.WriteLine($"[Test] Initial child ID: {initialChildId}"); + var initialFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Initial father ID: {initialFatherId}"); + var initialMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Initial mother ID: {initialMotherId}"); + + Assert.NotEqual(links.Constants.Null, initialChildId); + Assert.NotEqual(links.Constants.Null, initialFatherId); + Assert.NotEqual(links.Constants.Null, initialMotherId); + + var initialLinks = GetAllLinks(links); + Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); + var initialChildLink = initialLinks.First(l => l.Index == initialChildId); + Assert.Equal(initialFatherId, initialChildLink.Source); + Assert.Equal(initialMotherId, initialChildLink.Target); + Console.WriteLine("[Test] Initial state verification completed"); + + // Update child link to be named "son" instead + Console.WriteLine("[Test] Step 3: Updating link name"); + // First, let's try to remove the old name + Console.WriteLine("[Test] Removing old name 'child'"); + links.RemoveName(initialChildId); + Console.WriteLine("[Test] Old name removed"); + + // Then create the new link with the new name + Console.WriteLine("[Test] Creating new link with name 'son'"); + var updateOptions = new Options { Query = "(() ((son: father mother)))", Trace = true }; + ProcessQuery(links, updateOptions); + Console.WriteLine("[Test] New link creation completed"); + + // Verify final state + Console.WriteLine("[Test] Step 4: Verifying final state"); + Assert.Equal(links.Constants.Null, links.GetByName("child")); + var finalSonId = links.GetByName("son"); + Console.WriteLine($"[Test] Final son ID: {finalSonId}"); + var finalFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); + var finalMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); + + Assert.NotEqual(links.Constants.Null, finalSonId); + Assert.NotEqual(links.Constants.Null, finalFatherId); + Assert.NotEqual(links.Constants.Null, finalMotherId); + + var finalLinks = GetAllLinks(links); + Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); + var finalSonLink = Assert.Single(finalLinks, l => l.Index == finalSonId); + Assert.Equal(finalFatherId, finalSonLink.Source); + Assert.Equal(finalMotherId, finalSonLink.Target); + Console.WriteLine("[Test] Final state verification completed"); + Console.WriteLine("[Test] UpdateNamedLinkNameTest completed successfully"); + }, enableTracing: true); + } + + [Fact] + public void UpdateNamedLinkNameTest() + { + Console.WriteLine("[Test] ===== Starting UpdateNamedLinkNameTest ====="); + RunTestWithLinks(links => + { + try + { + Console.WriteLine($"[Test] Constants: Null={links.Constants.Null}, Any={links.Constants.Any}, Continue={links.Constants.Continue}"); + // Step 1: Creating initial link + Console.WriteLine("[Test] Step 1: Creating initial link"); + var createQuery = "(() ((child: father mother)))"; + Console.WriteLine($"[Test] Query: {createQuery}"); + + var createOptions = new Options + { + Query = createQuery, + Trace = true + }; + ProcessQuery(links, createOptions); + Console.WriteLine("[Test] Initial link creation completed"); + + // Step 2: Verify initial state + Console.WriteLine("[Test] Step 2: Verifying initial state"); + var childId = links.GetByName("child"); + Console.WriteLine($"[Test] Initial child ID: {childId}"); + var fatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Initial father ID: {fatherId}"); + var motherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Initial mother ID: {motherId}"); + + var initialLinks = links.All().ToList(); + Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); + foreach (var link in initialLinks) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Initial link: Index={link}, Source={source}, Target={target}"); + } + Console.WriteLine("[Test] Initial state verification completed"); + + // Step 3: Update link name + Console.WriteLine("[Test] Step 3: Updating link name from 'child' to 'son'"); + var updateQuery = "(((child: father mother)) ((son: father mother)))"; + Console.WriteLine($"[Test] Query: {updateQuery}"); + + // Log state before update + Console.WriteLine("[Test] Current state before update:"); + Console.WriteLine($"[Test] - child name exists: {links.GetByName("child") != 0}"); + Console.WriteLine($"[Test] - son name exists: {links.GetByName("son") != 0}"); + Console.WriteLine($"[Test] - father name exists: {links.GetByName("father") != 0}"); + Console.WriteLine($"[Test] - mother name exists: {links.GetByName("mother") != 0}"); + + Console.WriteLine("[Test] Starting ProcessQuery for update..."); + Console.WriteLine("[Test] Current links before update:"); + foreach (var link in links.All()) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); + } + + // Add detailed tracing for the update operation + var updateOptions = new Options + { + Query = updateQuery, + Trace = true, + ChangesHandler = (before, after) => + { + Console.WriteLine($"[Test] Update ChangesHandler called:"); + Console.WriteLine($"[Test] - Before state: {before}"); + Console.WriteLine($"[Test] - After state: {after}"); + + // Log name states during change + Console.WriteLine($"[Test] - child name during change: {links.GetByName("child")}"); + Console.WriteLine($"[Test] - son name during change: {links.GetByName("son")}"); + Console.WriteLine($"[Test] - father name during change: {links.GetByName("father")}"); + Console.WriteLine($"[Test] - mother name during change: {links.GetByName("mother")}"); + + // Log all links during change + Console.WriteLine("[Test] - All links during change:"); + foreach (var link in links.All()) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); + } + + // Add detailed tracing for link creation + if (after != null && before == null) + { + var afterLink = new DoubletLink(after); + var source = links.GetSource(after); + var target = links.GetTarget(after); + Console.WriteLine($"[Test] Creating new link: Index={afterLink.Index}, Source={source}, Target={target}"); + Console.WriteLine($"[Test] Checking if link exists: {links.Exists>(afterLink.Index)}"); + Console.WriteLine($"[Test] Checking if source exists: {links.Exists>(source)}"); + Console.WriteLine($"[Test] Checking if target exists: {links.Exists>(target)}"); + + // Log all names before creation + Console.WriteLine("[Test] Names before creation:"); + foreach (var name in new[] { "child", "son", "father", "mother" }) + { + var id = links.GetByName(name); + Console.WriteLine($"[Test] - {name}: {id}"); + } + } + + return links.Constants.Continue; + } + }; + + ProcessQuery(links, updateOptions); + Console.WriteLine("[Test] Update operation completed"); + + // Step 4: Verify final state + Console.WriteLine("[Test] Step 4: Verifying final state"); + var finalChildId = links.GetByName("child"); + Console.WriteLine($"[Test] Final child ID: {finalChildId}"); + var finalSonId = links.GetByName("son"); + Console.WriteLine($"[Test] Final son ID: {finalSonId}"); + var finalFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); + var finalMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); + + var finalLinks = links.All().ToList(); + Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); + foreach (var link in finalLinks) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Final link: Index={link}, Source={source}, Target={target}"); + } + + // Verify the update was successful + Assert.Equal(0, finalChildId); // Old name should be gone + Assert.NotEqual(0, finalSonId); // New name should exist + Assert.Equal(finalFatherId, links.GetSource(finalSonId)); // Source should be father + Assert.Equal(finalMotherId, links.GetTarget(finalSonId)); // Target should be mother + + Console.WriteLine("[Test] ===== UpdateNamedLinkNameTest completed successfully ====="); + } + catch (Exception ex) + { + Console.WriteLine($"[Test] Error in UpdateNamedLinkNameTest: {ex}"); + Console.WriteLine($"[Test] Stack trace: {ex.StackTrace}"); + throw; + } + }, enableTracing: true); + } + + [Fact] + public void DeleteNamedFamilyLinksRemovesNamesTest() + { + RunTestWithLinks(links => + { + // Prepare query: create (child: father mother) + var query = "(() ((child: father mother)))"; + var options = new Options + { + Query = query, + }; + ProcessQuery(links, options); + + // Delete the 'child' link + var childId = links.GetByName("child"); + links.Delete(childId); + + // Assert: 'child' name is removed, 'father' and 'mother' remain + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.NotEqual(links.Constants.Null, links.GetByName("father")); + Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void DeleteNamedLinkTest() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + + ProcessQuery(links, "(((*:)) ())"); + + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.Equal(links.Constants.Null, links.GetByName("father")); + Assert.Equal(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void DeleteByNamesTest() + { + RunTestWithLinks(links => + { + // Create link by name + ProcessQuery(links, "(() ((child: father mother)))"); + + // Delete link by name + ProcessQuery(links, "(((child: father mother)) ())"); + + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.NotEqual(links.Constants.Null, links.GetByName("father")); + Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void NameLookupConsistencyTest() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((x: 1 2)))"); + ProcessQuery(links, "(((x: 1 2)) ((y: 1 2)))"); + ProcessQuery(links, "(((y: 1 2)) ((z: 1 2)))"); + links.Delete(links.GetByName("z")); + Assert.Equal(links.Constants.Null, links.GetByName("x")); + Assert.Equal(links.Constants.Null, links.GetByName("y")); + Assert.Equal(links.Constants.Null, links.GetByName("z")); + }); + } + + [Fact] + public void CreateNamedLinkWithStringId_ShouldCreateSingleLink() + { + RunTestWithLinks(links => + { + var options = new Options { Query = "(() ((link: link link)))", Trace = true }; + ProcessQuery(links, options); + var allLinks = GetAllLinks(links); + // This should only create a single named link with string id 'link' + Assert.Single(allLinks); + var linkId = links.GetByName("link"); + Assert.NotEqual(links.Constants.Null, linkId); + var link = allLinks.First(); + Assert.Equal(linkId, link.Index); + Assert.Equal(linkId, link.Source); + Assert.Equal(linkId, link.Target); + Assert.Equal("link", links.GetName(linkId)); + }, enableTracing: true); + } + + [Fact] + public void CreateLinkWithIntegerId_ShouldCreateSingleLink() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1)))"); + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + var link = allLinks.First(); + Assert.Equal(1u, link.Index); + Assert.Equal(1u, link.Source); + Assert.Equal(1u, link.Target); + }); + } + + [Fact] + public void CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQuery(links, "(() ((2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQuery(links, "(() ((2: 1 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 1, 2); + }); + } + + [Fact] + public void CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((type: type type)))"); + ProcessQuery(links, "(() ((link: link type)))"); + + // Assert + var allLinks = GetAllLinks(links); + // Expect only two links, but extra self-referential named link for 'link' is created indicating a bug. + Assert.Equal(2, allLinks.Count); + var typeId = links.GetByName("type"); + var linkId = links.GetByName("link"); + AssertLinkExists(allLinks, typeId, typeId, typeId); + AssertLinkExists(allLinks, linkId, linkId, typeId); + }); + } + + [Fact] + public void CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((type: type type)))"); + ProcessQuery(links, "(() ((link: type link)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + var typeId = links.GetByName("type"); + var linkId = links.GetByName("link"); + AssertLinkExists(allLinks, typeId, typeId, typeId); + AssertLinkExists(allLinks, linkId, typeId, linkId); + }); + } + + // ============================================ + // Link Deduplication Tests + // ============================================ + + [Fact] + public void DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink() + { + // Issue #65: Test deduplication of (m a) (m a) pattern + // Query: () (((m a) (m a))) + // Expected: m, a (named self-refs), link 3 = (m a), link 4 = (3 3) + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((m a) (m a))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(4, allLinks.Count); + + // Get the named link IDs + var mId = links.GetByName("m"); + var aId = links.GetByName("a"); + + Assert.NotEqual(links.Constants.Null, mId); + Assert.NotEqual(links.Constants.Null, aId); + + // m and a should be self-referencing + AssertLinkExists(allLinks, mId, mId, mId); + AssertLinkExists(allLinks, aId, aId, aId); + + // Find the (m a) link + var maLink = allLinks.FirstOrDefault(l => l.Source == mId && l.Target == aId); + Assert.NotEqual(default, maLink); + + // Find the outer link ((m a) (m a)) which should be (maLink.Index maLink.Index) + var outerLink = allLinks.FirstOrDefault(l => l.Source == maLink.Index && l.Target == maLink.Index); + Assert.NotEqual(default, outerLink); + + // Verify deduplication: the outer link's source and target should be the same + Assert.Equal(outerLink.Source, outerLink.Target); + }); + } + + [Fact] + public void DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink() + { + // Issue #65: Test deduplication with numeric IDs + // Query: () (((1 2) (1 2))) + // When using numeric IDs directly, they are treated as references (not creating self-refs) + // So (1 2) creates link with source=1, target=2 + // The deduplication still works: ((1 2) (1 2)) creates only one (1 2) link + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 2) (1 2))))"); + + // Assert + var allLinks = GetAllLinks(links); + + // Should have 2 links: (1 2) and ((1 2) (1 2)) + Assert.Equal(2, allLinks.Count); + + // Link 1 should be (1 2) - the deduplicated sub-link + AssertLinkExists(allLinks, 1, 1, 2); + + // Link 2 should be (1 1) - referencing the same sub-link twice + AssertLinkExists(allLinks, 2, 1, 1); + }); + } + + [Fact] + public void DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink() + { + // Test with three identical pairs using named links: (((a b) ((a b) (a b)))) + // The (a b) should only be created once + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((a b) ((a b) (a b)))))"); + + // Assert + var allLinks = GetAllLinks(links); + + var aId = links.GetByName("a"); + var bId = links.GetByName("b"); + + // a and b should be self-referencing + AssertLinkExists(allLinks, aId, aId, aId); + AssertLinkExists(allLinks, bId, bId, bId); + + // Find (a b) link - the deduplicated sub-link + var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); + Assert.NotEqual(default, abLink); + + // Find ((a b) (a b)) link - should reference abLink twice + var innerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == abLink.Index); + Assert.NotEqual(default, innerLink); + + // Find outer link ((a b) ((a b) (a b))) + var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == innerLink.Index); + Assert.NotEqual(default, outerLink); + + Assert.Equal(5, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks() + { + // Test that named links are reused across queries + RunTestWithLinks(links => + { + // First query creates (m a) + ProcessQuery(links, "(() ((m a)))"); + + var mId = links.GetByName("m"); + var aId = links.GetByName("a"); + + // Second query should reuse existing m and a links + ProcessQuery(links, "(() (((m a) (m a))))"); + + // Assert + var allLinks = GetAllLinks(links); + + // m and a should still have the same IDs + Assert.Equal(mId, links.GetByName("m")); + Assert.Equal(aId, links.GetByName("a")); + + // Should have 4 links total: m, a, (m a), ((m a) (m a)) + Assert.Equal(4, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks() + { + // Test that different pairs are NOT deduplicated + // Query: () (((a b) (b a))) - using named links + // (a b) and (b a) are different and should both be created + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((a b) (b a))))"); + + // Assert + var allLinks = GetAllLinks(links); + + var aId = links.GetByName("a"); + var bId = links.GetByName("b"); + + // a and b should be self-referencing + AssertLinkExists(allLinks, aId, aId, aId); + AssertLinkExists(allLinks, bId, bId, bId); + + // Find (a b) link + var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); + Assert.NotEqual(default, abLink); + + // Find (b a) link + var baLink = allLinks.FirstOrDefault(l => l.Source == bId && l.Target == aId); + Assert.NotEqual(default, baLink); + + // Find outer link ((a b) (b a)) - should have different source and target + var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == baLink.Index); + Assert.NotEqual(default, outerLink); + Assert.NotEqual(outerLink.Source, outerLink.Target); + + Assert.Equal(5, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels() + { + // Test deeply nested deduplication using named links + // Query: () ((((x y) (x y)) ((x y) (x y)))) + // (x y) is duplicated at multiple levels + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((((x y) (x y)) ((x y) (x y)))))"); + + // Assert + var allLinks = GetAllLinks(links); + + var xId = links.GetByName("x"); + var yId = links.GetByName("y"); + + // x and y should be self-referencing + AssertLinkExists(allLinks, xId, xId, xId); + AssertLinkExists(allLinks, yId, yId, yId); + + // Find (x y) - the base link + var xyLink = allLinks.FirstOrDefault(l => l.Source == xId && l.Target == yId); + Assert.NotEqual(default, xyLink); + + // Find ((x y) (x y)) - references (x y) twice (deduplicated) + var level1Link = allLinks.FirstOrDefault(l => l.Source == xyLink.Index && l.Target == xyLink.Index); + Assert.NotEqual(default, level1Link); + + // Find (((x y) (x y)) ((x y) (x y))) - references level1Link twice (deduplicated) + var level2Link = allLinks.FirstOrDefault(l => l.Source == level1Link.Index && l.Target == level1Link.Index); + Assert.NotEqual(default, level2Link); + + // Total: x, y, (x y), ((x y) (x y)), (((x y) (x y)) ((x y) (x y))) + Assert.Equal(5, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds() + { + // Issue #65: Verify that named links maintain consistent IDs across operations + RunTestWithLinks(links => + { + // First create named links + ProcessQuery(links, "(() ((p: p p)))"); + ProcessQuery(links, "(() ((a: a a)))"); + + var pId = links.GetByName("p"); + var aId = links.GetByName("a"); + + // Now create ((p a) (p a)) - should reuse existing p and a + ProcessQuery(links, "(() (((p a) (p a))))"); + + // Assert + var allLinks = GetAllLinks(links); + + // p and a should still have the same IDs + Assert.Equal(pId, links.GetByName("p")); + Assert.Equal(aId, links.GetByName("a")); + + // Verify the structure + AssertLinkExists(allLinks, pId, pId, pId); + AssertLinkExists(allLinks, aId, aId, aId); + + // Find (p a) link + var paLink = allLinks.FirstOrDefault(l => l.Source == pId && l.Target == aId); + Assert.NotEqual(default, paLink); + + // Find ((p a) (p a)) link - should reference paLink twice + var outerLink = allLinks.FirstOrDefault(l => l.Source == paLink.Index && l.Target == paLink.Index); + Assert.NotEqual(default, outerLink); + }); + } + + [Fact] + public void StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((father: father father)))"); + ProcessQuery(links, "(() ((mother: mother mother)))"); + ProcessQuery(links, "(() ((child: father mother)))"); + + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + + ProcessQuery(links, "((($id: father mother)) (($id: mother father)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, fatherId, fatherId, fatherId); + AssertLinkExists(allLinks, motherId, motherId, motherId); + AssertLinkExists(allLinks, childId, motherId, fatherId); + }); + } + + [Fact] + public void Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1) (18: 1 21) (19: 1 20) (20: 20 20) (21: 21 21)))"); + + ProcessQuery(links, "((($i: 1 21)) (($i: $s $t) ($i 20)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(6, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 18, 1, 21); + AssertLinkExists(allLinks, 19, 1, 20); + AssertLinkExists(allLinks, 20, 20, 20); + AssertLinkExists(allLinks, 21, 21, 21); + + var outgoingLink = Assert.Single(allLinks, link => link.Source == 18 && link.Target == 20); + Assert.NotEqual(links.Constants.Null, outgoingLink.Index); + Assert.NotEqual(links.Constants.Any, outgoingLink.Index); + Assert.DoesNotContain(allLinks, link => link.Index == links.Constants.Any || link.Source == links.Constants.Any || link.Target == links.Constants.Any); + }); + } + + [Fact] + public void Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((21: 21 21)))"); + + ProcessQuery(links, "(((21: 21 21)) ((21: $s $t)))"); + + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 21, 21, 21); + Assert.DoesNotContain(allLinks, link => link.Source == links.Constants.Any || link.Target == links.Constants.Any); + }); + } + + [Fact] + public void EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException() + { + RunTestWithLinks(links => + { + var exception = Assert.Throws(() => LinksExtensions.EnsureCreated(links, links.Constants.Any)); + + Assert.Contains("unsupported link address", exception.Message); + }); + } + + // Helper methods + + /// + /// Wall-clock budget for a single test body. Every test here is expected to finish in + /// milliseconds, so this is a deadlock guard, not a performance assertion: the previous value of + /// one second was tight enough that a loaded CI runner failed tests that were perfectly correct. + /// Override with the LINK_CLI_TEST_TIMEOUT_SECONDS environment variable. + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs index 66bb41f..961a4c5 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs @@ -6,10 +6,9 @@ using DoubletLink = Platform.Data.Doublets.Link; using static Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; - namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class AdvancedMixedQueryProcessor + public partial class AdvancedMixedQueryProcessor { [Fact] public void CreateSingleLinkTest() @@ -700,1148 +699,5 @@ public void DeleteSingleLinkTest_Source2Target2() Assert.Empty(allLinks); }); } - - [Fact] - public void DeleteMultipleLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((1 2) (2 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void DeleteLinksByAnyTargetTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((1 *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void DeleteLinksByAnySourceTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (1 2)))"); - - // Act - ProcessQuery(links, "(((* 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } - - [Fact] - public void DeleteAllLinksBySourceAndTargetTest1() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((* *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void NestedDeleteAllLinksBySourceAndTargetTest1() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "((((* *) (* *))) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void DeleteAllLinksBySourceAndTargetTest2() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 1)))"); - - // Act - ProcessQuery(links, "(((* *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void DeleteAllLinksByIndexTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((*:)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void CreateNamedFamilyLinksTest() - { - RunTestWithLinks(links => - { - // Prepare query: create (child: father mother) - var query = "(() ((child: father mother)))"; - var options = new Options - { - Query = query, - }; - ProcessQuery(links, options); - - // Assert: links for 'father', 'mother', and 'child' exist and are named - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - Assert.NotEqual(links.Constants.Null, fatherId); - Assert.NotEqual(links.Constants.Null, motherId); - Assert.NotEqual(links.Constants.Null, childId); - Assert.Equal("father", links.GetName(fatherId)); - Assert.Equal("mother", links.GetName(motherId)); - Assert.Equal("child", links.GetName(childId)); - - // The child link should have father as source and mother as target - var allLinks = GetAllLinks(links); - var childLink = allLinks.First(l => l.Index == childId); - Assert.Equal(fatherId, childLink.Source); - Assert.Equal(motherId, childLink.Target); - }); - } - - [Fact] - public void CreateTwoNamedLinksTest() - { - RunTestWithLinks(links => - { - Console.WriteLine("[Test] Starting UpdateNamedLinkNameTest"); - - // Create initial link: (child: father mother) - Console.WriteLine("[Test] Step 1: Creating initial link"); - var createOptions = new Options { Query = "(() ((child: father mother)))", Trace = true }; - ProcessQuery(links, createOptions); - Console.WriteLine("[Test] Initial link creation completed"); - - // Verify initial state - Console.WriteLine("[Test] Step 2: Verifying initial state"); - var initialChildId = links.GetByName("child"); - Console.WriteLine($"[Test] Initial child ID: {initialChildId}"); - var initialFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Initial father ID: {initialFatherId}"); - var initialMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Initial mother ID: {initialMotherId}"); - - Assert.NotEqual(links.Constants.Null, initialChildId); - Assert.NotEqual(links.Constants.Null, initialFatherId); - Assert.NotEqual(links.Constants.Null, initialMotherId); - - var initialLinks = GetAllLinks(links); - Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); - var initialChildLink = initialLinks.First(l => l.Index == initialChildId); - Assert.Equal(initialFatherId, initialChildLink.Source); - Assert.Equal(initialMotherId, initialChildLink.Target); - Console.WriteLine("[Test] Initial state verification completed"); - - // Update child link to be named "son" instead - Console.WriteLine("[Test] Step 3: Updating link name"); - // First, let's try to remove the old name - Console.WriteLine("[Test] Removing old name 'child'"); - links.RemoveName(initialChildId); - Console.WriteLine("[Test] Old name removed"); - - // Then create the new link with the new name - Console.WriteLine("[Test] Creating new link with name 'son'"); - var updateOptions = new Options { Query = "(() ((son: father mother)))", Trace = true }; - ProcessQuery(links, updateOptions); - Console.WriteLine("[Test] New link creation completed"); - - // Verify final state - Console.WriteLine("[Test] Step 4: Verifying final state"); - Assert.Equal(links.Constants.Null, links.GetByName("child")); - var finalSonId = links.GetByName("son"); - Console.WriteLine($"[Test] Final son ID: {finalSonId}"); - var finalFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); - var finalMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); - - Assert.NotEqual(links.Constants.Null, finalSonId); - Assert.NotEqual(links.Constants.Null, finalFatherId); - Assert.NotEqual(links.Constants.Null, finalMotherId); - - var finalLinks = GetAllLinks(links); - Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); - var finalSonLink = Assert.Single(finalLinks, l => l.Index == finalSonId); - Assert.Equal(finalFatherId, finalSonLink.Source); - Assert.Equal(finalMotherId, finalSonLink.Target); - Console.WriteLine("[Test] Final state verification completed"); - Console.WriteLine("[Test] UpdateNamedLinkNameTest completed successfully"); - }, enableTracing: true); - } - - [Fact] - public void UpdateNamedLinkNameTest() - { - Console.WriteLine("[Test] ===== Starting UpdateNamedLinkNameTest ====="); - RunTestWithLinks(links => - { - try - { - Console.WriteLine($"[Test] Constants: Null={links.Constants.Null}, Any={links.Constants.Any}, Continue={links.Constants.Continue}"); - // Step 1: Creating initial link - Console.WriteLine("[Test] Step 1: Creating initial link"); - var createQuery = "(() ((child: father mother)))"; - Console.WriteLine($"[Test] Query: {createQuery}"); - - var createOptions = new Options - { - Query = createQuery, - Trace = true - }; - ProcessQuery(links, createOptions); - Console.WriteLine("[Test] Initial link creation completed"); - - // Step 2: Verify initial state - Console.WriteLine("[Test] Step 2: Verifying initial state"); - var childId = links.GetByName("child"); - Console.WriteLine($"[Test] Initial child ID: {childId}"); - var fatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Initial father ID: {fatherId}"); - var motherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Initial mother ID: {motherId}"); - - var initialLinks = links.All().ToList(); - Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); - foreach (var link in initialLinks) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Initial link: Index={link}, Source={source}, Target={target}"); - } - Console.WriteLine("[Test] Initial state verification completed"); - - // Step 3: Update link name - Console.WriteLine("[Test] Step 3: Updating link name from 'child' to 'son'"); - var updateQuery = "(((child: father mother)) ((son: father mother)))"; - Console.WriteLine($"[Test] Query: {updateQuery}"); - - // Log state before update - Console.WriteLine("[Test] Current state before update:"); - Console.WriteLine($"[Test] - child name exists: {links.GetByName("child") != 0}"); - Console.WriteLine($"[Test] - son name exists: {links.GetByName("son") != 0}"); - Console.WriteLine($"[Test] - father name exists: {links.GetByName("father") != 0}"); - Console.WriteLine($"[Test] - mother name exists: {links.GetByName("mother") != 0}"); - - Console.WriteLine("[Test] Starting ProcessQuery for update..."); - Console.WriteLine("[Test] Current links before update:"); - foreach (var link in links.All()) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); - } - - // Add detailed tracing for the update operation - var updateOptions = new Options - { - Query = updateQuery, - Trace = true, - ChangesHandler = (before, after) => - { - Console.WriteLine($"[Test] Update ChangesHandler called:"); - Console.WriteLine($"[Test] - Before state: {before}"); - Console.WriteLine($"[Test] - After state: {after}"); - - // Log name states during change - Console.WriteLine($"[Test] - child name during change: {links.GetByName("child")}"); - Console.WriteLine($"[Test] - son name during change: {links.GetByName("son")}"); - Console.WriteLine($"[Test] - father name during change: {links.GetByName("father")}"); - Console.WriteLine($"[Test] - mother name during change: {links.GetByName("mother")}"); - - // Log all links during change - Console.WriteLine("[Test] - All links during change:"); - foreach (var link in links.All()) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); - } - - // Add detailed tracing for link creation - if (after != null && before == null) - { - var afterLink = new DoubletLink(after); - var source = links.GetSource(after); - var target = links.GetTarget(after); - Console.WriteLine($"[Test] Creating new link: Index={afterLink.Index}, Source={source}, Target={target}"); - Console.WriteLine($"[Test] Checking if link exists: {links.Exists>(afterLink.Index)}"); - Console.WriteLine($"[Test] Checking if source exists: {links.Exists>(source)}"); - Console.WriteLine($"[Test] Checking if target exists: {links.Exists>(target)}"); - - // Log all names before creation - Console.WriteLine("[Test] Names before creation:"); - foreach (var name in new[] { "child", "son", "father", "mother" }) - { - var id = links.GetByName(name); - Console.WriteLine($"[Test] - {name}: {id}"); - } - } - - return links.Constants.Continue; - } - }; - - ProcessQuery(links, updateOptions); - Console.WriteLine("[Test] Update operation completed"); - - // Step 4: Verify final state - Console.WriteLine("[Test] Step 4: Verifying final state"); - var finalChildId = links.GetByName("child"); - Console.WriteLine($"[Test] Final child ID: {finalChildId}"); - var finalSonId = links.GetByName("son"); - Console.WriteLine($"[Test] Final son ID: {finalSonId}"); - var finalFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); - var finalMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); - - var finalLinks = links.All().ToList(); - Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); - foreach (var link in finalLinks) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Final link: Index={link}, Source={source}, Target={target}"); - } - - // Verify the update was successful - Assert.Equal(0, finalChildId); // Old name should be gone - Assert.NotEqual(0, finalSonId); // New name should exist - Assert.Equal(finalFatherId, links.GetSource(finalSonId)); // Source should be father - Assert.Equal(finalMotherId, links.GetTarget(finalSonId)); // Target should be mother - - Console.WriteLine("[Test] ===== UpdateNamedLinkNameTest completed successfully ====="); - } - catch (Exception ex) - { - Console.WriteLine($"[Test] Error in UpdateNamedLinkNameTest: {ex}"); - Console.WriteLine($"[Test] Stack trace: {ex.StackTrace}"); - throw; - } - }, enableTracing: true); - } - - [Fact] - public void DeleteNamedFamilyLinksRemovesNamesTest() - { - RunTestWithLinks(links => - { - // Prepare query: create (child: father mother) - var query = "(() ((child: father mother)))"; - var options = new Options - { - Query = query, - }; - ProcessQuery(links, options); - - // Delete the 'child' link - var childId = links.GetByName("child"); - links.Delete(childId); - - // Assert: 'child' name is removed, 'father' and 'mother' remain - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.NotEqual(links.Constants.Null, links.GetByName("father")); - Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); - }); - } - - [Fact] - public void DeleteNamedLinkTest() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); - - ProcessQuery(links, "(((*:)) ())"); - - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.Equal(links.Constants.Null, links.GetByName("father")); - Assert.Equal(links.Constants.Null, links.GetByName("mother")); - }); - } - - [Fact] - public void DeleteByNamesTest() - { - RunTestWithLinks(links => - { - // Create link by name - ProcessQuery(links, "(() ((child: father mother)))"); - - // Delete link by name - ProcessQuery(links, "(((child: father mother)) ())"); - - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.NotEqual(links.Constants.Null, links.GetByName("father")); - Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); - }); - } - - [Fact] - public void NameLookupConsistencyTest() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((x: 1 2)))"); - ProcessQuery(links, "(((x: 1 2)) ((y: 1 2)))"); - ProcessQuery(links, "(((y: 1 2)) ((z: 1 2)))"); - links.Delete(links.GetByName("z")); - Assert.Equal(links.Constants.Null, links.GetByName("x")); - Assert.Equal(links.Constants.Null, links.GetByName("y")); - Assert.Equal(links.Constants.Null, links.GetByName("z")); - }); - } - - [Fact] - public void CreateNamedLinkWithStringId_ShouldCreateSingleLink() - { - RunTestWithLinks(links => - { - var options = new Options { Query = "(() ((link: link link)))", Trace = true }; - ProcessQuery(links, options); - var allLinks = GetAllLinks(links); - // This should only create a single named link with string id 'link' - Assert.Single(allLinks); - var linkId = links.GetByName("link"); - Assert.NotEqual(links.Constants.Null, linkId); - var link = allLinks.First(); - Assert.Equal(linkId, link.Index); - Assert.Equal(linkId, link.Source); - Assert.Equal(linkId, link.Target); - Assert.Equal("link", links.GetName(linkId)); - }, enableTracing: true); - } - - [Fact] - public void CreateLinkWithIntegerId_ShouldCreateSingleLink() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1)))"); - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - var link = allLinks.First(); - Assert.Equal(1u, link.Index); - Assert.Equal(1u, link.Source); - Assert.Equal(1u, link.Target); - }); - } - - [Fact] - public void CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - ProcessQuery(links, "(() ((2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } - - [Fact] - public void CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - ProcessQuery(links, "(() ((2: 1 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 1, 2); - }); - } - - [Fact] - public void CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((type: type type)))"); - ProcessQuery(links, "(() ((link: link type)))"); - - // Assert - var allLinks = GetAllLinks(links); - // Expect only two links, but extra self-referential named link for 'link' is created indicating a bug. - Assert.Equal(2, allLinks.Count); - var typeId = links.GetByName("type"); - var linkId = links.GetByName("link"); - AssertLinkExists(allLinks, typeId, typeId, typeId); - AssertLinkExists(allLinks, linkId, linkId, typeId); - }); - } - - [Fact] - public void CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((type: type type)))"); - ProcessQuery(links, "(() ((link: type link)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - var typeId = links.GetByName("type"); - var linkId = links.GetByName("link"); - AssertLinkExists(allLinks, typeId, typeId, typeId); - AssertLinkExists(allLinks, linkId, typeId, linkId); - }); - } - - // ============================================ - // Link Deduplication Tests - // ============================================ - - [Fact] - public void DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink() - { - // Issue #65: Test deduplication of (m a) (m a) pattern - // Query: () (((m a) (m a))) - // Expected: m, a (named self-refs), link 3 = (m a), link 4 = (3 3) - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((m a) (m a))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(4, allLinks.Count); - - // Get the named link IDs - var mId = links.GetByName("m"); - var aId = links.GetByName("a"); - - Assert.NotEqual(links.Constants.Null, mId); - Assert.NotEqual(links.Constants.Null, aId); - - // m and a should be self-referencing - AssertLinkExists(allLinks, mId, mId, mId); - AssertLinkExists(allLinks, aId, aId, aId); - - // Find the (m a) link - var maLink = allLinks.FirstOrDefault(l => l.Source == mId && l.Target == aId); - Assert.NotEqual(default, maLink); - - // Find the outer link ((m a) (m a)) which should be (maLink.Index maLink.Index) - var outerLink = allLinks.FirstOrDefault(l => l.Source == maLink.Index && l.Target == maLink.Index); - Assert.NotEqual(default, outerLink); - - // Verify deduplication: the outer link's source and target should be the same - Assert.Equal(outerLink.Source, outerLink.Target); - }); - } - - [Fact] - public void DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink() - { - // Issue #65: Test deduplication with numeric IDs - // Query: () (((1 2) (1 2))) - // When using numeric IDs directly, they are treated as references (not creating self-refs) - // So (1 2) creates link with source=1, target=2 - // The deduplication still works: ((1 2) (1 2)) creates only one (1 2) link - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 2) (1 2))))"); - - // Assert - var allLinks = GetAllLinks(links); - - // Should have 2 links: (1 2) and ((1 2) (1 2)) - Assert.Equal(2, allLinks.Count); - - // Link 1 should be (1 2) - the deduplicated sub-link - AssertLinkExists(allLinks, 1, 1, 2); - - // Link 2 should be (1 1) - referencing the same sub-link twice - AssertLinkExists(allLinks, 2, 1, 1); - }); - } - - [Fact] - public void DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink() - { - // Test with three identical pairs using named links: (((a b) ((a b) (a b)))) - // The (a b) should only be created once - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((a b) ((a b) (a b)))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var aId = links.GetByName("a"); - var bId = links.GetByName("b"); - - // a and b should be self-referencing - AssertLinkExists(allLinks, aId, aId, aId); - AssertLinkExists(allLinks, bId, bId, bId); - - // Find (a b) link - the deduplicated sub-link - var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); - Assert.NotEqual(default, abLink); - - // Find ((a b) (a b)) link - should reference abLink twice - var innerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == abLink.Index); - Assert.NotEqual(default, innerLink); - - // Find outer link ((a b) ((a b) (a b))) - var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == innerLink.Index); - Assert.NotEqual(default, outerLink); - - Assert.Equal(5, allLinks.Count); - }); - } - - [Fact] - public void DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks() - { - // Test that named links are reused across queries - RunTestWithLinks(links => - { - // First query creates (m a) - ProcessQuery(links, "(() ((m a)))"); - - var mId = links.GetByName("m"); - var aId = links.GetByName("a"); - - // Second query should reuse existing m and a links - ProcessQuery(links, "(() (((m a) (m a))))"); - - // Assert - var allLinks = GetAllLinks(links); - - // m and a should still have the same IDs - Assert.Equal(mId, links.GetByName("m")); - Assert.Equal(aId, links.GetByName("a")); - - // Should have 4 links total: m, a, (m a), ((m a) (m a)) - Assert.Equal(4, allLinks.Count); - }); - } - - [Fact] - public void DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks() - { - // Test that different pairs are NOT deduplicated - // Query: () (((a b) (b a))) - using named links - // (a b) and (b a) are different and should both be created - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((a b) (b a))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var aId = links.GetByName("a"); - var bId = links.GetByName("b"); - - // a and b should be self-referencing - AssertLinkExists(allLinks, aId, aId, aId); - AssertLinkExists(allLinks, bId, bId, bId); - - // Find (a b) link - var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); - Assert.NotEqual(default, abLink); - - // Find (b a) link - var baLink = allLinks.FirstOrDefault(l => l.Source == bId && l.Target == aId); - Assert.NotEqual(default, baLink); - - // Find outer link ((a b) (b a)) - should have different source and target - var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == baLink.Index); - Assert.NotEqual(default, outerLink); - Assert.NotEqual(outerLink.Source, outerLink.Target); - - Assert.Equal(5, allLinks.Count); - }); - } - - [Fact] - public void DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels() - { - // Test deeply nested deduplication using named links - // Query: () ((((x y) (x y)) ((x y) (x y)))) - // (x y) is duplicated at multiple levels - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((((x y) (x y)) ((x y) (x y)))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var xId = links.GetByName("x"); - var yId = links.GetByName("y"); - - // x and y should be self-referencing - AssertLinkExists(allLinks, xId, xId, xId); - AssertLinkExists(allLinks, yId, yId, yId); - - // Find (x y) - the base link - var xyLink = allLinks.FirstOrDefault(l => l.Source == xId && l.Target == yId); - Assert.NotEqual(default, xyLink); - - // Find ((x y) (x y)) - references (x y) twice (deduplicated) - var level1Link = allLinks.FirstOrDefault(l => l.Source == xyLink.Index && l.Target == xyLink.Index); - Assert.NotEqual(default, level1Link); - - // Find (((x y) (x y)) ((x y) (x y))) - references level1Link twice (deduplicated) - var level2Link = allLinks.FirstOrDefault(l => l.Source == level1Link.Index && l.Target == level1Link.Index); - Assert.NotEqual(default, level2Link); - - // Total: x, y, (x y), ((x y) (x y)), (((x y) (x y)) ((x y) (x y))) - Assert.Equal(5, allLinks.Count); - }); - } - - [Fact] - public void DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds() - { - // Issue #65: Verify that named links maintain consistent IDs across operations - RunTestWithLinks(links => - { - // First create named links - ProcessQuery(links, "(() ((p: p p)))"); - ProcessQuery(links, "(() ((a: a a)))"); - - var pId = links.GetByName("p"); - var aId = links.GetByName("a"); - - // Now create ((p a) (p a)) - should reuse existing p and a - ProcessQuery(links, "(() (((p a) (p a))))"); - - // Assert - var allLinks = GetAllLinks(links); - - // p and a should still have the same IDs - Assert.Equal(pId, links.GetByName("p")); - Assert.Equal(aId, links.GetByName("a")); - - // Verify the structure - AssertLinkExists(allLinks, pId, pId, pId); - AssertLinkExists(allLinks, aId, aId, aId); - - // Find (p a) link - var paLink = allLinks.FirstOrDefault(l => l.Source == pId && l.Target == aId); - Assert.NotEqual(default, paLink); - - // Find ((p a) (p a)) link - should reference paLink twice - var outerLink = allLinks.FirstOrDefault(l => l.Source == paLink.Index && l.Target == paLink.Index); - Assert.NotEqual(default, outerLink); - }); - } - - [Fact] - public void StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((father: father father)))"); - ProcessQuery(links, "(() ((mother: mother mother)))"); - ProcessQuery(links, "(() ((child: father mother)))"); - - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - - ProcessQuery(links, "((($id: father mother)) (($id: mother father)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, fatherId, fatherId, fatherId); - AssertLinkExists(allLinks, motherId, motherId, motherId); - AssertLinkExists(allLinks, childId, motherId, fatherId); - }); - } - - [Fact] - public void Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1) (18: 1 21) (19: 1 20) (20: 20 20) (21: 21 21)))"); - - ProcessQuery(links, "((($i: 1 21)) (($i: $s $t) ($i 20)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(6, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 18, 1, 21); - AssertLinkExists(allLinks, 19, 1, 20); - AssertLinkExists(allLinks, 20, 20, 20); - AssertLinkExists(allLinks, 21, 21, 21); - - var outgoingLink = Assert.Single(allLinks, link => link.Source == 18 && link.Target == 20); - Assert.NotEqual(links.Constants.Null, outgoingLink.Index); - Assert.NotEqual(links.Constants.Any, outgoingLink.Index); - Assert.DoesNotContain(allLinks, link => link.Index == links.Constants.Any || link.Source == links.Constants.Any || link.Target == links.Constants.Any); - }); - } - - [Fact] - public void Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((21: 21 21)))"); - - ProcessQuery(links, "(((21: 21 21)) ((21: $s $t)))"); - - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 21, 21, 21); - Assert.DoesNotContain(allLinks, link => link.Source == links.Constants.Any || link.Target == links.Constants.Any); - }); - } - - [Fact] - public void EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException() - { - RunTestWithLinks(links => - { - var exception = Assert.Throws(() => LinksExtensions.EnsureCreated(links, links.Constants.Any)); - - Assert.Contains("unsupported link address", exception.Message); - }); - } - - // Helper methods - - /// - /// Wall-clock budget for a single test body. Every test here is expected to finish in - /// milliseconds, so this is a deadlock guard, not a performance assertion: the previous value of - /// one second was tight enough that a loaded CI runner failed tests that were perfectly correct. - /// Override with the LINK_CLI_TEST_TIMEOUT_SECONDS environment variable. - /// - private static TimeSpan TestTimeout - { - get - { - const int defaultTimeoutSeconds = 60; - var configured = Environment.GetEnvironmentVariable("LINK_CLI_TEST_TIMEOUT_SECONDS"); - if (!string.IsNullOrWhiteSpace(configured) - && int.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) - && seconds > 0) - { - return TimeSpan.FromSeconds(seconds); - } - return TimeSpan.FromSeconds(defaultTimeoutSeconds); - } - } - - private static void RunTestWithLinks(Action> testAction, bool enableTracing = false) - { - string tempDbFile = Path.GetTempFileName(); - var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); - try - { - // Disposed at the end of the try block, before the finally deletes the backing files. The - // decorator owns memory-mapped handles for both databases; POSIX tolerates unlinking a file - // that is still mapped, Windows fails the delete with IOException. - using var decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); - - var timeout = TestTimeout; - using var cts = new CancellationTokenSource(timeout); - var task = Task.Run(() => - { - testAction(decoratedLinks); - }, cts.Token); - - try - { - task.Wait(cts.Token); - } - catch (OperationCanceledException) - { - Console.WriteLine($"[Test] Test was cancelled after {timeout.TotalSeconds} seconds timeout"); - throw new TimeoutException($"Test exceeded {timeout.TotalSeconds} seconds timeout"); - } - } - finally - { - if (File.Exists(namesDbFile)) - { - File.Delete(namesDbFile); - } - if (File.Exists(tempDbFile)) - { - File.Delete(tempDbFile); - } - } - } - - private static List GetAllLinks(NamedTypesDecorator links) - { - var any = links.Constants.Any; - var query = new DoubletLink(index: any, source: any, target: any); - var allLinks = links.All(query).Select(doublet => new DoubletLink(doublet)).ToList(); - Console.WriteLine($"[Test] All links: {string.Join(" ", allLinks)}"); - return allLinks; - } - - private static void ProcessQuery(NamedTypesDecorator links, string query) - { - ProcessQuery(links, new Options { Query = query }); - } - - private static void ProcessQuery(NamedTypesDecorator links, Options options) - { - options.AutoCreateMissingReferences = true; - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); - } - - private static void ProcessQueryStrict(NamedTypesDecorator links, string query) - { - ProcessQueryStrict(links, new Options { Query = query }); - } - - private static void ProcessQueryStrict(NamedTypesDecorator links, Options options) - { - options.AutoCreateMissingReferences = false; - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); - } - - private static void AssertLinkExists(List allLinks, uint index, uint source, uint target) - { - var link = new DoubletLink(index, source, target); - Assert.True(allLinks.Contains(link), $"Link {link} not found in the list of all links ({string.Join(" ", allLinks)})"); - } - - private static void AssertChangeExists(List<(DoubletLink, DoubletLink)> changes, DoubletLink linkBefore, DoubletLink linkAfter) - { - Assert.Contains(changes, change => change.Item1 == linkBefore && change.Item2 == linkAfter); - } - - // New tests for link reference validation - - [Fact] - public void CreateLinkWithNonExistentReference_ShouldThrowException() - { - RunTestWithLinks(links => - { - // Act & Assert - should throw exception for referencing non-existent link 10 - var exception = Assert.Throws(() => - { - ProcessQueryStrict(links, "(() ((1: 10 20)))"); - }); - - Assert.Contains("Invalid reference to non-existent link '10'", exception.Message); - Assert.Contains("--auto-create-missing-references", exception.Message); - }); - } - - [Fact] - public void CreateLinkWithValidSelfReference_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because link 1 references itself - ProcessQueryStrict(links, "(() ((1: 1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } - - [Fact] - public void CreateMultipleLinksWithCrossReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because both links are created in the same operation - ProcessQueryStrict(links, "(() ((1: 1 2) (2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } - - [Fact] - public void CreateLinkReferencingExistingLink_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Arrange - create first link - ProcessQueryStrict(links, "(() ((1: 1 1)))"); - - // Act - should succeed because link 1 exists - ProcessQueryStrict(links, "(() ((2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } - - [Fact] - public void UpdateWithNonExistentReference_ShouldThrowException() - { - RunTestWithLinks(links => - { - // Arrange - create initial link - ProcessQueryStrict(links, "(() ((1: 1 1)))"); - - // Act & Assert - should throw exception for referencing non-existent link 99 - var exception = Assert.Throws(() => - { - ProcessQueryStrict(links, "(((1: 1 1)) ((1: 1 99)))"); - }); - - Assert.Contains("Invalid reference to non-existent link '99'", exception.Message); - }); - } - - [Fact] - public void CreateNamedLinkWithMissingNamedReferences_ShouldThrowException() - { - RunTestWithLinks(links => - { - var exception = Assert.Throws(() => - { - ProcessQueryStrict(links, "(() ((child: father mother)))"); - }); - - Assert.Contains("Invalid reference to non-existent link 'father'", exception.Message); - Assert.Contains("--auto-create-missing-references", exception.Message); - }); - } - - [Fact] - public void CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((20: 10 20)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 10, 10, 10); - AssertLinkExists(allLinks, 20, 10, 20); - }); - } - - [Fact] - public void CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); - - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, fatherId, fatherId, fatherId); - AssertLinkExists(allLinks, motherId, motherId, motherId); - AssertLinkExists(allLinks, childId, fatherId, motherId); - }); - } - - [Fact] - public void CreateLinkWithVariableReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because variables are not validated - ProcessQueryStrict(links, "(() (($link: $source $target)))"); - - // Assert - one link should be created with variables resolved - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - }); - } - - [Fact] - public void CreateLinkWithWildcardReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because wildcards are not validated - ProcessQueryStrict(links, "(() ((1: * *)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - }); - } } } From 8127db6c3352c553732fe7f96a49246cdacbeb88 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:38:53 +0000 Subject: [PATCH 08/21] ci(issue-96): harden the Rust workflow and serialize GitHub Pages deploys Applies the same hardening as the C# workflow to rust.yml: per-job timeout-minutes, workflow-level 'permissions: contents: read', a non-cancellable writer concurrency group for the publishing jobs, cancel-in-progress disabled on main, and '!cancelled()' in place of the redundant 'always() && !cancelled()'. docs.yml and wasm.yml both deploy to GitHub Pages, which accepts only one deployment per repository at a time, so a push touching both would make the second deploy fail. They now share a non-cancellable writer group. --- .github/workflows/docs.yml | 7 +++++++ .github/workflows/rust.yml | 36 ++++++++++++++++++++++++++++++------ .github/workflows/wasm.yml | 7 +++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index de17de0..de9d0e5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -142,6 +142,13 @@ jobs: deploy: name: Deploy to GitHub Pages + # GitHub Pages allows a single deployment at a time for the whole + # repository, and both docs.yml and wasm.yml deploy to it. A shared, + # non-cancellable writer group serialises them instead of letting the + # second one fail with "another deployment is in progress" (issue #96). + concurrency: + group: github-pages-${{ github.repository }} + cancel-in-progress: false if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' needs: build runs-on: ubuntu-latest diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 26d24bf..ee9c03f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -25,9 +25,16 @@ on: required: false type: string +# Reader runs (PR validation) are cancelled when superseded; runs on main are +# writers - they publish to crates.io and push version commits, so cancelling +# one mid-flight would leave a half-published release behind (issue #96). concurrency: group: rust-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# Least privilege by default; the publishing jobs opt into more below. +permissions: + contents: read env: CARGO_TERM_COLOR: always @@ -40,6 +47,7 @@ jobs: detect-changes: name: Detect Changes runs-on: ubuntu-latest + timeout-minutes: 15 if: github.event_name != 'workflow_dispatch' outputs: rs-changed: ${{ steps.changes.outputs.rs-changed }} @@ -72,6 +80,7 @@ jobs: changelog: name: Changelog Fragment Check runs-on: ubuntu-latest + timeout-minutes: 15 needs: [detect-changes] if: github.event_name == 'pull_request' && needs.detect-changes.outputs.rust-code-changed == 'true' steps: @@ -95,9 +104,10 @@ jobs: lint: name: Lint and Format Check runs-on: ubuntu-latest + timeout-minutes: 30 needs: [detect-changes] if: | - always() && !cancelled() && ( + !cancelled() && ( github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rs-changed == 'true' || @@ -141,8 +151,9 @@ jobs: test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 45 needs: [detect-changes, changelog] - if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') + if: ${{ !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -174,8 +185,9 @@ jobs: build: name: Build Package runs-on: ubuntu-latest + timeout-minutes: 30 needs: [lint, test] - if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} steps: - uses: actions/checkout@v6 @@ -203,8 +215,14 @@ jobs: auto-release: name: Auto Release needs: [lint, test, build] - if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 60 + # Writer group: never cancel a release that may already have published a + # crate or pushed a version commit. + concurrency: + group: rust-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write steps: @@ -296,8 +314,14 @@ jobs: manual-release: name: Manual Release needs: [lint, test, build] - if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 60 + # Writer group: never cancel a release that may already have published a + # crate or pushed a version commit. + concurrency: + group: rust-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write steps: diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 663685b..f318d55 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -140,6 +140,13 @@ jobs: deploy-pages: name: Deploy GitHub Pages + # GitHub Pages allows a single deployment at a time for the whole + # repository, and both docs.yml and wasm.yml deploy to it. A shared, + # non-cancellable writer group serialises them instead of letting the + # second one fail with "another deployment is in progress" (issue #96). + concurrency: + group: github-pages-${{ github.repository }} + cancel-in-progress: false if: | (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' From b06c399941d3a03c67abd5a262d74d7d83e5a492 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:44:42 +0000 Subject: [PATCH 09/21] ci(issue-96): add the security and broken-link workflows from the templates All three ai-driven-development-pipeline templates ship security.yml and links.yml; link-cli had neither, so dependency vulnerabilities and dead documentation links went unnoticed - the false-negative class of #96. - security.yml merges the C#, Rust and JS template jobs: CodeQL over csharp/rust/javascript-typescript/actions, dependency-review on PRs, cargo audit over both Cargo.lock files and npm audit over js/, - links.yml is the C# template's lychee checker with the Wayback Machine fallback (scripts/check-web-archive.mjs reused verbatim), extended to exclude dev/log alongside docs/case-studies, - 'npm audit fix' in js/ clears the three pre-existing high-severity advisories (nanoid, postcss, vite) that the new job would have failed on. --- .github/workflows/links.yml | 99 +++++++++ .github/workflows/security.yml | 113 ++++++++++ .lycheeignore | 4 + js/package-lock.json | 364 ++++++++++++--------------------- scripts/check-web-archive.mjs | 263 ++++++++++++++++++++++++ 5 files changed, 615 insertions(+), 228 deletions(-) create mode 100644 .github/workflows/links.yml create mode 100644 .github/workflows/security.yml create mode 100644 .lycheeignore create mode 100644 scripts/check-web-archive.mjs diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000..fcd8b9b --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,99 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path dev/log + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links found and no web archive fallback + if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true' + run: | + echo "::error::Broken links were detected with no Web Archive fallback available." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " The Web Archive (Wayback Machine) check found no archived versions for some of them." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..a4a6457 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,113 @@ +name: Security + +# Mirrors the security workflow shared by the C#, Rust and JS +# ai-driven-development-pipeline templates, merged into a single file because +# this repository ships all three languages (issue #96). + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [csharp, rust, javascript-typescript, actions] + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + if: matrix.language == 'csharp' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Analyze + uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + cargo-audit: + name: Cargo audit (${{ matrix.manifest }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-cargo-audit-${{ matrix.manifest }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + manifest: [rust, rust/wasm] + steps: + - uses: actions/checkout@v6 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit@0.22.2 + + - name: Audit committed Cargo.lock + run: cargo audit --file ${{ matrix.manifest }}/Cargo.lock + + npm-audit: + name: Audit npm lock + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-npm-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Audit current lock + working-directory: js + run: npm audit --package-lock-only --audit-level=high diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000..60ea928 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,4 @@ +# Lychee ignore patterns - regex patterns for URLs to skip during link checking + +# npmjs.com returns 403 to link checkers because of bot protection. +https://www\.npmjs\.com diff --git a/js/package-lock.json b/js/package-lock.json index e40a476..45df73b 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -19,63 +19,10 @@ "vite-plugin-wasm": "^3.6.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -83,9 +30,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -100,9 +47,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -117,9 +64,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -134,9 +81,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -151,9 +98,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -168,9 +115,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], @@ -188,9 +135,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], @@ -208,9 +155,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], @@ -228,9 +175,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -248,9 +195,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -268,9 +215,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -288,9 +235,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -304,29 +251,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -341,9 +269,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -364,17 +292,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -451,9 +368,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -467,23 +384,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -502,9 +419,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -523,9 +440,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -544,9 +461,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -565,9 +482,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -586,9 +503,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -610,9 +527,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -634,9 +551,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -658,9 +575,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -682,9 +599,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -703,9 +620,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -733,9 +650,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -759,9 +676,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -772,9 +689,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -792,7 +709,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -822,14 +739,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -838,27 +755,26 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -879,9 +795,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -895,26 +811,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -930,7 +838,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/scripts/check-web-archive.mjs b/scripts/check-web-archive.mjs new file mode 100644 index 0000000..2b8244d --- /dev/null +++ b/scripts/check-web-archive.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +/** + * Check broken links against the Wayback Machine (web.archive.org) + * + * This script reads the lychee link checker output (markdown format), + * extracts broken URLs, and checks each one against the Wayback Machine API. + * It then outputs a report with: + * - Links that have a web archive version (with suggestion to replace) + * - Links that have no web archive version (clearly marked as unrecoverable) + * + * Usage: + * node scripts/check-web-archive.mjs + * + * Environment variables: + * - LYCHEE_OUTPUT: Path to lychee markdown output file (default: lychee/out.md) + * + * GitHub Actions outputs: + * - all_archived: 'true' if all broken links have a web archive version + * + * Exit codes: + * - 0: All broken links have web archive versions (or no broken links) + * - 1: Some broken links have no web archive version + */ + +import { readFileSync, appendFileSync, existsSync } from 'fs'; + +const WAYBACK_API = 'https://archive.org/wayback/available?url='; + +/** + * Write output to GitHub Actions output file + * @param {string} name - Output name + * @param {string} value - Output value + */ +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${name}=${value}\n`); + } + console.log(`${name}=${value}`); +} + +/** + * Extract broken URLs from lychee markdown output + * Lychee markdown format includes lines like: + * * [404] https://example.com/broken-link + * * [ERROR] https://another-broken.com + * @param {string} content - The markdown content from lychee + * @returns {string[]} Array of broken URLs + */ +function extractBrokenUrls(content) { + const urls = []; + + // Match lines with error status codes or ERROR markers followed by URLs + // Lychee output format: [STATUS_CODE] URL or bullet points with links + const urlPattern = + /\[(?:4\d\d|5\d\d|ERROR|TIMEOUT|UNKNOWN)\]\s+(https?:\/\/[^\s)]+)/gi; + let match; + + while ((match = urlPattern.exec(content)) !== null) { + const url = match[1].trim(); + if (url && !urls.includes(url)) { + urls.push(url); + } + } + + // Also match plain URL lines in broken sections + // Lychee sometimes outputs: `[ERROR] url | description` + const linePattern = /^\s*(?:\*|-)\s+.*?(https?:\/\/[^\s|)>\]]+)/gm; + let lineMatch; + + while ((lineMatch = linePattern.exec(content)) !== null) { + const url = lineMatch[1].trim().replace(/[.,;!?]+$/, ''); + if (url && !urls.includes(url) && url.startsWith('http')) { + urls.push(url); + } + } + + return urls; +} + +/** + * Check if a URL has an archived version in the Wayback Machine + * Uses the Wayback Machine Availability API: + * https://archive.org/help/wayback_api.php + * @param {string} url - The URL to check + * @returns {Promise<{available: boolean, archiveUrl: string|null, timestamp: string|null}>} + */ +async function checkWaybackMachine(url) { + const apiUrl = `${WAYBACK_API}${encodeURIComponent(url)}`; + + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(apiUrl, { + headers: { + 'User-Agent': 'broken-link-checker/1.0 (GitHub Actions CI)', + }, + signal: controller.signal, + }); + + if (!response.ok) { + console.warn(` Wayback API returned ${response.status} for ${url}`); + return { available: false, archiveUrl: null, timestamp: null }; + } + + const data = await response.json(); + + if (data.archived_snapshots?.closest?.available === true) { + const snapshot = data.archived_snapshots.closest; + const archiveUrl = snapshot.url.replace(/^http:\/\//, 'https://'); + return { + available: true, + archiveUrl, + timestamp: snapshot.timestamp, + }; + } + + return { available: false, archiveUrl: null, timestamp: null }; + } catch (error) { + console.warn( + ` Failed to check Wayback Machine for ${url}: ${error.message}` + ); + return { available: false, archiveUrl: null, timestamp: null }; + } finally { + globalThis.clearTimeout(timeoutId); + } +} + +/** + * Format a timestamp from Wayback Machine (YYYYMMDDHHmmss) to readable date + * @param {string} timestamp - e.g. "20231015143022" + * @returns {string} - e.g. "2023-10-15" + */ +function formatTimestamp(timestamp) { + if (!timestamp || timestamp.length < 8) { + return timestamp; + } + const year = timestamp.slice(0, 4); + const month = timestamp.slice(4, 6); + const day = timestamp.slice(6, 8); + return `${year}-${month}-${day}`; +} + +/** + * Main function + */ +async function main() { + const lycheeOutput = process.env.LYCHEE_OUTPUT || 'lychee/out.md'; + + console.log('=== Web Archive Fallback Check ===\n'); + console.log(`Reading lychee output from: ${lycheeOutput}\n`); + + if (!existsSync(lycheeOutput)) { + console.log('No lychee output file found. Skipping web archive check.'); + setOutput('all_archived', 'true'); + process.exit(0); + } + + const content = readFileSync(lycheeOutput, 'utf-8'); + const brokenUrls = extractBrokenUrls(content); + + if (brokenUrls.length === 0) { + console.log('No broken URLs found in lychee output.'); + setOutput('all_archived', 'true'); + process.exit(0); + } + + console.log( + `Found ${brokenUrls.length} broken URL(s). Checking Web Archive...\n` + ); + + const withArchive = []; + const withoutArchive = []; + + for (const url of brokenUrls) { + console.log(`Checking: ${url}`); + const result = await checkWaybackMachine(url); + + if (result.available) { + const date = formatTimestamp(result.timestamp); + console.log(` ✓ Archived on ${date}: ${result.archiveUrl}`); + withArchive.push({ url, archiveUrl: result.archiveUrl, date }); + } else { + console.log(' ✗ Not found in Web Archive'); + withoutArchive.push(url); + } + + // Small delay to avoid rate-limiting the Wayback API + await new Promise((resolve) => globalThis.setTimeout(resolve, 500)); + } + + console.log('\n=== Web Archive Check Summary ===\n'); + + if (withArchive.length > 0) { + console.log( + `✓ ${withArchive.length} broken link(s) have Web Archive versions - consider replacing:` + ); + for (const { url, archiveUrl, date } of withArchive) { + console.log(` Original: ${url}`); + console.log(` Archive (${date}): ${archiveUrl}`); + console.log(''); + } + + // Print GitHub Actions annotations as suggestions (one per link) + for (const { url, archiveUrl, date } of withArchive) { + console.log( + `::notice title=Broken link - Web Archive available (${date})::` + + `Broken link detected: ${url}\n` + + `A Web Archive snapshot from ${date} is available.\n` + + `Suggested fix: replace the broken link with the archived version:\n` + + ` ${archiveUrl}` + ); + } + } + + if (withoutArchive.length > 0) { + console.log( + `✗ ${withoutArchive.length} broken link(s) have NO Web Archive version:` + ); + for (const url of withoutArchive) { + console.log(` ${url}`); + } + console.log(''); + + // Print GitHub Actions annotations as errors (one per link) + for (const url of withoutArchive) { + console.log( + `::error title=Broken link - No Web Archive fallback::` + + `Broken link detected: ${url}\n` + + `No archived version was found in the Wayback Machine.\n` + + `How to fix:\n` + + ` 1. Find an updated URL for the same or equivalent content and replace the link.\n` + + ` 2. Remove the link if the content is no longer relevant.\n` + + ` 3. Add the URL to .lycheeignore if it is a known false positive (e.g. localhost, example.com).` + ); + } + } + + const allArchived = withoutArchive.length === 0; + setOutput('all_archived', allArchived ? 'true' : 'false'); + + if (!allArchived) { + console.log( + '\nAction required: Fix or remove the broken links listed above.' + ); + console.log( + 'For links with Web Archive versions, you can replace them with the suggested archive.org URLs.' + ); + process.exit(1); + } else { + console.log( + '\nAll broken links have Web Archive versions. Consider replacing them with the suggested archive.org URLs.' + ); + process.exit(0); + } +} + +main().catch((error) => { + console.error('Unexpected error:', error); + process.exit(1); +}); From 95ad9ae9f9e10d3038223ef4b81c6198ee0a8258 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:45:39 +0000 Subject: [PATCH 10/21] docs(issue-96): track the investigation log for issue #96 / PR #97 dev/log was swallowed by the '[Ll]og/' rule inherited from the standard .NET .gitignore, so the collected CI logs, the downloaded template workflows and the root-cause analysis were never part of the PR. The rule is now negated for dev/log specifically. --- .gitignore | 4 + dev/log/issues/96/pulls/97/analysis/README.md | 134 +++ .../97/analysis/temp-file-leak-evidence.md | 25 + .../97/analysis/windows-failed-tests.txt | 114 ++ .../97/best-practices/CI-CD-BEST-PRACTICES.md | 437 +++++++ .../96/pulls/97/issue/issue-96-comments.json | 1 + .../issues/96/pulls/97/issue/issue-96.json | 1 + dev/log/issues/96/pulls/97/issue/issue-96.txt | 66 + .../97/issue/pr-97-conversation-comments.json | 1 + .../pulls/97/issue/pr-97-review-comments.json | 1 + .../96/pulls/97/issue/pr-97-reviews.json | 1 + dev/log/issues/96/pulls/97/issue/pr-97.json | 1 + .../pulls/97/templates/csharp-workflows.txt | 4 + .../96/pulls/97/templates/csharp/docs.yml | 110 ++ .../96/pulls/97/templates/csharp/links.yml | 98 ++ .../96/pulls/97/templates/csharp/release.yml | 793 ++++++++++++ .../96/pulls/97/templates/csharp/security.yml | 49 + .../96/pulls/97/templates/js-workflows.txt | 4 + .../96/pulls/97/templates/js/example-app.yml | 312 +++++ .../issues/96/pulls/97/templates/js/links.yml | 101 ++ .../96/pulls/97/templates/js/release.yml | 862 +++++++++++++ .../96/pulls/97/templates/js/security.yml | 93 ++ .../96/pulls/97/templates/rust-workflows.txt | 4 + .../97/templates/rust/desktop-release.yml | 171 +++ .../96/pulls/97/templates/rust/links.yml | 108 ++ .../96/pulls/97/templates/rust/release.yml | 1072 +++++++++++++++++ .../96/pulls/97/templates/rust/security.yml | 74 ++ .../96/pulls/97/workflows/repo/csharp.yml | 519 ++++++++ .../96/pulls/97/workflows/repo/docs.yml | 159 +++ .../96/pulls/97/workflows/repo/rust.yml | 356 ++++++ .../96/pulls/97/workflows/repo/wasm.yml | 160 +++ 31 files changed, 5835 insertions(+) create mode 100644 dev/log/issues/96/pulls/97/analysis/README.md create mode 100644 dev/log/issues/96/pulls/97/analysis/temp-file-leak-evidence.md create mode 100644 dev/log/issues/96/pulls/97/analysis/windows-failed-tests.txt create mode 100644 dev/log/issues/96/pulls/97/best-practices/CI-CD-BEST-PRACTICES.md create mode 100644 dev/log/issues/96/pulls/97/issue/issue-96-comments.json create mode 100644 dev/log/issues/96/pulls/97/issue/issue-96.json create mode 100644 dev/log/issues/96/pulls/97/issue/issue-96.txt create mode 100644 dev/log/issues/96/pulls/97/issue/pr-97-conversation-comments.json create mode 100644 dev/log/issues/96/pulls/97/issue/pr-97-review-comments.json create mode 100644 dev/log/issues/96/pulls/97/issue/pr-97-reviews.json create mode 100644 dev/log/issues/96/pulls/97/issue/pr-97.json create mode 100644 dev/log/issues/96/pulls/97/templates/csharp-workflows.txt create mode 100644 dev/log/issues/96/pulls/97/templates/csharp/docs.yml create mode 100644 dev/log/issues/96/pulls/97/templates/csharp/links.yml create mode 100644 dev/log/issues/96/pulls/97/templates/csharp/release.yml create mode 100644 dev/log/issues/96/pulls/97/templates/csharp/security.yml create mode 100644 dev/log/issues/96/pulls/97/templates/js-workflows.txt create mode 100644 dev/log/issues/96/pulls/97/templates/js/example-app.yml create mode 100644 dev/log/issues/96/pulls/97/templates/js/links.yml create mode 100644 dev/log/issues/96/pulls/97/templates/js/release.yml create mode 100644 dev/log/issues/96/pulls/97/templates/js/security.yml create mode 100644 dev/log/issues/96/pulls/97/templates/rust-workflows.txt create mode 100644 dev/log/issues/96/pulls/97/templates/rust/desktop-release.yml create mode 100644 dev/log/issues/96/pulls/97/templates/rust/links.yml create mode 100644 dev/log/issues/96/pulls/97/templates/rust/release.yml create mode 100644 dev/log/issues/96/pulls/97/templates/rust/security.yml create mode 100644 dev/log/issues/96/pulls/97/workflows/repo/csharp.yml create mode 100644 dev/log/issues/96/pulls/97/workflows/repo/docs.yml create mode 100644 dev/log/issues/96/pulls/97/workflows/repo/rust.yml create mode 100644 dev/log/issues/96/pulls/97/workflows/repo/wasm.yml diff --git a/.gitignore b/.gitignore index eb388a0..5824e25 100644 --- a/.gitignore +++ b/.gitignore @@ -424,3 +424,7 @@ csharp/_site/ csharp/docs/api/*.yml csharp/docs/api/*.manifest _site/ + +# Issue/PR investigation logs are deliberately tracked so the evidence behind a +# change ships with the pull request (issue #96). +!dev/log/ diff --git a/dev/log/issues/96/pulls/97/analysis/README.md b/dev/log/issues/96/pulls/97/analysis/README.md new file mode 100644 index 0000000..0acb75d --- /dev/null +++ b/dev/log/issues/96/pulls/97/analysis/README.md @@ -0,0 +1,134 @@ +# Issue #96 — Deep analysis + +> "Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all." +> — + +This folder contains the raw evidence (`../ci-logs/`, `../templates/`, `../workflows/`, +`../best-practices/`) and the analysis below. + +## 1. Requirements extracted from the issue + +| ID | Requirement (verbatim intent) | +|----|-------------------------------| +| R1 | Find and fix all **false positives** in CI/CD | +| R2 | Find and fix all **false negatives** in CI/CD | +| R3 | Find and fix all **warnings** in CI/CD | +| R4 | Find and fix all **errors** in CI/CD | +| R5 | Compare **the full file tree** of CI/CD scripts against the three AI-driven-development pipeline templates (`rust-`, `csharp-`, `js-ai-driven-development-pipeline-template`) and reuse all best practices | +| R6 | If the same defect exists in a template, **report an issue in that template repo** too | +| R7 | Follow the CI/CD best practices document from `link-assistant/hive-mind` (`docs/CI-CD-BEST-PRACTICES.md`) | +| R8 | Plan and execute **everything in this single pull request** until every requirement is fully addressed | + +## 2. Timeline of events + +| When | What | Evidence | +|------|------|----------| +| earlier | Windows C# tests started failing with `System.IO.IOException` on temp-file deletion | run 25760911270 (logs expired, HTTP 410) | +| — | Instead of fixing the tests, `continue-on-error: ${{ matrix.os == 'windows-latest' }}` was added to the `Run tests` step of `csharp.yml` with the comment *"Windows has pre-existing file locking issues with some tests"* | `.github/workflows/csharp.yml:186-187` | +| 2026-05-20 | Run 26176444325: the Windows job **reports success** while its log contains `Test Run Failed. Total tests: 212, Passed: 98, Failed: 114` and `Build FAILED` | `../ci-logs/run-26176444325.log` | +| same run | macOS job flakes on `SwapSourceAndTargetForAllLinksUsingVariablesTest` with `System.TimeoutException : Test exceeded 1 seconds timeout` | same log | +| same run | 2 distinct `CS1570` warnings emitted 8× across the matrix; a Node.js-20 deprecation warning is emitted by `actions/github-script` pulled in transitively by `codecov/codecov-action@v5` | same log | +| 2026-08 | Issue #96 filed asking for all of the above to be found and fixed | `../issue/issue-96.json` | + +## 3. Findings, root causes and fixes + +### R2 — false negatives (a real failure reported as success) + +**F1. `continue-on-error` masks 114 failing Windows tests.** +`.github/workflows/csharp.yml:187` marks the whole `dotnet test` step as non-fatal on +`windows-latest`. GitHub Actions then reports the job green. This is the single most +severe defect: for months the Windows leg of the matrix has been decorative. +*Root cause:* a symptom was suppressed rather than diagnosed. +*Fix:* remove the suppression — but only after F2/F3 below are actually fixed, +otherwise CI just turns red. + +**F17. `csharp/scripts/check-file-size.mjs` is dead code.** The script exists and +enforces a 1000-line limit, but no workflow ever invokes it, so the limit is not +enforced for C# at all (the Rust equivalent *is* wired up at `rust.yml:138`). +*Fix:* wire it into the C# lint job. + +**F8/F9. C# warnings can never fail CI.** There is no `csharp/Directory.Build.props` +(all three templates ship one with `TreatWarningsAsErrors`, `EnableNETAnalyzers`, +`AnalysisLevel=latest-all`), and the lint job never runs +`dotnet format --verify-no-changes` (present at `csharp/release.yml:199` in the template). +*Fix:* add both. + +### R4 — errors + +**F2. 226 `System.IO.IOException` → 73 failing Windows tests.** +Every failure is `System.IO.FileSystem.DeleteFile`. The test helpers construct +`NamedTypesDecorator` / `NamedLinksDecorator` / `SimpleLinksDecorator`, +which own `FileMappedResizableDirectMemory` handles, and then `File.Delete` the backing +files **without disposing the decorator first**. +*Root cause:* POSIX allows unlinking a file that is still open; Windows uses mandatory +locking and refuses. The tests are therefore not a Windows bug — they are a resource-leak +bug that only Windows is strict enough to surface. +*Fix:* dispose every `IDisposable` decorator before deleting its files, in **all** +affected helpers. + +**F3. 2 `Assert.Equal` failures on Windows.** +`NamedLinksDecoratorTests.MakeNamesDatabaseFilename_CorrectlyGeneratesFilename` +hard-codes `/`-separated expectations (`"/tmp/test.names.links"`), while the production +code builds the path with `Path.Combine`, which emits `\` on Windows. +*Root cause:* platform-dependent expectation in a platform-independent test. +*Fix:* build the expectation with the same platform-neutral primitives. + +**F4. macOS flake: `Test exceeded 1 seconds timeout`.** +`RunTestWithLinks` wraps every test body in a `CancellationTokenSource(TimeSpan.FromSeconds(1))`. +*Root cause:* a hard-coded 1-second wall-clock budget is far too tight for a shared, +loaded CI runner; it measures runner load, not correctness. +*Fix:* raise the budget and make it overridable via an environment variable. + +### R3 — warnings + +**F5. `CS1570` ×2** at `ChangesSimplifier.cs:187` — `Link` written literally inside an +XML doc comment, so `` is parsed as a (never-closed) XML tag. +*Fix:* use the documentation-comment escape `Link{uint}`; sweep the whole codebase for +the same pattern. + +**F6. Node.js 20 deprecation** for `actions/github-script@60a0d83…`. Not referenced by any +workflow in this repo — it comes transitively from `codecov/codecov-action@v5`. Nothing to +fix locally; reportable upstream (R6). + +**F7. File-size warning:** `rust/src/query_processor.rs` is 994 lines, over the 900-line +warn threshold of `rust/scripts/check-file-size.rs`. + +### R1 — false positives + +A "false positive" here is CI failing (or warning) for something that is not a real defect +in the change under test. The 1-second test timeout (F4) is exactly that: a red build +caused by runner load. Fixing F4 removes it. `fail_ci_if_error: false` on the Codecov step +is retained deliberately — a Codecov outage must not fail a code change. + +### R5/R7 — template + best-practice gaps + +| ID | Gap | Templates that have it | +|----|-----|------------------------| +| F10 | no `security.yml` (CodeQL + `dependency-review-action`) | rust, csharp, js | +| F11 | no `links.yml` (lychee broken-link check) | rust, csharp, js | +| F12 | zero `timeout-minutes` in `csharp.yml` and `rust.yml` | all | +| F13 | no workflow-level least-privilege `permissions:` in `csharp.yml`/`rust.yml` | all | +| F14 | workflow-level `cancel-in-progress: true` on workflows that contain **release/write** jobs — an in-flight NuGet/crates.io publish or tag push can be cancelled | all (reader/writer split) | +| F15 | `always()` in job `if:` instead of `!cancelled()` — keeps running after the user cancels | best practice #12 | +| F16 | no "simulate fresh merge with the base branch" validation | best practice #7 | +| F18 | JS is ~11% of the repo but has no lint job | js template | + +## 4. Existing components / libraries surveyed + +* **`dotnet format`** (ships with the SDK) — whitespace/style verification; no third-party + linter needed for C#. +* **Roslyn analyzers** via `EnableNETAnalyzers` + `AnalysisLevel=latest-all` — built in, + preferred over adding StyleCop/SonarAnalyzer packages. +* **`github/codeql-action`** — first-party SAST, already the templates' choice. +* **`actions/dependency-review-action`** — first-party dependency-diff scanning. +* **`lycheeverse/lychee-action`** — the de-facto broken-link checker; used by the templates. +* **`Microsoft.NET.Test.Sdk` / xUnit `IAsyncLifetime`** — the idiomatic way to scope + test resources; used here via `IDisposable` fixtures instead of ad-hoc `try/finally`. +* No third-party library solves the Windows file-locking problem: the correct fix is to + dispose the handle, which is a plain resource-lifetime bug. + +## 5. Verbose / debug mode + +`RunTestWithLinks` already accepts `enableTracing` (default `false`). The timeout is now +also controllable without a code change via `LINK_CLI_TEST_TIMEOUT_SECONDS`, so a future +iteration can widen or narrow the budget from CI alone. Both default to off/generous. diff --git a/dev/log/issues/96/pulls/97/analysis/temp-file-leak-evidence.md b/dev/log/issues/96/pulls/97/analysis/temp-file-leak-evidence.md new file mode 100644 index 0000000..7830921 --- /dev/null +++ b/dev/log/issues/96/pulls/97/analysis/temp-file-leak-evidence.md @@ -0,0 +1,25 @@ +# Evidence: temp-file leak fixed (measurable on Linux) + +The Windows `IOException`s are the visible symptom of a leak that also existed on Linux — +it was simply invisible there, because POSIX allows unlinking a still-mapped file, and +because several test helpers never deleted the `.names.links` companion database at all. + +Reproduction, before the fix (`dotnet test --no-build -c Release`, Linux): + +``` +$ ls /tmp/*.names.links | wc -l +104 +``` + +After the fix (temp directory cleared, full suite re-run): + +``` +$ rm -f /tmp/*.names.links +$ dotnet test --no-build -c Release +Passed! - Failed: 0, Passed: 222, Skipped: 0, Total: 222 +$ ls /tmp/*.names.links 2>/dev/null | wc -l +0 +``` + +Every names database is now released and deleted. On Windows the same change is what makes +the `File.Delete` calls succeed instead of throwing. diff --git a/dev/log/issues/96/pulls/97/analysis/windows-failed-tests.txt b/dev/log/issues/96/pulls/97/analysis/windows-failed-tests.txt new file mode 100644 index 0000000..fd6e0e4 --- /dev/null +++ b/dev/log/issues/96/pulls/97/analysis/windows-failed-tests.txt @@ -0,0 +1,114 @@ +Foundation.Data.Doublets.Cli.Tests.Issue62ReviewCoverageTests.ExplicitNumericIdUpdate_CanBeReversedWithAnotherUpdate +Foundation.Data.Doublets.Cli.Tests.Issue62ReviewCoverageTests.NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_CreatesNamedReferencesAsPointLinks +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_ReproducesNumberedLinksAtTheirExplicitIndexes +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_TreatsOutOfRangeNumbersAsNames +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_UnquotesNamesWrittenByExporter +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_EscapesNamesThatNeedQuoting +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_SelectsQuoteStyleForNamesContainingQuotes +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_UsesNamesForIndexesSourcesAndTargets_WhenNamesExist +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_UsesNumberedReferences_WhenLinksHaveNoNames +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatStructure_RendersLeftBranchWithLinkIndexes +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatStructure_RendersRepeatedSourceAndTargetAsReferenceOnRight +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.WriteToFile_WritesCompleteDatabaseAsLinoLines +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.AfterCreation_SetNameAndGetName_ShouldWork +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.CanConstructNamedLinksDecorator +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.DeleteLink_RemovesNameAutomatically +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.MakeNamesDatabaseFilename_CorrectlyGeneratesFilename +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.RemoveName_NonExistent_DoesNotThrow +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.RemoveName_ShouldReturnNullAfterRemoval +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.SetNameAndGetName_ShouldReturnSameName +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.SetName_OverwriteOldName +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanConstructFromDatabaseFilename +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanEnumeratePinnedTypes +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanGetLinkByName +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanOverwriteNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanRemoveNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanSetAndGetNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_DeleteRemovesAssociatedNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_HandlesNonexistentNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ImplementsILinks +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ImplementsINamedTypes +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ImplementsIPinnedTypes +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ReassigningExistingNameMovesNameToNewLink +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_UsesProvidedPinnedTypesDecorator +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.CanConstructSimpleLinksDecorator +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.DeleteAfterGetOrCreate_DoesNotThrow +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.DeleteAfterGetOrCreate_DoesNotThrow_WithTracing +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.Delete_WithRestriction_DoesNotThrow +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create3LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create4LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create5LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create6LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create7LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkReferencingExistingLink_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithIntegerId_ShouldCreateSingleLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithNonExistentReference_ShouldThrowException +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithSource2Target2Test +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithValidSelfReference_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithVariableReferences_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithWildcardReferences_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateMultipleLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateMultipleLinksWithCrossReferences_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedFamilyLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedLinkWithMissingNamedReferences_ShouldThrowException +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedLinkWithStringId_ShouldCreateSingleLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkWithIndexAfterDoubleGapTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkWithIndexAfterGapTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkWithIndexTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateTwoNamedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreationDuringUpdateTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreationWithEmptySlotDuringUpdateTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteAllLinksByIndexTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteAllLinksBySourceAndTargetTest1 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteAllLinksBySourceAndTargetTest2 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteByNamesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteLinksByAnySourceTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteLinksByAnyTargetTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteMultipleLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteNamedFamilyLinksRemovesNamesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteNamedLinkTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteSingleLinkTest_Source1Target2 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteSingleLinkTest_Source2Target2 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeletionDuringUpdateTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.ExactMatchAndDelete2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MakeAllLinksSelfReferencingUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MakeAllLinksToGoIntoFirstLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MakeAllLinksToGoOutOfFirstLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MatchAndDelete2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MatchSelfReferencingAndMakeThemGoOutFromFirstLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MatchWithExactIndexAndDelete2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MixedMultipleUpdatesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MultipleUpdatesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NameLookupConsistencyTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NestedDeleteAllLinksBySourceAndTargetTest1 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NoExactMatch2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NoUpdateUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.SwapEqualSourceAndTargetUsingVariablesHasAllChangesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.SwapSourceAndTargetForAllLinksUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.SwapSourceAndTargetForSingleLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.UpdateNamedLinkNameTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.UpdateSingleLinkTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.UpdateWithNonExistentReference_ShouldThrowException +Foundation.Data.Doublets.Cli.Tests.Tests.PersistentTransformationDecoratorTests.AlwaysTriggerIsStoredInLinksAndAppliedAfterWrite +Foundation.Data.Doublets.Cli.Tests.Tests.PersistentTransformationDecoratorTests.NeverRemovesMatchingStoredTrigger +Foundation.Data.Doublets.Cli.Tests.Tests.PersistentTransformationDecoratorTests.OnceTriggerDeletesItselfAfterFirstMatch diff --git a/dev/log/issues/96/pulls/97/best-practices/CI-CD-BEST-PRACTICES.md b/dev/log/issues/96/pulls/97/best-practices/CI-CD-BEST-PRACTICES.md new file mode 100644 index 0000000..d0bedd0 --- /dev/null +++ b/dev/log/issues/96/pulls/97/best-practices/CI-CD-BEST-PRACTICES.md @@ -0,0 +1,437 @@ +# CI/CD Best Practices for AI-Driven Development (languages: en • [zh](CI-CD-BEST-PRACTICES.zh.md) • [hi](CI-CD-BEST-PRACTICES.hi.md) • [ru](CI-CD-BEST-PRACTICES.ru.md)) + +This document describes CI/CD best practices that significantly improve the quality and reliability of AI-driven development workflows. When properly configured, Hive Mind AI solvers are forced to iterate with CI/CD checks until all tests pass, ensuring code quality meets the highest standards. + +## Why CI/CD Matters for AI Development + +Hive Mind's AI issue solver is instructed to pay attention to CI/CD checks in each pull request. This creates a powerful feedback loop: + +1. **AI creates a solution** - The solver generates code based on issue requirements +2. **CI/CD validates the solution** - Automated checks verify code quality +3. **AI iterates until passing** - The solver fixes issues until all checks pass +4. **Quality is guaranteed** - No code merges without passing all gates + +This approach ensures consistent quality regardless of whether the team consists of humans, AIs, or both. + +## Recommended CI/CD Templates + +We provide ready-to-use templates for multiple languages with all best practices pre-configured: + +| Language | Template Repository | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| JavaScript/TypeScript | [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) | +| Rust | [rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) | +| Python | [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) | +| Go | [go-ai-driven-development-pipeline-template](https://github.com/link-foundation/go-ai-driven-development-pipeline-template) | +| C# | [csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) | +| Java | [java-ai-driven-development-pipeline-template](https://github.com/link-foundation/java-ai-driven-development-pipeline-template) | +| PHP | [php-ai-driven-development-pipeline-template](https://github.com/link-foundation/php-ai-driven-development-pipeline-template) | + +> **Tip:** You don't have to pick a template by hand. Run `fix --ci-cd` (see [Automatic CI/CD Remediation](#automatic-cicd-remediation)) and Hive Mind detects the repository's languages and selects the matching templates for you. + +## Key CI/CD Principles + +### 1. Run Checks Only on Relevant File Changes + +**Only trigger checks when relevant files change.** This dramatically reduces CI costs and run times. + +Use a `detect-changes` job at the start of your workflow to determine which file categories changed: + +```yaml +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + code-changed: ${{ steps.changes.outputs.code }} + docs-changed: ${{ steps.changes.outputs.docs }} + docker-changed: ${{ steps.changes.outputs.docker }} + workflow-changed: ${{ steps.changes.outputs.workflow }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Detect changes + id: changes + run: node scripts/detect-code-changes.mjs +``` + +Then gate each job on the relevant output: + +```yaml +test-suites: + needs: [detect-changes] + if: needs.detect-changes.outputs.code-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... + +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + # ... + +docker-pr-check: + needs: [detect-changes] + if: needs.detect-changes.outputs.docker-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... +``` + +**What to exclude from "code changes" detection:** + +- Markdown files (`*.md`) — documentation-only changes don't need changeset files +- `.changeset/` folder — changeset metadata isn't code +- `data/` and `experiments/` folders — non-production content +- `.gitkeep` files — placeholder files with no functional impact + +**What always triggers checks when changed:** + +- Source code files (`.mjs`, `.ts`, `.py`, `.rs`, `.go`, etc.) +- `package.json` / dependency manifests +- CI/CD workflow files (`.github/workflows/*.yml`) +- `Dockerfile` and related infrastructure files + +### 2. File Size Limits + +**Enforce a maximum of 1000-1500 lines per code file.** + +This constraint benefits both AI and human developers: + +- AI models can read and understand entire files within context windows +- Humans can navigate and comprehend files without cognitive overload +- Forces modular, well-organized code architecture + +Example enforcement in CI (bash): + +```bash +find src/ -name "*.mjs" -type f | while read -r file; do + line_count=$(wc -l < "$file") + if [ "$line_count" -gt 1500 ]; then + echo "ERROR: $file has $line_count lines (limit: 1500)" + echo "::error file=$file::File has $line_count lines (limit: 1500)" + exit 1 + fi +done +``` + +**Synchronize the file-size ESLint rule with the CI check** to catch violations locally before CI: + +```js +// eslint.config.mjs +{ + rules: { + 'max-lines': ['error', { max: 1500 }] + } +} +``` + +### 3. Automated Code Formatting + +Consistent formatting eliminates style debates and reduces diff noise: + +| Language | Tool | +| --------------------- | ----------------------------- | +| JavaScript/TypeScript | ESLint + Prettier | +| Rust | rustfmt | +| Python | Ruff | +| Go | gofmt | +| C# | dotnet format | +| Java | Spotless (Google Java Format) | +| PHP | PHP CS Fixer | + +All templates include pre-commit hooks that run formatters automatically before each commit. + +### 4. Static Analysis & Linting + +Catch bugs and enforce patterns before code reaches review: + +| Language | Tools | +| --------------------- | ----------------------------------- | +| JavaScript/TypeScript | ESLint with strict rules | +| Rust | Clippy (pedantic + nursery) | +| Python | Ruff + mypy | +| Go | go vet + staticcheck | +| C# | .NET analyzers (warnings as errors) | +| Java | SpotBugs (maximum effort) | +| PHP | PHPStan (max level) | + +### 5. Fast-Fail Job Ordering + +**Run fast checks before slow checks** to give the fastest possible feedback: + +``` +Fast checks (~7-30s each): Slow checks (~1-10 min each): +├── test-compilation ├── test-suites (unit tests) +├── lint (format + ESLint) ├── test-execution (integration) +└── check-file-line-limits ├── docker-pr-check + └── helm-pr-check +``` + +Gate slow checks on fast checks: + +```yaml +test-suites: + needs: [test-compilation, lint, check-file-line-limits] + if: | + always() && + !cancelled() && + !contains(needs.*.result, 'failure') && + needs.test-compilation.result == 'success' && + needs.lint.result == 'success' && + needs.check-file-line-limits.result == 'success' +``` + +### 6. Changeset-Based Versioning + +All templates use a changeset system that: + +- **Eliminates merge conflicts** - Each PR creates an independent changeset file +- **Automates version bumps** - Highest bump type wins when merging +- **Generates changelogs** - Release notes are compiled automatically +- **Supports semantic versioning** - patch/minor/major bumps are explicit + +| Language | Tool | +| --------------------- | ---------------------------- | +| JavaScript/TypeScript | @changesets/cli | +| Rust | changelog.d + custom scripts | +| Python | Scriv | +| PHP | changelog.d + custom scripts | +| Go, C#, Java | Custom changeset workflows | + +**Exempt docs-only PRs from changeset requirements:** + +```yaml +changeset-check: + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' +``` + +Documentation-only changes (updating `.md` files) should not require a version bump. + +### 7. Validate the Actual Merge Result + +**CI must test what will actually be merged, not a stale PR snapshot.** + +When a PR is opened against a base branch that later receives new commits, the GitHub merge preview can become stale. Simulate a fresh merge before running checks: + +```yaml +- name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git fetch origin "$BASE_REF" + BEHIND_COUNT=$(git rev-list --count HEAD..origin/$BASE_REF) + if [ "$BEHIND_COUNT" -gt 0 ]; then + git merge origin/$BASE_REF --no-edit || \ + (echo "::error::Merge conflict! PR must be rebased before merging." && exit 1) + fi +``` + +This ensures lint, file-size, and other checks validate the final merged state. + +### 8. Pre-commit Hooks + +Local quality gates prevent broken commits from reaching CI: + +1. Format check and auto-fix +2. Lint and static analysis +3. Type checking (where applicable) +4. File size validation +5. Secrets detection + +This "shift left" approach catches issues immediately rather than waiting for CI. + +### 9. Release Automation + +Automated release workflows ensure: + +- **No manual version management** - Versions update automatically +- **OIDC trusted publishing** - No API tokens needed in CI (npm, PyPI, crates.io) +- **Validated releases only** - All checks must pass before publishing +- **Dual trigger modes** - Both automatic (on merge) and manual (workflow dispatch) + +**Prohibit manual version changes** in PRs — all version bumps should be managed by the CI release workflow: + +```yaml +version-check: + if: github.event_name == 'pull_request' + steps: + - name: Check for version changes in package.json + run: node scripts/check-version.mjs +``` + +### 10. Concurrency Control + +**Separate cancellable read-only checks from non-cancellable write jobs.** Configure concurrency at the job level when a workflow contains both kinds of work: + +```yaml +jobs: + lint: + # Include the job identity (and matrix values, when present) so unrelated + # checks remain parallel while a newer run replaces only the stale check. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true + # ... + + deploy: + needs: [lint] + if: ${{ !cancelled() && needs.lint.result == 'success' }} + # Every job that writes to main or an external deployment target uses this + # repository-wide group, even when the jobs live in different workflows. + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # ... +``` + +- **Read-only jobs:** Cancel superseded checks on both pull requests and `main` to reduce runner load. Give each job a distinct suffix; include relevant matrix values so different matrix entries can still run in parallel. +- **Dependent writers:** Use `needs` and require successful prerequisites. A cancelled prerequisite must make its write job not start. +- **Active writers:** Give every release, deploy, tag, generated-content push, and other write job the same repository-scoped group with `cancel-in-progress: false`. An already started writer finishes while the next writer waits in the queue, including writers from another workflow file. +- **Workflow scope:** Do not put cancellable concurrency at workflow level when the workflow has write jobs. Cancelling the workflow would also interrupt a writer that has already started. + +By default, a concurrency group keeps at most one running and one pending job; a newer pending writer replaces the older pending writer. If every queued write must run, add `queue: max` to the writer's concurrency block (up to 100 jobs can wait). `queue: max` cannot be combined with `cancel-in-progress: true`, and execution order follows when jobs start waiting rather than workflow dispatch order, so write jobs should remain idempotent. See [GitHub's concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) for the current queue limits and semantics. + +Use `!cancelled()` instead of `always()` in job conditions so cancellation propagates correctly through the job graph. A bare `always()` can keep downstream work running after cancellation. + +### 11. Secrets Detection + +Prevent accidental credential leaks in CI: + +- Include a secrets scan step using tools like `secretlint` or `truffleHog` +- Fail CI immediately if secrets are detected +- Never log environment variables or token values + +### 12. Documentation Validation + +**Validate documentation files in CI just like code:** + +- Check file size limits (e.g., max 2500 lines for docs) +- Verify required sections exist in key documents +- Check for broken links using tools like `lychee` + +```yaml +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + steps: + - run: node tests/docs-validation.mjs +``` + +### 13. Container Images: Native Runners per Architecture + +**Build each architecture on its own native runner.** GitHub provides free arm64 Linux runners for public repositories (`ubuntu-24.04-arm`). Emulating arm64 with QEMU on an x86 runner is much slower for compiled languages, and building two architectures inside one job makes them sequential instead of parallel. + +```yaml +build-image: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: docker/build-push-action@v7 + with: + platforms: ${{ matrix.platform }} + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + +merge-manifest: + needs: [build-image] + steps: + - run: docker buildx imagetools create -t $IMAGE:$VERSION $DIGESTS +``` + +- **No `setup-qemu-action`.** Its presence means an architecture is being emulated; use a native runner instead. +- **Publish images for every architecture your users run.** A single-architecture image silently excludes Apple Silicon, Graviton, and arm CI runners. +- **Always cache.** Set `cache-from: type=gha` and `cache-to: type=gha,mode=max` on every build step; otherwise every architecture rebuilds the full dependency tree for every release. +- **Never gate the release on the image push.** Publish the GitHub Release and language-registry package first, then attach images as they finish. Release notes contain no data derived from image bytes, so a slow or failed registry push must not hide an otherwise completed release. +- **Assert what you shipped.** Verify that the published manifest lists every intended platform and that each default-branch tag has a corresponding GitHub Release; a missing release is otherwise easy to overlook. + +Reference implementations: [`link-foundation/box`](https://github.com/link-foundation/box) and [`link-assistant/hive-mind`](https://github.com/link-assistant/hive-mind). + +## Quality Enforcement Strategy + +The templates implement a defense-in-depth approach: + +``` +Developer Machine → CI/CD Pipeline → Release +├── Pre-commit hooks ├── detect-changes ├── All checks pass +├── Local tests ├── version-check ├── Version bump +└── IDE integration ├── changeset-check ├── Changelog update + ├── test-compilation └── Publish package + ├── lint (format+ESLint) + ├── check-file-line-limits + ├── test-suites + ├── test-execution + ├── validate-docs + └── docker-pr-check +``` + +Each layer catches different issues, ensuring no problematic code reaches production. + +## Getting Started + +1. **Choose a template** from the table above matching your language +2. **Use it as a GitHub template** to create your new repository +3. **Configure secrets** if needed for publishing (OIDC preferred) +4. **Start developing** with all best practices pre-configured + +The AI solvers will automatically respect and iterate with all configured checks, producing higher quality output than repositories without CI/CD enforcement. + +## Automatic CI/CD Remediation + +For an existing repository, you don't need to apply these practices by hand. The `fix` command automates the whole flow: + +```bash +fix https://github.com/owner/repo --ci-cd +``` + +This command: + +1. **Detects the repository's languages** using the GitHub Linguist API (`GET /repos/{owner}/{repo}/languages`), ordered by the number of bytes per language. +2. **Selects the matching CI/CD templates** from the table above, sorted so the template for the most-used language comes first. +3. **Inspects the latest default-branch commit** and collects its CI/CD runs (falling back to the most recent runs on the default branch when the latest commit has none). +4. **Creates a remediation issue** that lists the failing runs, the detected languages, the recommended templates, and a link back to this document. The issue is created as a **Bug** (with a `bug` label) and its title and text are taken from the [standard remediation template](https://github.com/link-assistant/web-capture/issues/139). +5. **Hands the issue off to `/solve --development-log --deep-analysis --auto-merge`**, which iterates until the fixes are merged. Every option `fix` does not consume itself (for example `--tool`, `--model`, `--think`) is forwarded to `/solve`. + +### Why the issue is a Bug, and what it leaves out + +`--development-log` replaces the template's retired case-study-folder instruction and collects artifacts under `./dev/log/issues/{issue-id}/pulls/{pull-id}`. `/fix` never emits the retired paragraph, including with `--no-solve` or partial option sets. `--deep-analysis` supplies the timeline, root-cause, debug-output, and upstream-reporting guidance, so `fix` conditionally omits the matching paragraphs instead of delivering them twice. + +That omission is only lossless because `/solve` emits the root-cause wording **only for bug-typed issues** — which is why `fix` creates the issue as a Bug. Issue types are configured per organization and labels per repository, so if the target repository accepts neither, the issue is still created without them. + +The retired paragraph cannot be restored by an option combination; `--development-log` is the only supported collection workflow. The remaining conditional omissions are controlled by `--deep-analysis`. + +### Language → Template Mapping + +The command maps detected languages to templates as follows (JavaScript and TypeScript share a single template): + +| Detected Language(s) | Template | +| --------------------- | ---------------------------------------------------------------- | +| JavaScript/TypeScript | `link-foundation/js-ai-driven-development-pipeline-template` | +| Rust | `link-foundation/rust-ai-driven-development-pipeline-template` | +| Python | `link-foundation/python-ai-driven-development-pipeline-template` | +| Go | `link-foundation/go-ai-driven-development-pipeline-template` | +| C# | `link-foundation/csharp-ai-driven-development-pipeline-template` | +| Java | `link-foundation/java-ai-driven-development-pipeline-template` | +| PHP | `link-foundation/php-ai-driven-development-pipeline-template` | + +Languages without a dedicated template (for example Shell or Dockerfile) are listed in the issue for awareness, and the closest matching template is recommended. + +Use `--dry-run` to preview the issue without creating it, and `--no-solve` to create the issue without starting `/solve`: + +```bash +fix owner/repo --ci-cd --dry-run +fix owner/repo --ci-cd --no-solve +``` + +## References + +- [Code Architecture Principles](https://github.com/link-foundation/code-architecture-principles) +- [Contributing Guidelines](./CONTRIBUTING.md) +- [Best Practices](./BEST-PRACTICES.md) diff --git a/dev/log/issues/96/pulls/97/issue/issue-96-comments.json b/dev/log/issues/96/pulls/97/issue/issue-96-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/issue-96-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/issue-96.json b/dev/log/issues/96/pulls/97/issue/issue-96.json new file mode 100644 index 0000000..433f21c --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/issue-96.json @@ -0,0 +1 @@ +{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"### Latest default-branch CI/CD runs\n\n| Workflow | Status | Conclusion | Run |\n| --- | --- | --- | --- |\n| Docs | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444416) |\n| C# CI/CD Pipeline | completed | failure | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444325) |\n| WebAssembly CI | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444415) |\n| Rust CI/CD Pipeline | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444323) |\n\nUse all the best practices from CI/CD templates (check full file tree to compare for all GitHub workflow and CI/CD scripts file), if the same issue is found in template report issue also in templates:\n\n- https://github.com/link-foundation/rust-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/js-ai-driven-development-pipeline-template\n\nWe should compare all files, so we don't have more CI/CD errors in the future and reuse all the best practices from these templates.\n\nFollow the CI/CD best practices collected in [https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md).\n\nPlease plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.\n\n---\n\n
\nContext collected by /fix --ci-cd\n\n- **Repository:** [link-foundation/link-cli](https://github.com/link-foundation/link-cli)\n- **Default branch:** `main`\n- **Latest commit:** `ab2ce8b` ([commit](https://github.com/link-foundation/link-cli/commit/ab2ce8be8e671c91e011d4f02eea10a19deea809)) — Merge pull request #95 from link-foundation/issue-94-c873317dc78c\n- **CI/CD runs found:** 4 (1 not passing)\n\n**Detected languages**\n\n- **Rust** — 45.6%\n- **C#** — 42.2%\n- **JavaScript** — 11.3%\n- **CSS** — 0.8%\n- **HTML** — 0.1%\n\n**Recommended CI/CD templates**\n\nApply the best practices from these templates, in priority order (most-used language first):\n\n1. **Rust** — [link-foundation/rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) _(detected: Rust)_\n2. **C#** — [link-foundation/csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) _(detected: C#)_\n3. **JavaScript / TypeScript** — [link-foundation/js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) _(detected: JavaScript)_\n\nOther detected languages without a dedicated template: CSS, HTML.\n\n
","createdAt":"2026-08-18T12:54:10Z","labels":[{"id":"LA_kwDONXCAbs8AAAAB0ixENw","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":96,"state":"OPEN","title":"Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all"} diff --git a/dev/log/issues/96/pulls/97/issue/issue-96.txt b/dev/log/issues/96/pulls/97/issue/issue-96.txt new file mode 100644 index 0000000..4b63683 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/issue-96.txt @@ -0,0 +1,66 @@ +title: Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all +state: OPEN +author: konard (Konstantin Diachenko) +labels: bug +comments: 0 +assignees: +projects: +milestone: +issue-type: Bug +parent: +sub-issues: +sub-issues-completed: +blocked-by: +blocking: +number: 96 +-- +### Latest default-branch CI/CD runs + +| Workflow | Status | Conclusion | Run | +| --- | --- | --- | --- | +| Docs | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444416) | +| C# CI/CD Pipeline | completed | failure | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444325) | +| WebAssembly CI | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444415) | +| Rust CI/CD Pipeline | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444323) | + +Use all the best practices from CI/CD templates (check full file tree to compare for all GitHub workflow and CI/CD scripts file), if the same issue is found in template report issue also in templates: + +- https://github.com/link-foundation/rust-ai-driven-development-pipeline-template +- https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template +- https://github.com/link-foundation/js-ai-driven-development-pipeline-template + +We should compare all files, so we don't have more CI/CD errors in the future and reuse all the best practices from these templates. + +Follow the CI/CD best practices collected in [https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md). + +Please plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done. + +--- + +
+Context collected by /fix --ci-cd + +- **Repository:** [link-foundation/link-cli](https://github.com/link-foundation/link-cli) +- **Default branch:** `main` +- **Latest commit:** `ab2ce8b` ([commit](https://github.com/link-foundation/link-cli/commit/ab2ce8be8e671c91e011d4f02eea10a19deea809)) — Merge pull request #95 from link-foundation/issue-94-c873317dc78c +- **CI/CD runs found:** 4 (1 not passing) + +**Detected languages** + +- **Rust** — 45.6% +- **C#** — 42.2% +- **JavaScript** — 11.3% +- **CSS** — 0.8% +- **HTML** — 0.1% + +**Recommended CI/CD templates** + +Apply the best practices from these templates, in priority order (most-used language first): + +1. **Rust** — [link-foundation/rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) _(detected: Rust)_ +2. **C#** — [link-foundation/csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) _(detected: C#)_ +3. **JavaScript / TypeScript** — [link-foundation/js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) _(detected: JavaScript)_ + +Other detected languages without a dedicated template: CSS, HTML. + +
diff --git a/dev/log/issues/96/pulls/97/issue/pr-97-conversation-comments.json b/dev/log/issues/96/pulls/97/issue/pr-97-conversation-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97-conversation-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/pr-97-review-comments.json b/dev/log/issues/96/pulls/97/issue/pr-97-review-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97-review-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/pr-97-reviews.json b/dev/log/issues/96/pulls/97/issue/pr-97-reviews.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97-reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/pr-97.json b/dev/log/issues/96/pulls/97/issue/pr-97.json new file mode 100644 index 0000000..4b24bec --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97.json @@ -0,0 +1 @@ +{"body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #96.\n\n### 📋 Issue Reference\nFixes #96\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","createdAt":"2026-08-18T12:54:50Z","headRefName":"issue-96-df5aa0703ffa","number":97,"state":"OPEN","title":"[WIP] Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all"} diff --git a/dev/log/issues/96/pulls/97/templates/csharp-workflows.txt b/dev/log/issues/96/pulls/97/templates/csharp-workflows.txt new file mode 100644 index 0000000..5d02517 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp-workflows.txt @@ -0,0 +1,4 @@ +docs.yml +links.yml +release.yml +security.yml diff --git a/dev/log/issues/96/pulls/97/templates/csharp/docs.yml b/dev/log/issues/96/pulls/97/templates/csharp/docs.yml new file mode 100644 index 0000000..642b36a --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/docs.yml @@ -0,0 +1,110 @@ +name: docs + +# Build and deploy DocFX API documentation to GitHub Pages. +# +# Build runs on every push to main and on PRs that touch docs, sources, or +# this workflow. Publishing is gated on `push` to `main` (and manual dispatch) +# plus an explicit DEPLOY_GITHUB_PAGES=true repository variable, so fresh +# repositories keep docs build validation without failing before Pages is +# enabled and configured. See issue #15 for the failure mode that gating on +# releases produces. + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'docfx.json' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'docfx.json' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Install DocFX + run: dotnet tool update -g docfx + + - name: Restore dependencies + run: dotnet restore + + - name: Build documentation site + run: docfx docfx.json -o _site + + - name: List built site (debug) + run: | + echo "::group::_site tree" + find _site -maxdepth 3 -print + echo "::endgroup::" + + - name: Skip GitHub Pages deployment + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES != 'true' + run: | + echo "::notice::GitHub Pages deployment is disabled. Configure Pages, set repository variable DEPLOY_GITHUB_PAGES=true, then rerun this workflow to publish docs." + + - name: Configure GitHub Pages + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/upload-pages-artifact@v5 + with: + path: _site + + deploy: + name: Deploy to GitHub Pages + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + - name: Print resolved deployment URL (debug) + run: | + echo "Pages deployed to: ${{ steps.deployment.outputs.page_url }}" diff --git a/dev/log/issues/96/pulls/97/templates/csharp/links.yml b/dev/log/issues/96/pulls/97/templates/csharp/links.yml new file mode 100644 index 0000000..a465793 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/links.yml @@ -0,0 +1,98 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links found and no web archive fallback + if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true' + run: | + echo "::error::Broken links were detected with no Web Archive fallback available." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " The Web Archive (Wayback Machine) check found no archived versions for some of them." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/96/pulls/97/templates/csharp/release.yml b/dev/log/issues/96/pulls/97/templates/csharp/release.yml new file mode 100644 index 0000000..1aaddc0 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/release.yml @@ -0,0 +1,793 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name != 'workflow_dispatch' + outputs: + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: bun run "${{ steps.csharp_layout.outputs.root }}/scripts/detect-code-changes.mjs" + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets + changeset-check: + name: Changeset Validation + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Validate changeset + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: | + # Skip changeset check for automated release PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-manual-release-"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Run changeset validation script + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/validate-changeset.mjs" + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-foundation/js-ai-driven-development-pipeline-template/pull/18 for why this dependency was removed + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [detect-changes] + # detect-changes is intentionally skipped for workflow_dispatch. Without a + # status-check function, GitHub adds an implicit success() on detect-changes + # and skips lint (and everything that needs it, including instant-release). + # always() && !cancelled() lets the OR conditions decide instead. + if: | + always() && !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Check formatting + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet format --verify-no-changes --verbosity diagnostic + + - name: Build with warnings as errors + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release /warnaserror + + - name: Run script tests + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: bun test scripts/*.test.mjs + + - name: Check file size limit + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: bun run scripts/check-file-size.mjs + + # === TEST ON MULTIPLE OS === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + needs: [detect-changes] + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # Run tests only for events or file changes that affect runtime behavior. + if: | + always() && !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Build + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release + + - name: Run tests + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" + + - name: Skip Codecov upload when token is unavailable + if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN == '' + run: echo "::notice::CODECOV_TOKEN is not configured; skipping Codecov upload." + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v7 + with: + token: ${{ env.CODECOV_TOKEN }} + fail_ci_if_error: true + + # === BUILD PACKAGE === + # Only runs if lint and test pass + build: + name: Build Package + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [lint, test] + if: always() && needs.lint.result == 'success' && needs.test.result == 'success' + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Build Release + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release + + - name: Pack NuGet package + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: ${{ steps.csharp_layout.outputs.root }}/artifacts/*.nupkg + + # === AUTOMATIC RELEASE === + # Runs on push to main using changesets + release: + name: Release + needs: [lint, test, build] + if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check for changesets + id: check_changesets + run: | + # Count changeset files (excluding README.md and config.json) + CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset" + CHANGESET_COUNT=$(find "$CHANGESET_DIR" -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT + + - name: Check if release is needed + # Self-healing gate: even when a changeset is absent, resume publishing + # if the csproj is missing on NuGet or its GitHub release does + # not exist. See issue #11 and the JS template's check-release-needed.mjs + # for the same pattern. + id: check_release + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/check-release-needed.mjs" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + echo "Multiple changesets detected, merging..." + bun run scripts/merge-changesets.mjs + + - name: Version and commit + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \ + --mode changeset \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Resolve release version + # Picks the version that downstream steps should publish. Prefers the + # one just committed; falls back to the csproj reported by + # check-release-needed for self-healing re-runs. + id: release_version + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + run: | + if [ -n "${{ steps.version.outputs.new_version }}" ]; then + VERSION="${{ steps.version.outputs.new_version }}" + else + VERSION="${{ steps.check_release.outputs.current_version }}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing version: $VERSION" + + - name: Build release package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package id + id: package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r') + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r') + fi + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID="MyPackage" + fi + echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + # Upfront validation surfaces an expired/invalid NUGET_API_KEY before + # we attempt a push that would otherwise return HTTP 403 mid-flight. + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for NuGet indexing + # NuGet's flat-container API can take up to 15 minutes to reflect a + # newly pushed package (see issue #13). Poll it via the tested helper + # before creating the GitHub release so users can `dotnet add package` + # the version mentioned in the release notes. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true')) + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Smoke-test published NuGet package + # Indexing proves the version exists; this installs it in a clean + # throwaway project and runs the advertised library entry point before + # release notes are published. The helper captures command output + # before previewing it, avoiding SIGPIPE-prone live-output pagination. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true')) + run: | + bun run scripts/smoke-test-nuget-package.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \ + --release-version "${{ steps.release_version.outputs.version }}" \ + --repository "${{ github.repository }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --assets-glob "./artifacts/*.nupkg" + + # === MANUAL INSTANT RELEASE === + # Triggered via workflow_dispatch with instant mode + instant-release: + name: Instant Release + needs: [lint, test, build] + # Mirror the automatic release job: a status-check function plus explicit + # needs.*.result checks so the dispatch run is evaluated even though + # detect-changes (an upstream skip) propagated through the needs graph. + if: | + always() && !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.lint.result == 'success' && + needs.test.result == 'success' && + needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Version and commit + id: version + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \ + --mode instant \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Build package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package id + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + id: package + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r') + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r') + fi + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID="MyPackage" + fi + echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for NuGet indexing + # NuGet's flat-container API can take up to 15 minutes to reflect a + # newly pushed package (see issue #13). Poll it via the tested helper + # before creating the GitHub release so users can `dotnet add package` + # the version mentioned in the release notes. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Smoke-test published NuGet package + # Indexing proves the version exists; this installs it in a clean + # throwaway project and runs the advertised library entry point before + # release notes are published. The helper captures command output + # before previewing it, avoiding SIGPIPE-prone live-output pagination. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + bun run scripts/smoke-test-nuget-package.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \ + --release-version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --assets-glob "./artifacts/*.nupkg" + + # === MANUAL CHANGESET PR === + # Creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Create changeset file + run: | + CHANGESET_ID=$(date +%s) + CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset" + mkdir -p "$CHANGESET_DIR" + CHANGESET_FILE="$CHANGESET_DIR/manual-release-${CHANGESET_ID}.md" + + cat > "$CHANGESET_FILE" << 'EOF' + --- + 'MyPackage': ${{ github.event.inputs.bump_type }} + --- + + ${{ github.event.inputs.description || 'Manual release' }} + EOF + + echo "Created changeset: $CHANGESET_FILE" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will version, publish, and create a GitHub release diff --git a/dev/log/issues/96/pulls/97/templates/csharp/security.yml b/dev/log/issues/96/pulls/97/templates/csharp/security.yml new file mode 100644 index 0000000..80b2b93 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/security.yml @@ -0,0 +1,49 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [csharp, actions] + steps: + - uses: actions/checkout@v6 + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v4 + - uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + - uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/dev/log/issues/96/pulls/97/templates/js-workflows.txt b/dev/log/issues/96/pulls/97/templates/js-workflows.txt new file mode 100644 index 0000000..021b09c --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js-workflows.txt @@ -0,0 +1,4 @@ +example-app.yml +links.yml +release.yml +security.yml diff --git a/dev/log/issues/96/pulls/97/templates/js/example-app.yml b/dev/log/issues/96/pulls/97/templates/js/example-app.yml new file mode 100644 index 0000000..01f7f71 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/example-app.yml @@ -0,0 +1,312 @@ +name: Example app + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'examples/universal-app/**' + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/update-preview-images.mjs' + - '.github/workflows/example-app.yml' + push: + branches: + - main + paths: + - 'examples/universal-app/**' + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/update-preview-images.mjs' + - '.github/workflows/example-app.yml' + workflow_dispatch: + +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + web-build: + name: Build web app + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-web-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/configure-pages@v6 + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Build app + run: npm run example:web:build + env: + GITHUB_PAGES: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + VITE_REPOSITORY_URL: https://github.com/${{ github.repository }} + + - name: Upload web build artifact + uses: actions/upload-artifact@v7 + with: + name: universal-example-web + path: examples/universal-app/dist + if-no-files-found: error + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v5 + with: + path: examples/universal-app/dist + + # Requires Settings → Pages → Source = GitHub Actions to be set once + # in the repository before this job can succeed. See README → "Deploying + # the example app". The Pages source cannot be configured from a workflow. + pages-deploy: + name: Deploy GitHub Pages + needs: [web-build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v5 + + desktop-package: + name: Package desktop app (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-desktop-package-${{ matrix.os }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Package Electron app + shell: bash + run: | + npm run example:desktop:package + for attempt in {1..30}; do + if [[ -d examples/universal-app/out ]] && + [[ -n "$(find examples/universal-app/out -mindepth 1 -print -quit)" ]]; then + find examples/universal-app/out -maxdepth 2 -mindepth 1 -print + exit 0 + fi + sleep 1 + done + echo "::error::Desktop package output was not created at examples/universal-app/out" + exit 1 + env: + VITE_REPOSITORY_URL: https://github.com/${{ github.repository }} + + - name: Upload desktop package + uses: actions/upload-artifact@v7 + with: + name: universal-example-desktop-${{ matrix.os }} + path: examples/universal-app/out + if-no-files-found: error + + android-build: + name: Build Android app + if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_ANDROID_BUILD == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-android-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Add Android project + run: npm --prefix examples/universal-app run mobile:android:add + + - name: Build Android project + run: npm --prefix examples/universal-app run mobile:android:build + + - name: Upload Android output + uses: actions/upload-artifact@v7 + with: + name: universal-example-android + path: | + examples/universal-app/android/app/build/outputs/**/*.apk + examples/universal-app/android/app/build/outputs/**/*.aab + if-no-files-found: warn + + ios-build: + name: Build iOS app + if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_IOS_BUILD == 'true' + runs-on: macos-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-ios-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Add iOS project + run: npm --prefix examples/universal-app run mobile:ios:add + + - name: Build iOS project + run: npm --prefix examples/universal-app run mobile:ios:build + + # Regenerate example-app preview screenshots (docs/screenshots/example-app/*) + # using browser-commander + Playwright so README/site images always reflect + # the current UI. Issue: #62. Implementation: scripts/update-preview-images.mjs. + preview-regen: + name: Regenerate Preview Images + runs-on: ubuntu-latest + container: + # Keep this tag in sync with the playwright package version below. + image: mcr.microsoft.com/playwright:v1.59.1-noble + timeout-minutes: 20 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' + permissions: + contents: write + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + steps: + - uses: actions/checkout@v6 + with: + # Regenerate against main HEAD so the bot commit lands on a + # fast-forward parent regardless of which trigger started the job. + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Install example app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Install browser automation dependencies + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + run: npm install --no-save --package-lock=false --no-audit --no-fund browser-commander@0.8.1 playwright@1.59.1 + + - name: Regenerate preview images + run: node scripts/update-preview-images.mjs + + - name: Detect drift + id: drift + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "drift=true" >> "$GITHUB_OUTPUT" + echo "Preview images drifted:" + git status --porcelain + else + echo "drift=false" >> "$GITHUB_OUTPUT" + echo "Preview images already current." + fi + + - name: Commit drift back to main + if: steps.drift.outputs.drift == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Stage only generated artifacts so unrelated changes can't leak in. + git add docs/screenshots/example-app/*.png || true + if git diff --cached --quiet; then + echo "::notice::No tracked preview-image changes to commit (drift was outside expected paths)." + git status --porcelain + exit 0 + fi + # [skip ci] prevents an infinite re-run loop; the next push to main + # will pick up these fresh images for the regular pages-build. + git commit -m "chore(preview): regenerate example-app preview images [skip ci]" + bash scripts/push-main-with-rebase-retry.sh + + - name: Upload screenshot failure artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: preview-regen-failure-${{ github.run_id }} + path: | + docs/screenshots/ + web/test-results/ + web/playwright-report/ + retention-days: 7 + if-no-files-found: ignore + + - name: Summarize regeneration result + if: always() + run: | + if [[ "${{ steps.drift.outputs.drift }}" == "true" ]]; then + echo "::notice::Preview images regenerated and (if applicable) committed to main." + else + echo "::notice::Preview images are already up to date." + fi diff --git a/dev/log/issues/96/pulls/97/templates/js/links.yml b/dev/log/issues/96/pulls/97/templates/js/links.yml new file mode 100644 index 0000000..3cc3b91 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/links.yml @@ -0,0 +1,101 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + # Exclude the Vite source HTML because its root-relative app asset + # URLs are only valid when served by Vite. + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path examples/universal-app/index.html + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links were found + if: always() && steps.lychee.outputs.exit_code != 0 + run: | + echo "::error::Broken live links were detected." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " An archive is a suggested replacement; it does not make the live link valid." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive replacement exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/96/pulls/97/templates/js/release.yml b/dev/log/issues/96/pulls/97/templates/js/release.yml new file mode 100644 index 0000000..ec8edd7 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/release.yml @@ -0,0 +1,862 @@ +name: Checks and release + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + # Manual release support - consolidated here to work with npm trusted publishing + # npm only allows ONE workflow file as trusted publisher, so all publishing + # must go through this workflow (release.yml) + workflow_dispatch: + inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Manual release type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Manual release description (optional)' + required: false + type: string + +# Least-privilege default for the highest-value token in the repository. +# Every job starts read-only; the publishing jobs escalate individually. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + # Typical run: ~6s. Cap at 5min so a hung detection step + # surfaces quickly instead of stalling the whole pipeline. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name != 'workflow_dispatch' + outputs: + js-changed: ${{ steps.changes.outputs.js-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect changes + id: changes + run: node scripts/detect-code-changes.mjs + + # === FAST CHECKS - run before slow tests for fastest feedback === + # See: hive-mind CI/CD best practices principle #5 (fast-fail job ordering) + + # Syntax check all .mjs files with node --check (~7s) + test-compilation: + name: Test Compilation + runs-on: ubuntu-latest + # Typical run: <10s. Tight cap fails fast on syntax-check hangs. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-compilation + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.js-changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Check .mjs syntax + run: bash scripts/check-mjs-syntax.sh + + # Enforce 1500-line limit on .mjs files and release.yml + check-file-line-limits: + name: Check File Line Limits + runs-on: ubuntu-latest + # Typical run: <10s. This job only walks tracked files and counts lines. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-check-file-line-limits + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + # Enforces the 1500-line limit on JavaScript (.js/.mjs/.cjs) and + # Markdown (.md) files plus release.yml. This is the single source + # of truth for the line limit; validate-docs no longer re-checks it. + - name: Check file line limits + run: bash scripts/check-file-line-limits.sh + + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in package.json - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + # Typical run: ~6s. Read-only package.json diff inspection. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for version changes in package.json + env: + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: node scripts/check-version.mjs + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets + changeset-check: + name: Check for Changesets + runs-on: ubuntu-latest + # Typical run: <30s including npm install. 10min covers cold runners. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-changeset-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Check for changesets + env: + # Pass PR context to the validation script + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Skip changeset check for automated version PRs + if [[ "$GITHUB_HEAD_REF" == "changeset-release/"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Run changeset validation script + # This validates that exactly ONE changeset was ADDED by this PR + # Pre-existing changesets from other merged PRs are ignored + node scripts/validate-changeset.mjs + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + # IMPORTANT: ESLint includes max-lines rule (1500 lines) to ensure files stay maintainable + # See docs/case-studies/issue-23 for why fresh merge simulation is critical + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + # Typical run: <1min including install, ESLint, Prettier, jscpd, + # and secretlint. 10min protects against a hung lint plugin. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + !cancelled() && + ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + with: + # For PRs, fetch enough history to merge with base branch + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Check code duplication + run: npm run check:duplication + + - name: Check for secrets + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*" + + # Test matrix: 3 runtimes (Node.js, Bun, Deno) x 3 OS (Ubuntu, macOS, Windows) + # IMPORTANT: Tests must validate the ACTUAL merge result, not a stale merge preview. + # See docs/case-studies/issue-23 for why this is critical. + # Fast-fail: slow test matrix only runs after fast checks pass (hive-mind principle #5) + test: + name: Test (${{ matrix.runtime }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # Typical run: <1min per runtime/OS on warm runners, with Windows + # sometimes slower on cold starts. 10min fails hung tests well + # before GitHub Actions' 6h default. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.runtime }}-${{ matrix.os }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: + [ + detect-changes, + changeset-check, + test-compilation, + lint, + check-file-line-limits, + ] + # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278) + # Run for relevant code/package/workflow changes, or for an instant manual + # release. Skipped fast checks count as non-failures only after one of + # those positive gates passes. + if: | + !cancelled() && + ( + needs.detect-changes.outputs.any-code-changed == 'true' || + ( + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' + ) + ) && + (needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') && + (needs.test-compilation.result == 'success' || needs.test-compilation.result == 'skipped') && + (needs.lint.result == 'success' || needs.lint.result == 'skipped') && + (needs.check-file-line-limits.result == 'success' || needs.check-file-line-limits.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runtime: [node, bun, deno] + steps: + - uses: actions/checkout@v6 + with: + # For PRs, fetch enough history to merge with base branch + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + shell: bash + run: bash scripts/simulate-fresh-merge.sh + + - name: Setup Node.js + if: matrix.runtime == 'node' + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies (Node.js) + if: matrix.runtime == 'node' + run: npm install + + - name: Run tests (Node.js) + if: matrix.runtime == 'node' + run: npm test + + - name: Setup Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies (Bun) + if: matrix.runtime == 'bun' + run: bun install + + - name: Run tests (Bun) + if: matrix.runtime == 'bun' + # --timeout caps an individual test at 30s, matching Node's + # --test-timeout budget while leaving headroom for cold runners. + run: bun test --timeout 30000 + + - name: Setup Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Run tests (Deno) + if: matrix.runtime == 'deno' + run: deno test --allow-read + + # === DOCKER IMAGE BUILD CHECK (pull requests) === + # Builds the Dockerfile without pushing so a broken image fails the pull + # request instead of surfacing only after publish (issue #106). + # push: false + load: true keeps this working for fork pull requests, + # which have no registry credentials. Skipped when no Dockerfile exists. + docker-build: + name: Docker Image Build Check + runs-on: ubuntu-latest + # Typical run: a few minutes with a warm GHA layer cache. 30min covers + # a cold, uncached build without allowing a 6h hang. + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-docker-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + permissions: + contents: read + env: + DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }} + DOCKERFILE: ${{ vars.DOCKERFILE }} + steps: + - uses: actions/checkout@v6 + + - name: Check Docker build configuration + id: docker_config + run: node scripts/check-docker-build.mjs + + - name: Set up Docker Buildx (resilient) + if: steps.docker_config.outputs.enabled == 'true' + uses: ./.github/actions/setup-buildx-resilient + + - name: Build Docker image (no push) + if: steps.docker_config.outputs.enabled == 'true' + uses: docker/build-push-action@v7 + with: + context: ${{ steps.docker_config.outputs.context }} + file: ${{ steps.docker_config.outputs.dockerfile }} + push: false + load: true + tags: pr-check:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # === DOCUMENTATION VALIDATION === + # Validate documentation files when docs change (hive-mind principle #12) + validate-docs: + name: Validate Documentation + runs-on: ubuntu-latest + # Typical run: <10s. Pure shell checks over documentation files. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-validate-docs + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.docs-changed == 'true' + steps: + - uses: actions/checkout@v6 + + # Documentation line limits (1500 lines, matching the architecture + # limit) are enforced by the check-file-line-limits job, which scans + # every .md file. This job only validates required-file presence. + - name: Check required documentation files exist + run: | + REQUIRED_FILES=( + "docs/BEST-PRACTICES.md" + "docs/CONTRIBUTING.md" + "README.md" + "CHANGELOG.md" + ) + + MISSING=() + for file in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$file" ]; then + echo "ERROR: Required documentation file missing: $file" + MISSING+=("$file") + else + echo "Found: $file" + fi + done + + if [ "${#MISSING[@]}" -gt 0 ]; then + echo "" + echo "Missing required documentation files:" + printf ' %s\n' "${MISSING[@]}" + exit 1 + else + echo "All required documentation files present." + fi + + # Release - only runs on main after tests pass (for push events) + release: + name: Release + needs: [lint, test] + # Typical run is well under 10min. 30min gives npm and GitHub + # release APIs room for retries without allowing a 6h hang. + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278) + # This is needed because lint/test jobs have a transitive dependency on changeset-check + if: | + !cancelled() && + github.ref == 'refs/heads/main' && + github.event_name == 'push' && + needs.lint.result == 'success' && + needs.test.result == 'success' + runs-on: ubuntu-latest + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + outputs: + published: ${{ steps.publish.outputs.published }} + published_version: ${{ steps.publish.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - name: Remove deprecated npm auth config + run: node scripts/sanitize-npm-userconfig.mjs + + - name: Install dependencies + run: npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Check for changesets + id: check_changesets + run: node scripts/check-changesets.mjs + + - name: Check if release is needed + id: check_release + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + run: node scripts/check-release-needed.mjs + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + run: | + echo "Multiple changesets detected, merging..." + node scripts/merge-changesets.mjs + + - name: Version packages and commit to main + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + run: node scripts/version-and-commit.mjs --mode changeset + + - name: Publish to npm + # Run if version was committed, if a previous attempt already committed (for re-runs), + # or if check-release-needed detected an unpublished version (self-healing, issue #36) + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + id: publish + # Optional NPM_TOKEN bootstrap fallback. OIDC trusted publishing is the + # steady-state mechanism, but it cannot create a brand-new package (the + # first publish returns E404 because a trusted publisher can only be + # configured for a package that already exists). When NPM_TOKEN is set, + # the first publish succeeds; once the package exists and a trusted + # publisher is configured, OIDC takes over and the token can be removed. + # See issue #77. + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/publish-to-npm.mjs --should-pull + + - name: Smoke-test published npm package + if: steps.publish.outputs.published == 'true' + run: node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}" + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Manual Instant Release - triggered via workflow_dispatch with instant mode + # This job is in release.yml because npm trusted publishing + # only allows one workflow file to be registered as a trusted publisher + instant-release: + name: Instant Release + # Publishing must wait for both quality gates and require explicit success. + needs: [lint, test] + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.lint.result == 'success' && + needs.test.result == 'success' + runs-on: ubuntu-latest + # Same publish envelope as the automated release path. + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + outputs: + published: ${{ steps.publish.outputs.published }} + published_version: ${{ steps.publish.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - name: Remove deprecated npm auth config + run: node scripts/sanitize-npm-userconfig.mjs + + - name: Install dependencies + run: npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Version packages and commit to main + id: version + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + RELEASE_DESCRIPTION: ${{ github.event.inputs.description }} + run: node scripts/version-and-commit.mjs --mode instant --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION" + + - name: Publish to npm + # Run if version was committed OR if a previous attempt already committed (for re-runs) + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish + # Optional NPM_TOKEN bootstrap fallback; OIDC trusted publishing is used + # when the secret is unset. See the release job above for details (#77). + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/publish-to-npm.mjs + + - name: Smoke-test published npm package + if: steps.publish.outputs.published == 'true' + run: node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}" + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Optional Docker Hub publishing for packages that also ship Docker images. + # Set vars.DOCKERHUB_IMAGE to enable this path, then configure + # vars.DOCKERHUB_USERNAME and secrets.DOCKERHUB_TOKEN. + docker-publish-config: + name: Configure Docker Hub Publish + needs: [release, instant-release] + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + if: | + !cancelled() && + ( + (needs.release.result == 'success' && needs.release.outputs.published == 'true') || + (needs.instant-release.result == 'success' && needs.instant-release.outputs.published == 'true') + ) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + context: ${{ steps.docker_config.outputs.context }} + dockerfile: ${{ steps.docker_config.outputs.dockerfile }} + enabled: ${{ steps.docker_config.outputs.enabled }} + image: ${{ steps.docker_config.outputs.image }} + version: ${{ steps.release_version.outputs.version }} + env: + DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }} + DOCKERFILE: ${{ vars.DOCKERFILE }} + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} + RELEASE_VERSION: ${{ needs.release.outputs.published_version || needs.instant-release.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + + - name: Check Docker publish configuration + id: docker_config + run: node scripts/check-docker-publish.mjs + + - name: Export release version + id: release_version + env: + VERSION: ${{ env.RELEASE_VERSION }} + run: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Wait for npm package availability before Docker publish + if: steps.docker_config.outputs.enabled == 'true' + run: node scripts/wait-for-npm.mjs --release-version "${{ env.RELEASE_VERSION }}" + + # Build each architecture on a native runner. Each build is pushed by digest; + # docker-publish combines those immutable digests into the release tags. + docker-publish-build: + name: Build Docker Image (${{ matrix.platform }}) + needs: [docker-publish-config] + if: needs.docker-publish-config.outputs.enabled == 'true' + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Build and push platform image by digest + id: build + uses: ./.github/actions/publish-dockerhub + with: + context: ${{ needs.docker-publish-config.outputs.context }} + file: ${{ needs.docker-publish-config.outputs.dockerfile }} + image: ${{ needs.docker-publish-config.outputs.image }} + platform: ${{ matrix.platform }} + token: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ vars.DOCKERHUB_USERNAME }} + version: ${{ needs.docker-publish-config.outputs.version }} + + - name: Export image digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload image digest + uses: actions/upload-artifact@v7 + with: + name: docker-digest-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-publish: + name: Optional Docker Hub Publish + needs: [docker-publish-config, docker-publish-build] + if: needs.docker-publish-build.result == 'success' + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx (resilient) + uses: ./.github/actions/setup-buildx-resilient + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Download image digests + uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - name: Create multi-architecture manifest + working-directory: /tmp/digests + env: + IMAGE: ${{ needs.docker-publish-config.outputs.image }} + VERSION: ${{ needs.docker-publish-config.outputs.version }} + run: | + docker buildx imagetools create \ + --tag "${IMAGE}:latest" \ + --tag "${IMAGE}:${VERSION}" \ + $(printf "${IMAGE}@sha256:%s " *) + + # Manual Changeset PR - creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + # PR creation does not publish, but it must still pass the fast lint gate. + needs: [lint] + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'changeset-pr' && + needs.lint.result == 'success' + runs-on: ubuntu-latest + # PR creation only: install, create a changeset, format, and open a PR. + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Create changeset file + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + RELEASE_DESCRIPTION: ${{ github.event.inputs.description }} + run: node scripts/create-manual-changeset.mjs --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION" + + - name: Format changeset with Prettier + run: | + # Run Prettier on the changeset file to ensure it matches project style + npx prettier --write ".changeset/*.md" || true + + echo "Formatted changeset files" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will create a version PR + 4. Merge the version PR to publish to npm and create a GitHub release + + # GitHub reports jobs killed by timeout-minutes as cancelled rather than + # failed. Observe every job so those cancellations become visible failures + # on main, where concurrency never supersedes an in-progress run. + pipeline-status: + name: Pipeline Status + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-pipeline-status + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: always() + needs: + - detect-changes + - test-compilation + - check-file-line-limits + - version-check + - changeset-check + - lint + - test + - docker-build + - docker-publish-config + - docker-publish-build + - validate-docs + - release + - instant-release + - docker-publish + - changeset-pr + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Fail the run when a required job was cancelled or failed + env: + NEEDS_JSON: ${{ toJSON(needs) }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + run: bash scripts/check-pipeline-status.sh diff --git a/dev/log/issues/96/pulls/97/templates/js/security.yml b/dev/log/issues/96/pulls/97/templates/js/security.yml new file mode 100644 index 0000000..6c0e421 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/security.yml @@ -0,0 +1,93 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [javascript-typescript, actions] + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Analyze + uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + npm-audit: + name: Audit npm lock (${{ matrix.directory }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-npm-audit-${{ matrix.directory }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + directory: ['.', examples/universal-app] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Audit current lock + working-directory: ${{ matrix.directory }} + run: npm audit --package-lock-only --audit-level=high diff --git a/dev/log/issues/96/pulls/97/templates/rust-workflows.txt b/dev/log/issues/96/pulls/97/templates/rust-workflows.txt new file mode 100644 index 0000000..806c40a --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust-workflows.txt @@ -0,0 +1,4 @@ +desktop-release.yml +links.yml +release.yml +security.yml diff --git a/dev/log/issues/96/pulls/97/templates/rust/desktop-release.yml b/dev/log/issues/96/pulls/97/templates/rust/desktop-release.yml new file mode 100644 index 0000000..f888ceb --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/desktop-release.yml @@ -0,0 +1,171 @@ +name: Desktop Release + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_run: + workflows: [CI/CD Pipeline] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Release tag (defaults to the latest published release) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: desktop-release-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + resolve: + name: Resolve release + if: ${{ vars.DESKTOP_RELEASE_ENABLED == 'true' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.release.outputs.tag }} + should_build: ${{ steps.release.outputs.should_build }} + steps: + - uses: actions/checkout@v6 + - name: Resolve release tag + id: release + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + REPO: ${{ github.repository }} + WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: bash scripts/desktop-release-resolve.sh + + build: + name: Build ${{ matrix.label }} + needs: resolve + if: ${{ !cancelled() && (github.event_name == 'pull_request' || needs.resolve.outputs.should_build == 'true') }} + strategy: + fail-fast: false + matrix: + include: + - { label: linux-x64, runner: ubuntu-latest } + - { label: linux-arm64, runner: ubuntu-24.04-arm } + - { label: macos-x64, runner: macos-15-intel } + - { label: macos-arm64, runner: macos-14 } + - { label: windows-x64, runner: windows-latest } + - { label: windows-arm64, runner: windows-11-arm } + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name != 'pull_request' && needs.resolve.outputs.tag || '' }} + - uses: dtolnay/rust-toolchain@stable + - name: Package desktop application + env: + DESKTOP_RELEASE_TAG: ${{ github.event_name == 'pull_request' && 'dry-run' || needs.resolve.outputs.tag }} + # electron-builder 26 requires both values for certificate-free PR builds: + CSC_FOR_PULL_REQUEST: 'true' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: bash scripts/package-desktop.sh "${{ matrix.label }}" desktop-dist + - name: Smoke test release assets + env: + OUTPUT_DIR: desktop-dist + run: | + set -euo pipefail + first_asset="$(find "$OUTPUT_DIR" -type f -size +0c -print -quit)" + [ -n "$first_asset" ] || { echo "No non-empty desktop assets were produced" >&2; exit 1; } + find "$OUTPUT_DIR" -type f -size +0c -print | sed 's/^/Verified /' + # Electron apps must retain a signature envelope. Use + # electron-builder --mac ... -c.mac.identity=- and set + # CSC_FOR_PULL_REQUEST=true on the ad-hoc path. + while IFS= read -r app; do + [ -f "$app/Contents/_CodeSignature/CodeResources" ] || { + echo "Missing macOS CodeResources signature envelope: $app" >&2; exit 1; + } + done < <(find "$OUTPUT_DIR" -type d -name '*.app' -print) + - name: Create checksum fragment + run: | + set -euo pipefail + cd desktop-dist + if command -v sha256sum >/dev/null; then + find . -type f -print0 | sort -z | xargs -0 sha256sum + else + find . -type f -exec shasum -a 256 {} + + fi | sed 's# \./# #' > "SHA256SUMS-${{ matrix.label }}.partial" + - name: Upload packaged assets + uses: actions/upload-artifact@v7 + with: + name: desktop-assets-${{ matrix.label }} + path: desktop-dist + retention-days: 7 + - name: Upload checksum fragment + uses: actions/upload-artifact@v7 + with: + name: desktop-checksums-${{ matrix.label }} + path: desktop-dist/SHA256SUMS-${{ matrix.label }}.partial + retention-days: 7 + + finalize: + name: Publish checksums and provenance + needs: [resolve, build] + if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.build.result == 'success' && needs.resolve.outputs.should_build == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: write + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + pattern: desktop-assets-* + path: release-assets + - uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + pattern: desktop-checksums-* + path: checksums + merge-multiple: true + - name: Consolidate release metadata + env: + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + cat checksums/*.partial | sort -k2 > SHA256SUMS.txt + { + echo 'Desktop build provenance' + echo '========================' + echo 'Status : complete' + echo 'Repository : ${{ github.repository }}' + echo "Release tag: $TAG" + echo 'Targets : linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64 windows-arm64' + echo 'Attestation: verify each asset with gh attestation verify --repo ${{ github.repository }}' + echo 'Run : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } > BUILD-PROVENANCE.txt + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: | + release-assets/**/* + SHA256SUMS.txt + - name: Upload release metadata + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + mapfile -d '' assets < <(find release-assets -type f ! -name '*.partial' -print0) + [ ${#assets[@]} -gt 0 ] || { echo 'No release assets were downloaded' >&2; exit 1; } + gh release upload "$TAG" "${assets[@]}" SHA256SUMS.txt BUILD-PROVENANCE.txt \ + --repo "${{ github.repository }}" --clobber diff --git a/dev/log/issues/96/pulls/97/templates/rust/links.yml b/dev/log/issues/96/pulls/97/templates/rust/links.yml new file mode 100644 index 0000000..4607278 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/links.yml @@ -0,0 +1,108 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + - '.lycheeignore' + - 'scripts/check-web-archive.mjs' + - 'scripts/check-web-archive.test.mjs' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + - '.lycheeignore' + - 'scripts/check-web-archive.mjs' + - 'scripts/check-web-archive.test.mjs' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Test Web Archive report parser + run: node --test scripts/check-web-archive.test.mjs + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links were found + if: always() && steps.lychee.outputs.exit_code != 0 + run: | + echo "::error::Broken live links were detected." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " A Web Archive snapshot is a suggested replacement; it does not fix the broken source link." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. Review the Web Archive step when it ran. For links marked with a '::notice::' annotation," + echo " a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/96/pulls/97/templates/rust/release.yml b/dev/log/issues/96/pulls/97/templates/rust/release.yml new file mode 100644 index 0000000..6c70feb --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/release.yml @@ -0,0 +1,1072 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changelog-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +# Least-privilege default for every job; jobs that need more escalate explicitly. +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + # Provide Git's initial branch before actions/checkout runs git init so + # Git 2.54+ does not emit the upcoming Git 3.0 default-branch hint. + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + # Harden Cargo registry index and crate downloads against transient network + # flakes on GitHub-hosted runners, including curl HTTP/2 framing failures. + CARGO_NET_RETRY: '10' + CARGO_HTTP_MULTIPLEXING: 'false' + # Support both CARGO_REGISTRY_TOKEN (cargo's native env var) and CARGO_TOKEN (for backwards compatibility) + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + # Optional: set repository variable DOCKERHUB_IMAGE to namespace/image to publish Docker Hub releases. + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} + +# Concurrency is intentionally job-scoped. Each read-only job cancels only its +# superseded counterpart for the same non-main ref. Main checks are not +# cancelled, so a cancelled main job is always exceptional and the terminal +# status gate can safely report it as a failure. Jobs that can +# mutate repositories, registries, releases, or Pages all use one shared +# concurrency group instead. A write that has started is never cancelled, and +# GitHub retains at most one pending run for the group. +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name != 'workflow_dispatch' + outputs: + rs-changed: ${{ steps.changes.outputs.rs-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: rust-script scripts/detect-code-changes.rs + + # === CHANGELOG CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changelog fragments + changelog: + name: Changelog Fragment Check + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-changelog + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-changelog-fragment.rs + + # === VERSION CHECK - prevents manual version modification in PRs === + # This ensures versions are only modified by the automated release pipeline + version-check: + name: Version Modification Check + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check for manual version changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-version-modification.rs + + # === SECRETS SCAN === + # Language-agnostic credential scan; runs via npx so no Node dependency is committed. + secrets-scan: + name: Secrets Scan + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-secrets-scan + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + steps: + - uses: actions/checkout@v6 + + - name: Run secretlint + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint --secretlintignore .gitignore "**/*" + + # === FRESH MERGE SIMULATION === + # Catches semantic merge conflicts: a pull request that is green in isolation but + # breaks the base branch once merged into its current tip. + fresh-merge: + name: Fresh Merge Simulation + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-fresh-merge + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-fresh-merge-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-fresh-merge- + + - name: Simulate fresh merge with base branch + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + # === DOCKER IMAGE BUILD CHECK === + # Builds the image at pull-request stage without pushing, so a Dockerfile + # regression fails the pull request instead of producing a half-finished release + # (crate published, image missing). push: false also works for fork pull requests + # that have no registry credentials. + docker-build: + name: Docker Image Build Check + runs-on: ubuntu-latest + # The image compiles the crate from scratch; 30 minutes is not enough cold. + timeout-minutes: 60 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-docker-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Check for Dockerfile + id: dockerfile + run: | + if [ -f Dockerfile ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "No Dockerfile at repository root; skipping image build check." + fi + + - name: Set up Docker Buildx + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/setup-buildx-action@v4 + + - name: Build image (no push) + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/build-push-action@v7 + with: + context: . + push: false + load: true + tags: app:pr-check + cache-from: type=gha + cache-to: type=gha,mode=max + + # === CARGO.LOCK GUARD === + # Binary crates must commit Cargo.lock so CI and release jobs resolve the same + # dependency graph every run. This also prevents cache keys based on + # hashFiles('**/Cargo.lock') from silently degrading to the empty hash. + cargo-lock: + name: Cargo.lock Guard + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-cargo-lock + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check committed Cargo.lock for binary crates + run: rust-script scripts/check-cargo-lock.rs + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changelog check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + # Note: !cancelled() is required because detect-changes is skipped on workflow_dispatch, + # and without it, this job would also be skipped even though its condition includes workflow_dispatch. + # See: https://github.com/actions/runner/issues/491 + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + ) + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-lint- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run Clippy + run: cargo clippy --all-targets --all-features + + # Warning-band annotations are limited to files this pull request changed, + # so unchanged files stop repeating the same warning on every run. + # The 1000-line hard limit still applies to the whole repository. + - name: Collect changed files + id: changed-files + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + { + echo 'files</dev/null; then + git diff --name-only "$BASE_SHA" HEAD + else + # No usable base (e.g. workflow_dispatch): fall back to annotating every warning. + git ls-files + fi + echo 'CHANGED_FILES_EOF' + } >> "$GITHUB_OUTPUT" + + - name: Build documentation + # Fail-closed rustdoc gate: rustdoc-only lints (for example + # rustdoc::private_intra_doc_links) are not reported by clippy or + # `cargo test --doc`, so they must be caught here, before release, + # instead of in the post-release deploy-docs job. + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --all-features + + - name: Check file size limit + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.files }} + run: rust-script scripts/check-file-size.rs + + # === TEST === + # Test runs independently of changelog check and only for code-affecting changes + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.os }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + # Note: !cancelled() is required because detect-changes is skipped on workflow_dispatch, + # and without it, this job would also be skipped even though its condition includes workflow_dispatch. + # See: https://github.com/actions/runner/issues/491 + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + ) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry on Windows + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-test-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test-registry- + + - name: Cache cargo registry and target on Unix + if: runner.os != 'Windows' + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test- + + - name: Run tests + run: cargo test --all-features --verbose + + - name: Run doc tests + run: cargo test --doc --verbose + + # === CODE COVERAGE === + # Generate and upload code coverage using cargo-llvm-cov + coverage: + name: Code Coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-coverage + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' + ) + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-coverage- + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate code coverage + run: cargo llvm-cov --all-features --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v7 + with: + token: ${{ env.CODECOV_TOKEN }} + files: lcov.info + disable_search: true + fail_ci_if_error: true + + - name: Report skipped Codecov upload + if: env.CODECOV_TOKEN == '' + run: echo "::notice::Skipping Codecov upload because CODECOV_TOKEN is not configured" + + # === BUILD === + # Build package - only runs if lint and test pass + build: + name: Build Package + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [lint, test] + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Build release + run: cargo build --release --verbose + + - name: Check package + run: cargo package --list --allow-dirty + + - name: Check crate package size + run: rust-script scripts/check-crate-size.rs + + # === AUTO RELEASE === + # Automatic release on push to main using changelog fragments + # This job automatically bumps version based on fragments in changelog.d/ + auto-release: + name: Auto Release + needs: [lint, test, build] + outputs: + docker_enabled: ${{ steps.dockerhub.outputs.enabled }} + release_version: ${{ steps.release-metadata.outputs.version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + # Note: !cancelled() ensures consistent behavior with other jobs that depend on skipped jobs. + if: | + !cancelled() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.build.result == 'success' + runs-on: ubuntu-latest + # Covers the crate build, publish, smoke test, and a cold Docker build. + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Configure git + run: rust-script scripts/git-config.rs + + - name: Determine bump type from changelog fragments + id: bump_type + run: rust-script scripts/get-bump-type.rs + + - name: Check if version already released or no fragments + id: check + env: + HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: rust-script scripts/check-release-needed.rs + + - name: Collect changelog and bump version + id: version + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true' + run: | + rust-script scripts/version-and-commit.rs \ + --bump-type "${{ steps.bump_type.outputs.bump_type }}" + + - name: Get current version + id: current_version + if: steps.check.outputs.should_release == 'true' + run: rust-script scripts/get-version.rs + + - name: Build release + if: steps.check.outputs.should_release == 'true' + run: cargo build --release + + - name: Check crate package size + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + run: rust-script scripts/check-crate-size.rs + + - name: Publish to Crates.io + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Smoke-test published crate + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: rust-script scripts/smoke-test-published-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Configure Docker Hub publishing + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + id: dockerhub + run: | + disable_dockerhub() { + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "$1" + } + + if [ -z "$DOCKERHUB_IMAGE" ]; then + disable_dockerhub "Docker Hub publishing disabled: DOCKERHUB_IMAGE repository variable is not set" + exit 0 + fi + + if [ ! -f Dockerfile ]; then + disable_dockerhub "Docker Hub publishing disabled: Dockerfile was not found at repository root" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "docker_hub_url=https://hub.docker.com/r/${DOCKERHUB_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Resolve release metadata + id: release-metadata + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: | + release_version="${{ steps.version.outputs.new_version }}" + if [ -z "$release_version" ]; then + release_version="${{ steps.current_version.outputs.version }}" + fi + echo "version=$release_version" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_HUB_URL: ${{ steps.dockerhub.outputs.docker_hub_url }} + run: | + release_args=( + --release-version "${{ steps.release-metadata.outputs.version }}" + --repository "${{ github.repository }}" + ) + if [ -n "$DOCKER_HUB_URL" ]; then + release_args+=(--docker-hub-url "$DOCKER_HUB_URL") + fi + rust-script scripts/create-github-release.rs "${release_args[@]}" + + # === MANUAL INSTANT RELEASE === + # Manual release via workflow_dispatch - only after CI passes + manual-release: + name: Instant Release + needs: [lint, test, build] + outputs: + docker_enabled: ${{ steps.dockerhub.outputs.enabled }} + release_version: ${{ steps.version.outputs.new_version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + # Note: !cancelled() is required to evaluate the condition when dependencies may be skipped. + # The build job ensures lint and test passed before this job runs. + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.build.result == 'success' + runs-on: ubuntu-latest + # Covers the crate build, publish, smoke test, and a cold Docker build. + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Configure git + run: rust-script scripts/git-config.rs + + - name: Collect changelog fragments + run: rust-script scripts/collect-changelog.rs + + - name: Version and commit + id: version + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/version-and-commit.rs --bump-type "$BUMP_TYPE" --description "$DESCRIPTION" + + - name: Build release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: cargo build --release + + - name: Check crate package size + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: rust-script scripts/check-crate-size.rs + + - name: Publish to Crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Smoke-test published crate + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + run: rust-script scripts/smoke-test-published-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Configure Docker Hub publishing + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + id: dockerhub + run: | + disable_dockerhub() { + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "$1" + } + + if [ -z "$DOCKERHUB_IMAGE" ]; then + disable_dockerhub "Docker Hub publishing disabled: DOCKERHUB_IMAGE repository variable is not set" + exit 0 + fi + + if [ ! -f Dockerfile ]; then + disable_dockerhub "Docker Hub publishing disabled: Dockerfile was not found at repository root" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "docker_hub_url=https://hub.docker.com/r/${DOCKERHUB_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_HUB_URL: ${{ steps.dockerhub.outputs.docker_hub_url }} + run: | + release_args=( + --release-version "${{ steps.version.outputs.new_version }}" + --repository "${{ github.repository }}" + ) + if [ -n "$DOCKER_HUB_URL" ]; then + release_args+=(--docker-hub-url "$DOCKER_HUB_URL") + fi + rust-script scripts/create-github-release.rs "${release_args[@]}" + + # Build each architecture on a native runner. Publishing by digest lets both + # builds run in parallel without racing to update a shared image tag. + docker-publish: + name: Publish Docker / ${{ matrix.platform }} + needs: [auto-release, manual-release] + if: | + !cancelled() && + (needs.auto-release.result == 'success' || needs.manual-release.result == 'success') && + (needs.auto-release.outputs.docker_enabled == 'true' || needs.manual-release.outputs.docker_enabled == 'true') + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.auto-release.outputs.release_version || needs.manual-release.outputs.release_version }} + steps: + - uses: actions/checkout@v6 + with: + ref: v${{ env.RELEASE_VERSION }} + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: ./.github/actions/setup-buildx-resilient + + - name: Build and push architecture digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + labels: org.opencontainers.image.version=${{ env.RELEASE_VERSION }} + outputs: type=image,name=${{ env.DOCKERHUB_IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v6 + with: + name: docker-digest-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-merge-manifest: + name: Publish Docker manifest + needs: [auto-release, manual-release, docker-publish] + if: | + !cancelled() && + needs.docker-publish.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.auto-release.outputs.release_version || needs.manual-release.outputs.release_version }} + steps: + - name: Download digests + uses: actions/download-artifact@v7 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Create multi-architecture manifest + working-directory: /tmp/digests + run: | + mapfile -t digests < <(printf '${DOCKERHUB_IMAGE}@sha256:%s\n' *) + docker buildx imagetools create \ + --tag "${DOCKERHUB_IMAGE}:latest" \ + --tag "${DOCKERHUB_IMAGE}:${RELEASE_VERSION}" \ + "${digests[@]}" + + - name: Verify manifest platforms + run: | + manifest="$(docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${RELEASE_VERSION}")" + grep -F 'linux/amd64' <<< "$manifest" + grep -F 'linux/arm64' <<< "$manifest" + + # === MANUAL CHANGELOG PR === + changelog-pr: + name: Create Changelog PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Create changelog fragment + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/create-changelog-fragment.rs --bump-type "$BUMP_TYPE" --description "$DESCRIPTION" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release' + branch: changelog-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changelog fragment in this PR + 2. Merge this PR to main + 3. The automated release workflow will publish to crates.io and create a GitHub release + + # === DEPLOY DOCUMENTATION === + # Deploy Rust API documentation to GitHub Pages after a successful package build. + # Keep this independent from package/GitHub release publication so the website + # still updates when the release path fails. Use the official Pages artifact + # deployment path so repositories configured with "GitHub Actions" as their + # Pages source fail this job if Pages cannot deploy. + # + # One-time setup: in the repository's Settings -> Pages, set Source to + # "GitHub Actions". Without this, the first run fails on actions/deploy-pages + # with "Get Pages site failed" / "Failed to create deployment". This cannot be + # configured from a workflow. See README.md "Deploying API documentation". + deploy-docs: + name: Deploy Rust Documentation + needs: [build] + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + if: | + !cancelled() && + needs.build.result == 'success' && ( + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant') + ) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v6 + with: + ref: main + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build documentation + # Defense in depth: same fail-closed flags as the lint job's gate. + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --all-features + + - name: Generate Pages root index + run: | + # rustdoc emits target/doc//index.html but no root index.html, + # which makes the GitHub Pages root URL return 404. Redirect the root + # to the crate docs directory that rustdoc generated. + crate=$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["name"].replace("-","_"))') + printf '\n' "$crate" \ + > target/doc/index.html + touch target/doc/.nojekyll + + - name: Add desktop download page + run: | + mkdir -p target/doc/download + sed 's#__REPOSITORY__#${{ github.repository }}#g' \ + docs/download/index.html > target/doc/download/index.html + + - name: Configure GitHub Pages + uses: actions/configure-pages@v6 + + - name: Verify site tree + run: find target/doc -maxdepth 2 -print + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: target/doc + include-hidden-files: true + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + # === PIPELINE STATUS === + # GitHub reports jobs killed by timeout-minutes as cancelled. Observe every + # job so a timeout on main becomes a visible failure instead of a grey run. + pipeline-status: + name: Pipeline Status + needs: + - detect-changes + - changelog + - version-check + - secrets-scan + - fresh-merge + - docker-build + - cargo-lock + - lint + - test + - coverage + - build + - auto-release + - manual-release + - docker-publish + - docker-merge-manifest + - changelog-pr + - deploy-docs + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - name: Check aggregate pipeline status + env: + NEEDS_JSON: ${{ toJSON(needs) }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + run: bash scripts/check-pipeline-status.sh diff --git a/dev/log/issues/96/pulls/97/templates/rust/security.yml b/dev/log/issues/96/pulls/97/templates/rust/security.yml new file mode 100644 index 0000000..ab7cd08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/security.yml @@ -0,0 +1,74 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +# Keep the workflow read-only unless a job explicitly needs an additional scope. +permissions: + contents: read + +jobs: + cargo-audit: + name: Cargo audit + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-cargo-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit@0.22.2 + + - name: Audit committed Cargo.lock + run: cargo audit --file Cargo.lock + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [rust, actions] + steps: + - uses: actions/checkout@v6 + + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - uses: github/codeql-action/autobuild@v4 + + - uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/dev/log/issues/96/pulls/97/workflows/repo/csharp.yml b/dev/log/issues/96/pulls/97/workflows/repo/csharp.yml new file mode 100644 index 0000000..f90ae2d --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/csharp.yml @@ -0,0 +1,519 @@ +name: C# CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'csharp/**' + - 'csharp/scripts/**' + - '.github/workflows/csharp.yml' + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: csharp-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +defaults: + run: + working-directory: csharp + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + cs-changed: ${{ steps.changes.outputs.cs-changed }} + csproj-changed: ${{ steps.changes.outputs.csproj-changed }} + sln-changed: ${{ steps.changes.outputs.sln-changed }} + props-changed: ${{ steps.changes.outputs.props-changed }} + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + csharp-code-changed: ${{ steps.changes.outputs.csharp-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Detect changes + id: changes + working-directory: . + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node csharp/scripts/detect-code-changes.mjs + + # === CHANGESET CHECK - only runs on PRs with code changes === + changeset-check: + name: Changeset Validation + runs-on: ubuntu-latest + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.csharp-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Validate changeset + working-directory: . + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Skip changeset check for automated release PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-manual-release-"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Check if changeset exists in csharp/.changeset + CHANGESET_COUNT=$(find csharp/.changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + if [ "$CHANGESET_COUNT" -eq 0 ]; then + echo "::warning::No changeset found in csharp/.changeset/. Please add a changeset for C# code changes." + else + echo "Found $CHANGESET_COUNT changeset file(s)" + fi + + # === LINT AND FORMAT CHECK === + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.cs-changed == 'true' || + needs.detect-changes.outputs.csproj-changed == 'true' || + needs.detect-changes.outputs.sln-changed == 'true' || + needs.detect-changes.outputs.props-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Run release script tests + working-directory: . + run: node --test csharp/scripts/*.test.mjs + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + # === TEST ON MULTIPLE OS === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [detect-changes, changeset-check] + if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Run tests + # Windows has pre-existing file locking issues with some tests + continue-on-error: ${{ matrix.os == 'windows-latest' }} + run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: false + + # === BUILD PACKAGE === + build: + name: Build Package + runs-on: ubuntu-latest + needs: [lint, test] + if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build Release + run: dotnet build --no-restore --configuration Release + + - name: Pack NuGet package + run: dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: csharp/artifacts/*.nupkg + + # === AUTOMATIC RELEASE === + release: + name: Release + needs: [lint, test, build] + if: always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Check for changesets + id: check_changesets + working-directory: . + run: | + # Count changeset files (excluding README.md and config.json) + CHANGESET_COUNT=$(find csharp/.changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT + + - name: Check if release is needed + # Self-healing gate: even when a changeset is absent, resume publishing + # if the csproj is missing on NuGet or its GitHub release does + # not exist. See docs/case-studies/issue-84/README.md and the JS + # template's check-release-needed.mjs for the same pattern. + id: check_release + working-directory: . + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node csharp/scripts/check-release-needed.mjs + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + working-directory: . + run: | + echo "Multiple changesets detected, merging..." + node csharp/scripts/merge-changesets.mjs \ + --dir csharp/.changeset \ + --package-name Foundation.Data.Doublets.Cli + + - name: Version and commit + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + working-directory: . + run: node csharp/scripts/version-and-commit.mjs --mode changeset + + - name: Resolve release version + # Picks the version that downstream steps should publish. Prefers the + # one just committed; falls back to the csproj reported by + # check-release-needed for self-healing re-runs. + id: release_version + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + run: | + if [ -n "${{ steps.version.outputs.new_version }}" ]; then + VERSION="${{ steps.version.outputs.new_version }}" + else + VERSION="${{ steps.check_release.outputs.current_version }}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing version: $VERSION" + + - name: Build release package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package ids + id: package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + run: | + CLI_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj | head -n 1) + if [ -z "$CLI_ID" ]; then + CLI_ID=clink + fi + LIB_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli.Library/Foundation.Data.Doublets.Cli.Library.csproj | head -n 1) + if [ -z "$LIB_ID" ]; then + LIB_ID=Foundation.Data.Doublets.Cli + fi + echo "id=$CLI_ID" >> "$GITHUB_OUTPUT" + echo "library_id=$LIB_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + # Upfront validation surfaces an expired/invalid NUGET_API_KEY before + # we attempt a push that would otherwise return HTTP 403 mid-flight. + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify CLI package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true')) + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Verify library package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true')) + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: . + run: | + node csharp/scripts/create-github-release.mjs \ + --release-version "${{ steps.release_version.outputs.version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "csharp-v" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --changelog-path "csharp/CHANGELOG.md" \ + --assets-glob "csharp/artifacts/*.nupkg" + + # === MANUAL INSTANT RELEASE === + instant-release: + name: Instant Release + needs: [lint, test, build] + if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Version and commit + id: version + working-directory: . + run: | + node csharp/scripts/version-and-commit.mjs \ + --mode instant \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" + + - name: Build package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package ids + id: package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + run: | + CLI_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj | head -n 1) + if [ -z "$CLI_ID" ]; then + CLI_ID=clink + fi + LIB_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli.Library/Foundation.Data.Doublets.Cli.Library.csproj | head -n 1) + if [ -z "$LIB_ID" ]; then + LIB_ID=Foundation.Data.Doublets.Cli + fi + echo "id=$CLI_ID" >> "$GITHUB_OUTPUT" + echo "library_id=$LIB_ID" >> "$GITHUB_OUTPUT" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify CLI package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Verify library package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: . + run: | + node csharp/scripts/create-github-release.mjs \ + --release-version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "csharp-v" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --changelog-path "csharp/CHANGELOG.md" \ + --assets-glob "csharp/artifacts/*.nupkg" diff --git a/dev/log/issues/96/pulls/97/workflows/repo/docs.yml b/dev/log/issues/96/pulls/97/workflows/repo/docs.yml new file mode 100644 index 0000000..de17de0 --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/docs.yml @@ -0,0 +1,159 @@ +name: Docs + +# Build and deploy a unified API reference site that hosts both the C# +# (`Foundation.Data.Doublets.Cli`) and Rust (`link-cli`) library docs. +# +# GitHub Pages allows only one deployment per repository, so this workflow +# combines DocFX-generated C# docs (under `/csharp/`) and `cargo doc`-generated +# Rust docs (under `/rust/`) into a single site that also includes a small +# landing page linking out to both. The deploy job runs on pushes to `main` +# and on manual dispatch, mirroring the AI driven development pipeline +# templates' approach (see docs/case-studies/issue-92/templates). + +on: + push: + branches: [main] + paths: + - 'csharp/Foundation.Data.Doublets.Cli.Library/**' + - 'csharp/docs/**' + - 'csharp/docfx.json' + - 'rust/src/**' + - 'rust/Cargo.toml' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'csharp/Foundation.Data.Doublets.Cli.Library/**' + - 'csharp/docs/**' + - 'csharp/docfx.json' + - 'rust/src/**' + - 'rust/Cargo.toml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-docs-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-docs- + + - name: Install DocFX + run: dotnet tool update -g docfx + + - name: Restore C# dependencies + working-directory: csharp + run: dotnet restore + + - name: Build C# documentation + working-directory: csharp + run: docfx docfx.json -o _site + + - name: Build Rust documentation + run: cargo doc --manifest-path rust/Cargo.toml --no-deps --all-features + + - name: Assemble unified site + run: | + set -euo pipefail + mkdir -p _site/csharp _site/rust + cp -R csharp/_site/. _site/csharp/ + cp -R rust/target/doc/. _site/rust/ + # Landing page that links into both sub-sites. + cat > _site/index.html <<'HTML' + + + + + link-cli API documentation + + + + +

link-cli API documentation

+

Generated reference for the C# and Rust library packages that + ship alongside the clink CLI.

+ +

Source: github.com/link-foundation/link-cli

+ + + HTML + + - name: List unified site (debug) + run: | + echo "::group::_site tree" + find _site -maxdepth 3 -print + echo "::endgroup::" + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + uses: actions/upload-pages-artifact@v5 + with: + path: _site + + deploy: + name: Deploy to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + - name: Print resolved deployment URL (debug) + run: | + echo "Pages deployed to: ${{ steps.deployment.outputs.page_url }}" diff --git a/dev/log/issues/96/pulls/97/workflows/repo/rust.yml b/dev/log/issues/96/pulls/97/workflows/repo/rust.yml new file mode 100644 index 0000000..26d24bf --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/rust.yml @@ -0,0 +1,356 @@ +name: Rust CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'rust/**' + - 'rust/scripts/**' + - '.github/workflows/rust.yml' + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: rust-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + rs-changed: ${{ steps.changes.outputs.rs-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + rust-code-changed: ${{ steps.changes.outputs.rust-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: rust-script rust/scripts/detect-code-changes.rs + + # === CHANGELOG CHECK - only runs on PRs with code changes === + changelog: + name: Changelog Fragment Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.rust-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + RUST_ROOT: rust + run: rust-script rust/scripts/check-changelog-fragment.rs + + # === LINT AND FORMAT CHECK === + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install rust-script + run: cargo install rust-script + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --manifest-path rust/Cargo.toml --all -- --check + + - name: Run Clippy + run: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features + + - name: Check file size limit + run: rust-script rust/scripts/check-file-size.rs + + # === TEST === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [detect-changes, changelog] + if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run tests + run: cargo test --manifest-path rust/Cargo.toml --all-features --verbose + + - name: Run doc tests + run: cargo test --manifest-path rust/Cargo.toml --doc --verbose + + # === BUILD === + build: + name: Build Package + runs-on: ubuntu-latest + needs: [lint, test] + if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Build release + run: cargo build --manifest-path rust/Cargo.toml --release --verbose + + - name: Check package + run: cargo package --manifest-path rust/Cargo.toml --list --allow-dirty + + # === AUTO RELEASE === + auto-release: + name: Auto Release + needs: [lint, test, build] + if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Configure git + run: rust-script rust/scripts/git-config.rs + + - name: Determine bump type from changelog fragments + id: bump_type + env: + RUST_ROOT: rust + run: rust-script rust/scripts/get-bump-type.rs + + - name: Check if version already released or no fragments + id: check + env: + HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUST_ROOT: rust + run: | + rust-script rust/scripts/check-release-needed.rs \ + --tag-prefix rust-v \ + --repository "${{ github.repository }}" + + - name: Collect changelog and bump version + id: version + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true' + working-directory: . + run: | + rust-script rust/scripts/version-and-commit.rs \ + --rust-root rust \ + --tag-prefix rust-v \ + --bump-type "${{ steps.bump_type.outputs.bump_type }}" + + - name: Get current version + id: current_version + if: steps.check.outputs.should_release == 'true' + env: + RUST_ROOT: rust + run: rust-script rust/scripts/get-version.rs + + - name: Build release + if: steps.check.outputs.should_release == 'true' + run: cargo build --manifest-path rust/Cargo.toml --release + + - name: Publish to Crates.io + id: publish_crate + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + RUST_ROOT: rust + run: rust-script rust/scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: steps.check.outputs.should_release == 'true' + env: + RUST_ROOT: rust + run: rust-script rust/scripts/wait-for-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Create GitHub Release + if: steps.check.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RELEASE_VERSION="${{ steps.version.outputs.new_version }}" + if [ -z "$RELEASE_VERSION" ]; then + RELEASE_VERSION="${{ steps.current_version.outputs.version }}" + fi + + rust-script rust/scripts/create-github-release.rs \ + --rust-root rust \ + --release-version "$RELEASE_VERSION" \ + --repository "${{ github.repository }}" \ + --tag-prefix "rust-v" \ + --language "Rust" + + # === MANUAL RELEASE === + manual-release: + name: Manual Release + needs: [lint, test, build] + if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Configure git + run: rust-script rust/scripts/git-config.rs + + - name: Version and commit + id: version + run: | + rust-script rust/scripts/version-and-commit.rs \ + --rust-root rust \ + --tag-prefix rust-v \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" + + - name: Build release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: cargo build --manifest-path rust/Cargo.toml --release + + - name: Publish to Crates.io + id: publish_crate + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + RUST_ROOT: rust + run: rust-script rust/scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + RUST_ROOT: rust + run: rust-script rust/scripts/wait-for-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + rust-script rust/scripts/create-github-release.rs \ + --rust-root rust \ + --release-version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "rust-v" \ + --language "Rust" diff --git a/dev/log/issues/96/pulls/97/workflows/repo/wasm.yml b/dev/log/issues/96/pulls/97/workflows/repo/wasm.yml new file mode 100644 index 0000000..663685b --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/wasm.yml @@ -0,0 +1,160 @@ +name: WebAssembly CI + +on: + push: + branches: + - main + - issue-* + paths: + - '.github/workflows/wasm.yml' + - 'js/**' + - 'rust/**' + pull_request: + branches: + - main + paths: + - '.github/workflows/wasm.yml' + - 'js/**' + - 'rust/**' + workflow_dispatch: + +concurrency: + group: wasm-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: npm + cache-dependency-path: js/package-lock.json + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + rust/wasm/target + key: ${{ runner.os }}-wasm-cargo-${{ hashFiles('rust/wasm/Cargo.lock', 'rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-wasm-cargo- + + - name: Install npm dependencies + working-directory: js + run: npm ci + + - name: Test Rust CLI core + run: cargo test --manifest-path rust/Cargo.toml --all-features + + - name: Test WebAssembly wrapper + working-directory: js + run: npm run test:wasm + + - name: Build React WebAssembly app + working-directory: js + run: npm run build + + - name: Upload built app + uses: actions/upload-artifact@v7 + with: + name: link-cli-web + path: dist/ + + build-pages: + name: Build GitHub Pages app + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: npm + cache-dependency-path: js/package-lock.json + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + rust/wasm/target + key: ${{ runner.os }}-wasm-pages-cargo-${{ hashFiles('rust/wasm/Cargo.lock', 'rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-wasm-pages-cargo- + ${{ runner.os }}-wasm-cargo- + + - name: Install npm dependencies + working-directory: js + run: npm ci + + - name: Configure Pages + uses: actions/configure-pages@v6 + + - name: Build GitHub Pages app + working-directory: js + run: npm run build:pages + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: dist/ + + deploy-pages: + name: Deploy GitHub Pages + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' + needs: build-pages + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v5 From febe53af0966d287f47e8b73305f96a975a19c99 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:47:12 +0000 Subject: [PATCH 11/21] chore(issue-96): clear RUSTSEC advisories in both Cargo.lock files 'cargo audit' reported RUSTSEC-2026-0190 (anyhow) and RUSTSEC-2026-0186 (memmap2) as unsoundness warnings for rust/ and rust/wasm/. Both are fixed in semver-compatible releases, so a plain 'cargo update -p' clears them: anyhow 1.0.102 -> 1.0.104, memmap2 0.9.10 -> 0.9.11. cargo fmt, clippy with -Dwarnings and the full test suite stay green. --- rust/Cargo.lock | 8 ++++---- rust/wasm/Cargo.lock | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4009841..3a8e8c2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -60,9 +60,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "autocfg" @@ -358,9 +358,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] diff --git a/rust/wasm/Cargo.lock b/rust/wasm/Cargo.lock index a793a2e..63e12f9 100644 --- a/rust/wasm/Cargo.lock +++ b/rust/wasm/Cargo.lock @@ -60,9 +60,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-trait" @@ -462,9 +462,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] From 9f1ec917a363ae7a04f91c9452f074de446b650c Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:53:37 +0000 Subject: [PATCH 12/21] ci(issue-96): implement advertised release modes and lock them with policy tests The C# pipeline offered a 'changeset-pr' release mode in its workflow_dispatch input that no job implemented: selecting it produced a green run that did nothing. Port the changeset-pr job from the C# pipeline template, and mirror it in the Rust pipeline as changelog-pr (with the matching release_mode input the Rust template ships). Add scripts/workflow-policy.test.mjs, which fails on the pre-fix workflows for every defect fixed in this pull request: missing timeout-minutes, missing top-level permissions, continue-on-error masking failures, unwrapped 'if: !cancelled()' and dead release modes. It runs from both the C# and the Rust lint jobs. --- .github/workflows/csharp.yml | 71 +++++++++++++++ .github/workflows/rust.yml | 80 +++++++++++++++- scripts/workflow-policy.test.mjs | 152 +++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 scripts/workflow-policy.test.mjs diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index a48c5d3..375f746 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -162,6 +162,10 @@ jobs: working-directory: . run: node --test csharp/scripts/*.test.mjs + - name: Run workflow policy tests + working-directory: . + run: node --test scripts/*.test.mjs + - name: Restore dependencies run: dotnet restore @@ -546,3 +550,70 @@ jobs: --package-id "${{ steps.package.outputs.library_id }}" \ --changelog-path "csharp/CHANGELOG.md" \ --assets-glob "csharp/artifacts/*.nupkg" + + # === MANUAL CHANGESET PR === + # The workflow_dispatch input above advertises a 'changeset-pr' release mode, + # but no job used to handle it: selecting it silently did nothing while the + # run still reported success (issue #96). This job implements the advertised + # mode - it opens a pull request carrying the changeset instead of releasing + # straight away. The branch name matches the 'changeset-manual-release-*' + # prefix that changeset-check already skips. + changeset-pr: + name: Create Changeset PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + # Writer group: never cancel a run that may already have pushed a branch. + concurrency: + group: csharp-writer-${{ github.repository }} + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create changeset file + working-directory: . + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + CHANGESET_FILE="csharp/.changeset/manual-release-${RUN_ID}.md" + DESCRIPTION_TEXT="${DESCRIPTION:-Manual ${BUMP_TYPE} release}" + { + echo '---' + echo "'Foundation.Data.Doublets.Cli': ${BUMP_TYPE}" + echo '---' + echo + echo "${DESCRIPTION_TEXT}" + } > "$CHANGESET_FILE" + echo "Created changeset: $CHANGESET_FILE" + cat "$CHANGESET_FILE" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will version, publish, and create a GitHub release diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ee9c03f..da900b2 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -12,6 +12,14 @@ on: - '.github/workflows/rust.yml' workflow_dispatch: inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changelog-pr bump_type: description: 'Version bump type' required: true @@ -147,6 +155,16 @@ jobs: - name: Check file size limit run: rust-script rust/scripts/check-file-size.rs + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + # Guards the workflow invariants restored in issue #96 (timeouts, least + # privilege, no masked failures, no dead release modes). + - name: Run workflow policy tests + run: node --test scripts/*.test.mjs + # === TEST === test: name: Test (${{ matrix.os }}) @@ -314,7 +332,7 @@ jobs: manual-release: name: Manual Release needs: [lint, test, build] - if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 60 # Writer group: never cancel a release that may already have published a @@ -378,3 +396,63 @@ jobs: --repository "${{ github.repository }}" \ --tag-prefix "rust-v" \ --language "Rust" + + # === MANUAL CHANGELOG PR === + # Mirrors the C# 'changeset-pr' mode and the Rust pipeline template: instead of + # releasing straight away, open a pull request carrying the changelog fragment + # so the release goes through the normal review path (issue #96). + changelog-pr: + name: Create Changelog PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr' + runs-on: ubuntu-latest + timeout-minutes: 15 + # Writer group: never cancel a run that may already have pushed a branch. + concurrency: + group: rust-writer-${{ github.repository }} + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Create changelog fragment + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: | + rust-script rust/scripts/create-changelog-fragment.rs \ + --rust-root rust \ + --bump-type "$BUMP_TYPE" \ + --description "$DESCRIPTION" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release' + branch: changelog-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changelog fragment in this PR + 2. Merge this PR to main + 3. The automated release workflow will publish to crates.io and create a GitHub release diff --git a/scripts/workflow-policy.test.mjs b/scripts/workflow-policy.test.mjs new file mode 100644 index 0000000..1f133e2 --- /dev/null +++ b/scripts/workflow-policy.test.mjs @@ -0,0 +1,152 @@ +// Regression tests for the CI/CD invariants restored in issue #96. +// +// These run without any third-party dependency (the lint jobs call them with a +// bare `node --test`), so the workflows are inspected with a small line-based +// scanner instead of a YAML parser. Every rule below encodes a defect that was +// actually present in this repository at some point. + +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const workflowsDir = join(repoRoot, '.github', 'workflows'); + +const workflows = readdirSync(workflowsDir) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .map((name) => ({ + name, + path: join(workflowsDir, name), + text: readFileSync(join(workflowsDir, name), 'utf8'), + })); + +test('there is at least one workflow to inspect', () => { + assert.ok(workflows.length > 0, `no workflows found in ${workflowsDir}`); +}); + +/** Splits a workflow into `{ name, body }` entries, one per job. */ +function readJobs(text) { + const lines = text.split('\n'); + const jobsStart = lines.findIndex((line) => line === 'jobs:'); + if (jobsStart === -1) { + return []; + } + const jobs = []; + let current = null; + for (const line of lines.slice(jobsStart + 1)) { + if (/^[A-Za-z]/.test(line)) { + break; // back to a top-level key, the jobs mapping ended + } + const header = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line); + if (header) { + current = { name: header[1], body: [] }; + jobs.push(current); + continue; + } + if (current) { + current.body.push(line); + } + } + return jobs.map((job) => ({ name: job.name, body: job.body.join('\n') })); +} + +/** Reads the `options:` of a `workflow_dispatch` choice input. */ +function readChoiceOptions(text, inputName) { + const lines = text.split('\n'); + const start = lines.findIndex((line) => line.trim() === `${inputName}:`); + if (start === -1) { + return []; + } + const indent = lines[start].length - lines[start].trimStart().length; + const options = []; + let inOptions = false; + for (const line of lines.slice(start + 1)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const lineIndent = line.length - line.trimStart().length; + if (lineIndent <= indent) break; // left the input definition + if (trimmed === 'options:') { + inOptions = true; + continue; + } + if (inOptions) { + const item = /^-\s+(.+?)\s*$/.exec(trimmed); + if (!item) break; + options.push(item[1].replace(/^['"]|['"]$/g, '')); + } + } + return options; +} + +for (const workflow of workflows) { + // A job without a timeout can hang for the runner's six-hour default and + // burn the whole CI budget before anyone notices. + test(`${workflow.name}: every job declares timeout-minutes`, () => { + for (const job of readJobs(workflow.text)) { + assert.match( + job.body, + /^ {4}timeout-minutes: \d+$/m, + `job "${job.name}" in ${workflow.name} has no timeout-minutes`, + ); + } + }); + + // Least privilege: the default token must be read-only unless a job opts in. + test(`${workflow.name}: declares a top-level permissions block`, () => { + assert.match( + workflow.text, + /^permissions:$/m, + `${workflow.name} does not declare top-level permissions`, + ); + }); + + // Every job must be covered by a concurrency group, either its own (writers) + // or the workflow-level one (readers), so superseded runs do not pile up. + test(`${workflow.name}: every job is covered by a concurrency group`, () => { + const hasWorkflowLevel = /^concurrency:$/m.test(workflow.text); + for (const job of readJobs(workflow.text)) { + assert.ok( + hasWorkflowLevel || /^ {4}concurrency:$/m.test(job.body), + `job "${job.name}" in ${workflow.name} has no concurrency group`, + ); + } + }); + + // `continue-on-error` turns a real failure into a green run - the exact + // false negative that hid the Windows test failures reported in issue #96. + test(`${workflow.name}: does not mask failures with continue-on-error`, () => { + assert.ok( + !/continue-on-error/.test(workflow.text), + `${workflow.name} uses continue-on-error, which masks real failures`, + ); + }); + + // In YAML a leading `!` starts a tag, so `if: !cancelled() && ...` is a + // parse error. The expression has to be wrapped in ${{ }} on a single line. + test(`${workflow.name}: single-line if expressions do not start with !`, () => { + const offenders = workflow.text + .split('\n') + .map((line, index) => ({ line, number: index + 1 })) + .filter(({ line }) => /^\s*if:\s*!/.test(line)); + assert.deepEqual( + offenders.map(({ number }) => number), + [], + `${workflow.name} has unwrapped "if: !..." expressions (wrap them in \${{ }})`, + ); + }); + + // Every advertised release mode must be handled by a job. Before issue #96 + // the C# pipeline offered a "changeset-pr" mode that no job implemented, so + // selecting it produced a successful run that did nothing at all. + test(`${workflow.name}: every release_mode option is handled by a job`, () => { + const modes = readChoiceOptions(workflow.text, 'release_mode'); + for (const mode of modes) { + assert.ok( + workflow.text.includes(`release_mode == '${mode}'`), + `${workflow.name} offers release_mode "${mode}" but no job guards on it`, + ); + } + }); +} From c2c254d18a58a1deaa14021d6081f06df3ef63c3 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:56:33 +0000 Subject: [PATCH 13/21] ci(issue-96): run the JavaScript tests in CI and stop duplicate wasm runs js/test/*.test.mjs was never invoked by any workflow, so its repository layout test had been failing unnoticed on a tracked root .gitkeep. Run 'npm run test:js' from the WebAssembly workflow, delete the stray .gitkeep, and move the repository-wide CI helpers under .github/scripts so they respect the same layout rule (a root scripts/ directory is forbidden by that test). Also drop 'issue-*' from the wasm push trigger: combined with the pull_request trigger it ran the whole workflow twice for every push to an issue branch. Both invariants are now asserted by .github/scripts/workflow-policy.test.mjs. --- .../scripts}/check-web-archive.mjs | 0 .../scripts}/workflow-policy.test.mjs | 46 ++++++++++++++++++- .github/workflows/csharp.yml | 2 +- .github/workflows/links.yml | 2 +- .github/workflows/rust.yml | 2 +- .github/workflows/wasm.yml | 10 +++- .gitkeep | 1 - 7 files changed, 57 insertions(+), 6 deletions(-) rename {scripts => .github/scripts}/check-web-archive.mjs (100%) rename {scripts => .github/scripts}/workflow-policy.test.mjs (74%) delete mode 100644 .gitkeep diff --git a/scripts/check-web-archive.mjs b/.github/scripts/check-web-archive.mjs similarity index 100% rename from scripts/check-web-archive.mjs rename to .github/scripts/check-web-archive.mjs diff --git a/scripts/workflow-policy.test.mjs b/.github/scripts/workflow-policy.test.mjs similarity index 74% rename from scripts/workflow-policy.test.mjs rename to .github/scripts/workflow-policy.test.mjs index 1f133e2..2dc79a2 100644 --- a/scripts/workflow-policy.test.mjs +++ b/.github/scripts/workflow-policy.test.mjs @@ -11,7 +11,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const workflowsDir = join(repoRoot, '.github', 'workflows'); const workflows = readdirSync(workflowsDir) @@ -80,6 +80,50 @@ function readChoiceOptions(text, inputName) { return options; } +// A workflow that lists PR branches under `push:` while also reacting to +// `pull_request:` runs twice for every push to such a branch, doubling CI cost +// and producing duplicate status checks (issue #96). +test('no workflow reacts to both pull_request and non-main push branches', () => { + for (const workflow of workflows) { + if (!/^ {2}pull_request:$/m.test(workflow.text)) continue; + const lines = workflow.text.split('\n'); + const pushIndex = lines.findIndex((line) => line === ' push:'); + if (pushIndex === -1) continue; + const branches = []; + let inBranches = false; + for (const line of lines.slice(pushIndex + 1)) { + if (line.trim() === '') continue; + const indent = line.length - line.trimStart().length; + if (indent <= 2) break; // left the push trigger + if (indent === 4) { + inBranches = line.trim() === 'branches:'; + continue; + } + if (inBranches) { + const item = /^-\s+(.+?)\s*$/.exec(line.trim()); + if (item) branches.push(item[1].replace(/^['"]|['"]$/g, '')); + } + } + const extraneous = branches.filter((branch) => branch !== 'main'); + assert.deepEqual( + extraneous, + [], + `${workflow.name} triggers on both pull_request and push to ${extraneous.join(', ')}, so PR branches run twice`, + ); + } +}); + +// Tests that never run in CI are the purest false negative: js/test held a +// failing repository-layout test for months because no workflow invoked it. +test('the JavaScript unit tests are executed by a workflow', () => { + const jsTests = readdirSync(join(repoRoot, 'js', 'test')).filter((name) => name.endsWith('.test.mjs')); + assert.ok(jsTests.length > 0, 'expected JavaScript tests under js/test'); + assert.ok( + workflows.some((workflow) => /npm run test:js/.test(workflow.text)), + 'no workflow runs "npm run test:js", so js/test/*.test.mjs never executes in CI', + ); +}); + for (const workflow of workflows) { // A job without a timeout can hang for the runner's six-hour default and // burn the whole CI budget before anyone notices. diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 375f746..66f806e 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -164,7 +164,7 @@ jobs: - name: Run workflow policy tests working-directory: . - run: node --test scripts/*.test.mjs + run: node --test .github/scripts/*.test.mjs - name: Restore dependencies run: dotnet restore diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index fcd8b9b..8ed5203 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -73,7 +73,7 @@ jobs: - name: Check broken links against Web Archive if: steps.lychee.outputs.exit_code != 0 id: webarchive - run: node scripts/check-web-archive.mjs + run: node .github/scripts/check-web-archive.mjs env: LYCHEE_OUTPUT: lychee/out.md diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index da900b2..0a19169 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -163,7 +163,7 @@ jobs: # Guards the workflow invariants restored in issue #96 (timeouts, least # privilege, no masked failures, no dead release modes). - name: Run workflow policy tests - run: node --test scripts/*.test.mjs + run: node --test .github/scripts/*.test.mjs # === TEST === test: diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index f318d55..3df61cc 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -1,10 +1,12 @@ name: WebAssembly CI on: + # Only main on push: PR branches are covered by the pull_request trigger, and + # listing them here made every push to an issue-* branch run this workflow + # twice (issue #96). push: branches: - main - - issue-* paths: - '.github/workflows/wasm.yml' - 'js/**' @@ -72,6 +74,12 @@ jobs: working-directory: js run: npm run test:wasm + # js/test/*.test.mjs existed but no workflow ever ran it, so repository + # layout regressions passed CI unnoticed (issue #96). + - name: Test JavaScript + working-directory: js + run: npm run test:js + - name: Build React WebAssembly app working-directory: js run: npm run build diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index b55727e..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-08-18T12:54:40.512Z for PR creation at branch issue-96-df5aa0703ffa for issue https://github.com/link-foundation/link-cli/issues/96 \ No newline at end of file From c684a13f0bd5f63288927c27f4ff96ad3de50148 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:57:32 +0000 Subject: [PATCH 14/21] ci(issue-96): fail the Rust lint job on Cargo.lock drift A Cargo.toml dependency bump without a matching Cargo.lock update used to pass CI while every job re-resolved the dependency graph on the fly. 'cargo metadata --locked' turns that drift into an explicit failure for both the CLI crate and the WebAssembly crate. --- .github/workflows/rust.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0a19169..6281f4f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -155,6 +155,14 @@ jobs: - name: Check file size limit run: rust-script rust/scripts/check-file-size.rs + # A dependency bump in Cargo.toml without the matching Cargo.lock update + # otherwise passes CI while every job silently re-resolves the graph + # (issue #96). --locked turns that drift into a failure. + - name: Verify Cargo.lock is up to date + run: | + cargo metadata --locked --format-version 1 --manifest-path rust/Cargo.toml > /dev/null + cargo metadata --locked --format-version 1 --manifest-path rust/wasm/Cargo.toml > /dev/null + - name: Setup Node.js uses: actions/setup-node@v6 with: From fe82ee0841c2a142e7713164017a9c5cca2f44c0 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 13:57:54 +0000 Subject: [PATCH 15/21] chore(issue-96): add changeset and changelog fragment for the CI/CD hardening --- csharp/.changeset/issue-96-cicd-hardening.md | 13 +++++++++++++ .../20260818_120000_issue_96_cicd_hardening.md | 10 ++++++++++ 2 files changed, 23 insertions(+) create mode 100644 csharp/.changeset/issue-96-cicd-hardening.md create mode 100644 rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md diff --git a/csharp/.changeset/issue-96-cicd-hardening.md b/csharp/.changeset/issue-96-cicd-hardening.md new file mode 100644 index 0000000..5cd250c --- /dev/null +++ b/csharp/.changeset/issue-96-cicd-hardening.md @@ -0,0 +1,13 @@ +--- +'Foundation.Data.Doublets.Cli': patch +--- + +Hardened the C# build and the pipelines around it (issue #96). +`Directory.Build.props` now turns warnings into errors and enables the +.NET analyzers, and `TransactionsDecorator` / `VersionControlDecorator` +implement `IDisposable` so the memory-mapped databases they own are +released deterministically — the leak that made the Windows test job +fail while the pipeline still reported success. The C# workflow no +longer masks those Windows failures with `continue-on-error`, verifies +formatting and file sizes, and finally implements the `changeset-pr` +release mode it had been advertising without handling. diff --git a/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md b/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md new file mode 100644 index 0000000..38c3551 --- /dev/null +++ b/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md @@ -0,0 +1,10 @@ +--- +bump: patch +--- + +Hardened the Rust pipeline (issue #96). Every job now declares a +timeout and a least-privilege token, release jobs run in a +non-cancellable writer concurrency group, the advertised +`changelog-pr` release mode is implemented, and the lint job fails on +`Cargo.lock` drift. `anyhow` and `memmap2` were refreshed in both +lockfiles to clear the outstanding RUSTSEC advisories. From b03804eceed7f0aac6540ef336899ca09c207a10 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 14:07:05 +0000 Subject: [PATCH 16/21] fix(ci): publish one GitHub Pages site and stop misreporting links GitHub Pages serves a single site per repository. docs.yml and wasm.yml both uploaded a Pages artifact, so whichever finished last replaced the other one's files; the API reference URLs advertised in README.md answered 404: https://link-foundation.github.io/link-cli/ -> 200 https://link-foundation.github.io/link-cli/csharp/ -> 404 https://link-foundation.github.io/link-cli/rust/link_cli/ -> 404 docs.yml is now the only publisher: it builds the WebAssembly workbench (site root) together with the DocFX and rustdoc output (/csharp/, /rust/) and an API landing page (/docs/). wasm.yml keeps only its test job. Two policy tests pin the single-publisher invariant. check-web-archive.mjs scanned the whole lychee report, so bullets under "## Redirects per input" were escalated as broken links: run 32145481148 reported 9 unrecoverable links while lychee itself reported 4 errors. It now parses only the "Errors per input" section. Conversely, lychee errors that are not http URLs (a missing DocFX file, an unresolvable root-relative link) were dropped and the step reported all_archived=true, turning a real failure green; those are now reported and fail the job. Both defects are covered by check-web-archive.test.mjs against a report captured from that run. Refs #96 --- .github/scripts/check-web-archive.mjs | 121 +++++++++++++++------ .github/scripts/check-web-archive.test.mjs | 69 ++++++++++++ .github/scripts/fixtures/lychee-report.md | 39 +++++++ .github/scripts/workflow-policy.test.mjs | 27 +++++ .github/workflows/docs.yml | 60 +++++++--- .github/workflows/links.yml | 1 + .github/workflows/wasm.yml | 87 +-------------- .lycheeignore | 10 ++ README.md | 2 +- 9 files changed, 281 insertions(+), 135 deletions(-) create mode 100644 .github/scripts/check-web-archive.test.mjs create mode 100644 .github/scripts/fixtures/lychee-report.md diff --git a/.github/scripts/check-web-archive.mjs b/.github/scripts/check-web-archive.mjs index 2b8244d..463058b 100644 --- a/.github/scripts/check-web-archive.mjs +++ b/.github/scripts/check-web-archive.mjs @@ -10,7 +10,7 @@ * - Links that have no web archive version (clearly marked as unrecoverable) * * Usage: - * node scripts/check-web-archive.mjs + * node .github/scripts/check-web-archive.mjs * * Environment variables: * - LYCHEE_OUTPUT: Path to lychee markdown output file (default: lychee/out.md) @@ -24,6 +24,8 @@ */ import { readFileSync, appendFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; const WAYBACK_API = 'https://archive.org/wayback/available?url='; @@ -41,42 +43,63 @@ function setOutput(name, value) { } /** - * Extract broken URLs from lychee markdown output + * Extract the "Errors per input" section of a lychee markdown report. + * + * Everything after it - most importantly "## Redirects per input" - describes + * links that resolved successfully. Scanning the whole report made every + * redirected link look broken and failed the workflow for healthy URLs + * (issue #96). + * @param {string} content - The markdown content from lychee + * @returns {string} The errors section, or an empty string when there is none + */ +function extractErrorsSection(content) { + const lines = content.split('\n'); + const start = lines.findIndex((line) => /^#+\s+Errors per input\s*$/.test(line)); + if (start === -1) { + return ''; + } + const heading = /^(#+)\s/.exec(lines[start])[1].length; + const section = []; + for (const line of lines.slice(start + 1)) { + const next = /^(#+)\s/.exec(line); + if (next && next[1].length <= heading) { + break; // a sibling or parent heading ends the errors section + } + section.push(line); + } + return section.join('\n'); +} + +/** + * Extract broken links from lychee markdown output. * Lychee markdown format includes lines like: * * [404] https://example.com/broken-link - * * [ERROR] https://another-broken.com + * * [ERROR] | File not found * @param {string} content - The markdown content from lychee - * @returns {string[]} Array of broken URLs + * @returns {{urls: string[], others: string[]}} Broken http(s) URLs, and broken + * links that cannot be looked up in the Wayback Machine (local files, + * unresolvable root-relative links, ...) */ -function extractBrokenUrls(content) { +function extractBrokenLinks(content) { + const section = extractErrorsSection(content); const urls = []; + const others = []; - // Match lines with error status codes or ERROR markers followed by URLs - // Lychee output format: [STATUS_CODE] URL or bullet points with links - const urlPattern = - /\[(?:4\d\d|5\d\d|ERROR|TIMEOUT|UNKNOWN)\]\s+(https?:\/\/[^\s)]+)/gi; + // One bullet per broken link; the status marker is always present. + const entryPattern = + /^\s*(?:\*|-)\s+\[(?:4\d\d|5\d\d|ERROR|TIMEOUT|UNKNOWN)\]\s+|)]+)>?/gim; let match; - - while ((match = urlPattern.exec(content)) !== null) { - const url = match[1].trim(); - if (url && !urls.includes(url)) { - urls.push(url); - } - } - - // Also match plain URL lines in broken sections - // Lychee sometimes outputs: `[ERROR] url | description` - const linePattern = /^\s*(?:\*|-)\s+.*?(https?:\/\/[^\s|)>\]]+)/gm; - let lineMatch; - - while ((lineMatch = linePattern.exec(content)) !== null) { - const url = lineMatch[1].trim().replace(/[.,;!?]+$/, ''); - if (url && !urls.includes(url) && url.startsWith('http')) { - urls.push(url); + while ((match = entryPattern.exec(section)) !== null) { + const link = match[1].trim().replace(/[.,;!?]+$/, ''); + if (!link) continue; + if (/^https?:\/\//i.test(link)) { + if (!urls.includes(link)) urls.push(link); + } else if (!others.includes(link)) { + others.push(link); } } - return urls; + return { urls, others }; } /** @@ -159,12 +182,34 @@ async function main() { } const content = readFileSync(lycheeOutput, 'utf-8'); - const brokenUrls = extractBrokenUrls(content); + const { urls: brokenUrls, others: unarchivableLinks } = + extractBrokenLinks(content); + + if (unarchivableLinks.length > 0) { + // Local files and unresolvable root-relative links have no Wayback + // equivalent. Reporting `all_archived=true` for them turned a real lychee + // failure into a green run (issue #96). + console.log( + `✗ ${unarchivableLinks.length} broken link(s) cannot be checked against the Web Archive:` + ); + for (const link of unarchivableLinks) { + console.log(` ${link}`); + console.log( + '::error title=Broken link - not recoverable from the Web Archive::' + + `Broken link detected: ${link}\n` + + 'It is not an http(s) URL (missing file, unresolvable relative link, ...),\n' + + 'so the Wayback Machine cannot provide a fallback.\n' + + 'How to fix: correct the path, restore the missing file, or pass --root-dir\n' + + 'to lychee so root-relative links resolve.' + ); + } + console.log(''); + } if (brokenUrls.length === 0) { console.log('No broken URLs found in lychee output.'); - setOutput('all_archived', 'true'); - process.exit(0); + setOutput('all_archived', unarchivableLinks.length === 0 ? 'true' : 'false'); + process.exit(unarchivableLinks.length === 0 ? 0 : 1); } console.log( @@ -238,7 +283,8 @@ async function main() { } } - const allArchived = withoutArchive.length === 0; + const allArchived = + withoutArchive.length === 0 && unarchivableLinks.length === 0; setOutput('all_archived', allArchived ? 'true' : 'false'); if (!allArchived) { @@ -257,7 +303,12 @@ async function main() { } } -main().catch((error) => { - console.error('Unexpected error:', error); - process.exit(1); -}); +export { extractErrorsSection, extractBrokenLinks }; + +// Only run when executed directly, so the unit tests can import the parsers. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error('Unexpected error:', error); + process.exit(1); + }); +} diff --git a/.github/scripts/check-web-archive.test.mjs b/.github/scripts/check-web-archive.test.mjs new file mode 100644 index 0000000..884227f --- /dev/null +++ b/.github/scripts/check-web-archive.test.mjs @@ -0,0 +1,69 @@ +// Regression tests for the lychee report parser used by the Broken Link +// Checker workflow. Both cases below were live CI defects found in issue #96: +// run 32145481148 escalated 9 "broken" links while lychee itself reported 4 +// errors (false positives from the redirects section), and the four real +// errors included two that the script could never verify (false negative). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + extractErrorsSection, + extractBrokenLinks, +} from './check-web-archive.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const report = readFileSync(join(here, 'fixtures', 'lychee-report.md'), 'utf-8'); + +test('the errors section stops at the next top-level heading', () => { + const section = extractErrorsSection(report); + assert.ok(section.includes('Errors in README.md')); + assert.ok( + !section.includes('Redirects per input'), + 'the redirects section must not leak into the errors section' + ); +}); + +test('a report without an errors section yields nothing', () => { + assert.equal(extractErrorsSection('# Report\n\n## Redirects per input\n\n* https://example.com --[301]--> https://example.org\n'), ''); +}); + +test('redirected links are not reported as broken', () => { + const { urls } = extractBrokenLinks(report); + for (const redirected of [ + 'https://docs.rs/link-cli', + 'https://github.com/linksplatform/Protocols.Lino', + 'https://habr.com/ru/articles/804617', + ]) { + assert.ok( + !urls.some((url) => url.startsWith(redirected)), + `${redirected} redirects successfully and must not be treated as broken` + ); + } +}); + +test('every http error is extracted exactly once', () => { + const { urls } = extractBrokenLinks(report); + assert.deepEqual(urls, [ + 'https://link-foundation.github.io/link-cli/csharp/', + 'https://link-foundation.github.io/link-cli/rust/link_cli/', + ]); +}); + +test('errors that the Wayback Machine cannot answer are still reported', () => { + const { others } = extractBrokenLinks(report); + assert.equal( + others.length, + 2, + 'the missing DocFX file and the unresolvable root-relative link must not be silently dropped' + ); + assert.ok(others.some((link) => link.endsWith('Foundation.Data.Doublets.Cli.yml'))); +}); + +test('the parsed error count matches the count lychee reports', () => { + const { urls, others } = extractBrokenLinks(report); + const reported = Number(/🚫 Errors\s*\|\s*(\d+)/.exec(report)[1]); + assert.equal(urls.length + others.length, reported); +}); diff --git a/.github/scripts/fixtures/lychee-report.md b/.github/scripts/fixtures/lychee-report.md new file mode 100644 index 0000000..c5b370c --- /dev/null +++ b/.github/scripts/fixtures/lychee-report.md @@ -0,0 +1,39 @@ +# Link Checker Report + +| Status | Count | +| -------------- | ----- | +| 🔍 Total | 120 | +| ✅ Successful | 108 | +| ⏳ Timeouts | 0 | +| 🔀 Redirected | 6 | +| 👻 Excluded | 2 | +| ❓ Unknown | 0 | +| 🚫 Errors | 4 | +| ⛔ Unsupported | 0 | + +## Errors per input + +### Errors in csharp/docs/index.md + +* [ERROR] (at 15:12) | File not found. Check if file exists and path is correct + +### Errors in js/index.html + +* [ERROR] (at 10:49) | Cannot resolve root-relative link '/favicon.svg': To resolve root-relative links in local files, provide a root dir + +### Errors in README.md + +* [404] (at 48:130) | Rejected status code: 404 Not Found +* [404] (at 49:62) | Rejected status code: 404 Not Found + +## Redirects per input + +### Redirects in README.md + +* https://docs.rs/link-cli --[302]--> https://docs.rs/link-cli/latest/link_cli/ +* https://github.com/linksplatform/Protocols.Lino --[301]--> https://github.com/link-foundation/links-notation +* https://habr.com/ru/articles/804617 --[301]--> https://habr.com/ru/articles/804617/ --[302]--> https://habr.com/ru/companies/deepfoundation/articles/804617/ + +### Redirects in rust/README.md + +* https://docs.rs/link-cli --[302]--> https://docs.rs/link-cli/latest/link_cli/ diff --git a/.github/scripts/workflow-policy.test.mjs b/.github/scripts/workflow-policy.test.mjs index 2dc79a2..ccaff80 100644 --- a/.github/scripts/workflow-policy.test.mjs +++ b/.github/scripts/workflow-policy.test.mjs @@ -194,3 +194,30 @@ for (const workflow of workflows) { } }); } + +// GitHub Pages serves a single site per repository, so two workflows that both +// upload a Pages artifact silently overwrite each other: whichever deploys last +// wins and the other one's URLs answer 404. Until issue #96 both docs.yml and +// wasm.yml deployed, which is why the API reference links in README.md were +// broken. docs.yml now assembles everything into one artifact. +test('exactly one workflow publishes GitHub Pages', () => { + const publishers = workflows + .filter((workflow) => /uses:\s*actions\/deploy-pages@/.test(workflow.text)) + .map((workflow) => workflow.name); + assert.deepEqual( + publishers, + ['docs.yml'], + `expected docs.yml to be the only Pages publisher, found: ${publishers.join(', ')}`, + ); +}); + +test('only the Pages publisher uploads a Pages artifact', () => { + const uploaders = workflows + .filter((workflow) => /uses:\s*actions\/upload-pages-artifact@/.test(workflow.text)) + .map((workflow) => workflow.name); + assert.deepEqual( + uploaders, + ['docs.yml'], + `a second Pages artifact replaces the published site, found: ${uploaders.join(', ')}`, + ); +}); diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index de9d0e5..5ffb51c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,12 +3,15 @@ name: Docs # Build and deploy a unified API reference site that hosts both the C# # (`Foundation.Data.Doublets.Cli`) and Rust (`link-cli`) library docs. # -# GitHub Pages allows only one deployment per repository, so this workflow -# combines DocFX-generated C# docs (under `/csharp/`) and `cargo doc`-generated -# Rust docs (under `/rust/`) into a single site that also includes a small -# landing page linking out to both. The deploy job runs on pushes to `main` -# and on manual dispatch, mirroring the AI driven development pipeline -# templates' approach (see docs/case-studies/issue-92/templates). +# GitHub Pages serves a single site per repository, so this workflow is the +# only publisher: it combines the WebAssembly workbench (site root), the +# DocFX-generated C# docs (under `/csharp/`), the `cargo doc`-generated Rust +# docs (under `/rust/`), and an API landing page (under `/docs/`) into one +# artifact. Until issue #96 wasm.yml deployed the workbench separately, so +# whichever workflow finished last replaced the other one's files and the +# documentation URLs advertised in README.md answered 404. The deploy job runs +# on pushes to `main` and on manual dispatch, mirroring the AI driven +# development pipeline templates' approach (see docs/case-studies/issue-92/templates). on: push: @@ -19,6 +22,8 @@ on: - 'csharp/docfx.json' - 'rust/src/**' - 'rust/Cargo.toml' + - 'rust/wasm/**' + - 'js/**' - '.github/workflows/docs.yml' pull_request: branches: [main] @@ -28,6 +33,8 @@ on: - 'csharp/docfx.json' - 'rust/src/**' - 'rust/Cargo.toml' + - 'rust/wasm/**' + - 'js/**' - '.github/workflows/docs.yml' workflow_dispatch: @@ -61,6 +68,18 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: npm + cache-dependency-path: js/package-lock.json - name: Cache cargo registry uses: actions/cache@v5 @@ -69,7 +88,8 @@ jobs: ~/.cargo/registry ~/.cargo/git rust/target - key: ${{ runner.os }}-cargo-docs-${{ hashFiles('rust/Cargo.lock') }} + rust/wasm/target + key: ${{ runner.os }}-cargo-docs-${{ hashFiles('rust/Cargo.lock', 'rust/wasm/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-docs- @@ -87,14 +107,24 @@ jobs: - name: Build Rust documentation run: cargo doc --manifest-path rust/Cargo.toml --no-deps --all-features + - name: Install npm dependencies + working-directory: js + run: npm ci + + - name: Build WebAssembly workbench + working-directory: js + run: npm run build:pages + - name: Assemble unified site run: | set -euo pipefail - mkdir -p _site/csharp _site/rust + mkdir -p _site/csharp _site/rust _site/docs + # The workbench owns the site root; the docs live in sub-folders. + cp -R dist/. _site/ cp -R csharp/_site/. _site/csharp/ cp -R rust/target/doc/. _site/rust/ - # Landing page that links into both sub-sites. - cat > _site/index.html <<'HTML' + # Landing page that links into both documentation sub-sites. + cat > _site/docs/index.html <<'HTML' @@ -116,8 +146,9 @@ jobs:

Generated reference for the C# and Rust library packages that ship alongside the clink CLI.

Source: github.com/link-foundation/link-cli

@@ -142,10 +173,7 @@ jobs: deploy: name: Deploy to GitHub Pages - # GitHub Pages allows a single deployment at a time for the whole - # repository, and both docs.yml and wasm.yml deploy to it. A shared, - # non-cancellable writer group serialises them instead of letting the - # second one fail with "another deployment is in progress" (issue #96). + # Single writer for GitHub Pages: never cancel a deployment mid-flight. concurrency: group: github-pages-${{ github.repository }} cancel-in-progress: false diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 8ed5203..e2d868e 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -59,6 +59,7 @@ jobs: --timeout 30 --exclude-path docs/case-studies --exclude-path dev/log + --root-dir ${{ github.workspace }}/js './**/*.md' './**/*.html' # Don't fail the workflow immediately - we want to check web archive first diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 3df61cc..0c602f7 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -90,86 +90,7 @@ jobs: name: link-cli-web path: dist/ - build-pages: - name: Build GitHub Pages app - if: | - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - github.event_name == 'workflow_dispatch' - needs: test - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v6 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - - name: Install wasm-pack - run: cargo install wasm-pack --version 0.14.0 --locked - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20.x' - cache: npm - cache-dependency-path: js/package-lock.json - - - name: Cache cargo registry - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - rust/target - rust/wasm/target - key: ${{ runner.os }}-wasm-pages-cargo-${{ hashFiles('rust/wasm/Cargo.lock', 'rust/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-wasm-pages-cargo- - ${{ runner.os }}-wasm-cargo- - - - name: Install npm dependencies - working-directory: js - run: npm ci - - - name: Configure Pages - uses: actions/configure-pages@v6 - - - name: Build GitHub Pages app - working-directory: js - run: npm run build:pages - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: dist/ - - deploy-pages: - name: Deploy GitHub Pages - # GitHub Pages allows a single deployment at a time for the whole - # repository, and both docs.yml and wasm.yml deploy to it. A shared, - # non-cancellable writer group serialises them instead of letting the - # second one fail with "another deployment is in progress" (issue #96). - concurrency: - group: github-pages-${{ github.repository }} - cancel-in-progress: false - if: | - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - github.event_name == 'workflow_dispatch' - needs: build-pages - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Deploy Pages artifact - id: deployment - uses: actions/deploy-pages@v5 + # GitHub Pages is published by docs.yml alone: it assembles this workbench + # together with the C# and Rust API references into a single artifact. This + # workflow used to deploy the workbench on its own, which replaced the docs + # already published at /csharp/ and /rust/ (issue #96). diff --git a/.lycheeignore b/.lycheeignore index 60ea928..3f1ec02 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -2,3 +2,13 @@ # npmjs.com returns 403 to link checkers because of bot protection. https://www\.npmjs\.com + +# DocFX generates csharp/docs/api/*.yml into the build output; the files are +# deliberately not committed, so the relative link from csharp/docs/index.md +# cannot be resolved from a source checkout. +csharp/docs/api/ + +# Sub-sites of the unified GitHub Pages site, published by +# .github/workflows/docs.yml on merge to main. A pull request that documents +# them would otherwise fail on URLs it is about to create. +https://link-foundation\.github\.io/link-cli/(csharp|rust|docs)/ diff --git a/README.md b/README.md index 6ad5978..1abe944 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ package built from `doublets-rs`. - C# library: package page at and the DocFX-generated reference on [GitHub Pages](https://link-foundation.github.io/link-cli/csharp/). - Rust library: (also mirrored on [GitHub Pages](https://link-foundation.github.io/link-cli/rust/link_cli/)). -- Combined landing page: . +- API landing page: (the site root hosts the WebAssembly workbench). ## Installation From d9e85d32d8f43e17c011b41cb7b3ca8a4bbb22b2 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 14:12:40 +0000 Subject: [PATCH 17/21] ci: gate clippy warnings, lint the wasm crate, simulate the fresh merge Three false negatives, all of which let a broken tree report success: * `cargo clippy` was invoked without `-- -D warnings`, so it printed its findings and exited 0. Both Rust workspaces are now gated; both are clean today, so the gate is added at zero cost. * `rust/wasm` was never formatted or linted - the lint job only pointed at `rust/Cargo.toml`. * Every job tested the pull request head. A pull request can be green on its own head and still break `main`, because a clean textual merge is not necessarily a compiling one. `.github/scripts/simulate-fresh-merge.sh` (ported from the Rust pipeline template) merges the head into the current tip of the base branch and re-runs the fast checks there; rust.yml and csharp.yml each gained a pull-request-only job that uses it, parameterised through FRESH_MERGE_CHECKS because this repository builds three stacks from one tree. Policy tests pin all three invariants. Refs #96 --- .github/scripts/simulate-fresh-merge.sh | 81 ++++++++++++++++++++++++ .github/scripts/workflow-policy.test.mjs | 33 ++++++++++ .github/workflows/csharp.yml | 32 ++++++++++ .github/workflows/rust.yml | 55 +++++++++++++++- 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/simulate-fresh-merge.sh diff --git a/.github/scripts/simulate-fresh-merge.sh b/.github/scripts/simulate-fresh-merge.sh new file mode 100755 index 0000000..c6df2e6 --- /dev/null +++ b/.github/scripts/simulate-fresh-merge.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Simulate merging the pull request head into a fresh copy of the base branch and +# run the fast checks against that merge result. +# +# A pull request can be green in isolation and still break the base branch when a +# semantic (non-textual) conflict is introduced: both sides merge cleanly, but the +# combined tree no longer compiles or passes tests. GitHub only tests the PR head +# (or a merge commit computed at PR creation time), so this script recreates the +# merge locally against the current tip of the base branch. +# +# Environment: +# GITHUB_BASE_REF - base branch name (set automatically for pull_request events) +# FRESH_MERGE_CHECKS - optional newline-separated override of the commands to run +# +# Ported from link-foundation/rust-ai-driven-development-pipeline-template for +# issue #96; the check list is parameterised because this repository builds +# three stacks (Rust, C#, JavaScript/WASM) from one tree. +set -euo pipefail + +BASE_REF="${GITHUB_BASE_REF:-main}" + +if [ -z "${GITHUB_BASE_REF:-}" ]; then + echo "GITHUB_BASE_REF is not set; assuming base branch '${BASE_REF}'" +fi + +echo "Fetching origin/${BASE_REF}..." +git fetch --no-tags origin "${BASE_REF}" + +BASE_SHA="$(git rev-parse "origin/${BASE_REF}")" +HEAD_SHA="$(git rev-parse HEAD)" +echo "Base: ${BASE_REF} (${BASE_SHA})" +echo "Head: ${HEAD_SHA}" + +if git merge-base --is-ancestor "${HEAD_SHA}" "${BASE_SHA}"; then + echo "Head is already contained in origin/${BASE_REF}; nothing to simulate." + exit 0 +fi + +# Merge into a detached checkout of the base tip so the working branch is untouched. +git config user.name "${GIT_AUTHOR_NAME:-github-actions[bot]}" +git config user.email "${GIT_AUTHOR_EMAIL:-github-actions[bot]@users.noreply.github.com}" +git checkout --detach "${BASE_SHA}" + +if ! git merge --no-edit "${HEAD_SHA}"; then + echo "::error::Textual merge conflict with origin/${BASE_REF}. Merge the base branch into this pull request and resolve the conflicts." + git merge --abort || true + git checkout --force - + exit 1 +fi + +echo "Merge succeeded. Running checks on the merged tree..." + +status=0 +if [ -n "${FRESH_MERGE_CHECKS:-}" ]; then + # One command per line, so commands may contain spaces. + while IFS= read -r check; do + [ -n "${check}" ] || continue + echo "::group::${check}" + eval "${check}" || status=1 + echo "::endgroup::" + done <<< "${FRESH_MERGE_CHECKS}" +else + echo "::group::cargo fmt --all -- --check" + cargo fmt --all -- --check || status=1 + echo "::endgroup::" + + echo "::group::cargo clippy --all-targets --all-features" + cargo clippy --all-targets --all-features || status=1 + echo "::endgroup::" + + echo "::group::cargo test --all-features" + cargo test --all-features || status=1 + echo "::endgroup::" +fi + +if [ "${status}" -ne 0 ]; then + echo "::error::Checks failed on the simulated merge with origin/${BASE_REF} even though they pass on the pull request head. This is a semantic merge conflict." +fi + +git checkout --force - >/dev/null 2>&1 || true +exit "${status}" diff --git a/.github/scripts/workflow-policy.test.mjs b/.github/scripts/workflow-policy.test.mjs index ccaff80..fc5f737 100644 --- a/.github/scripts/workflow-policy.test.mjs +++ b/.github/scripts/workflow-policy.test.mjs @@ -221,3 +221,36 @@ test('only the Pages publisher uploads a Pages artifact', () => { `a second Pages artifact replaces the published site, found: ${uploaders.join(', ')}`, ); }); + +// `cargo clippy` without `-D warnings` prints its findings and exits 0, so lint +// regressions land silently. Both Rust workspaces must be gated (issue #96). +test('every clippy invocation denies warnings', () => { + for (const workflow of workflows) { + const invocations = workflow.text + .split('\n') + .map((line, index) => ({ line: line.trim(), number: index + 1 })) + .filter(({ line }) => /^(run:\s*)?cargo clippy\b/.test(line)); + for (const { line, number } of invocations) { + assert.match( + line, + /--\s+-D\s+warnings/, + `${workflow.name}:${number}: "${line}" does not fail on clippy warnings`, + ); + } + } +}); + +// A pull request that is green on its own head can still break main: a clean +// textual merge is not necessarily a compiling one. Each stack that has a +// pull-request pipeline re-runs its checks on the simulated merge result. +test('the Rust and C# pipelines simulate a fresh merge on pull requests', () => { + for (const name of ['rust.yml', 'csharp.yml']) { + const workflow = workflows.find((candidate) => candidate.name === name); + assert.ok(workflow, `${name} is missing`); + assert.match( + workflow.text, + /simulate-fresh-merge\.sh/, + `${name} does not run .github/scripts/simulate-fresh-merge.sh`, + ); + } +}); diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 66f806e..3e427b8 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -213,6 +213,38 @@ jobs: with: fail_ci_if_error: false + # === SEMANTIC MERGE CONFLICT CHECK === + # The jobs above test the pull request head. Two pull requests that each pass + # can still break `main` once merged, because a clean textual merge does not + # imply a compiling one (issue #96). This job merges the head into the current + # tip of the base branch and rebuilds and retests that tree. + fresh-merge: + name: Simulate Fresh Merge + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'pull_request' + needs: [detect-changes] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + # The script rewrites the working tree (detached checkout + merge), so it + # has to run from the repository root, not from the csharp/ default. + - name: Simulate fresh merge with base branch + working-directory: . + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + FRESH_MERGE_CHECKS: | + dotnet build csharp --configuration Release + dotnet test csharp --no-build --configuration Release --verbosity normal + run: bash .github/scripts/simulate-fresh-merge.sh + # === BUILD PACKAGE === build: name: Build Package diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6281f4f..4f1f876 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -147,10 +147,18 @@ jobs: ${{ runner.os }}-cargo- - name: Check formatting - run: cargo fmt --manifest-path rust/Cargo.toml --all -- --check + run: | + cargo fmt --manifest-path rust/Cargo.toml --all -- --check + cargo fmt --manifest-path rust/wasm/Cargo.toml --all -- --check + # `-D warnings` is what makes this a gate: without it clippy prints its + # findings and still exits 0, so lint warnings accumulated invisibly + # (issue #96). The wasm crate is a separate workspace and was not linted + # at all until now. - name: Run Clippy - run: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features + run: | + cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings + cargo clippy --manifest-path rust/wasm/Cargo.toml --all-targets --all-features -- -D warnings - name: Check file size limit run: rust-script rust/scripts/check-file-size.rs @@ -207,6 +215,49 @@ jobs: - name: Run doc tests run: cargo test --manifest-path rust/Cargo.toml --doc --verbose + # === SEMANTIC MERGE CONFLICT CHECK === + # Every other job tests the pull request head. A pull request can be green in + # isolation and still break `main`: two changes that merge without a textual + # conflict can still contradict each other semantically, and the breakage only + # appears after the merge lands (issue #96). This job merges the head into the + # current tip of the base branch and re-runs the fast checks on that tree. + # Ported from the Rust pipeline template's "Simulate fresh merge" step. + fresh-merge: + name: Simulate Fresh Merge + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'pull_request' + needs: [detect-changes] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-fresh-merge-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-fresh-merge- + + - name: Simulate fresh merge with base branch + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + FRESH_MERGE_CHECKS: | + cargo fmt --manifest-path rust/Cargo.toml --all -- --check + cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings + cargo test --manifest-path rust/Cargo.toml --all-features + run: bash .github/scripts/simulate-fresh-merge.sh + # === BUILD === build: name: Build Package From e53544459d07b243b664d92a0483fb5cf299ee91 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 14:15:11 +0000 Subject: [PATCH 18/21] test: point the Pages layout guard at the single publisher repositoryLayout.test.mjs asserted that wasm.yml deploys GitHub Pages, which is exactly the duplicate-publisher arrangement that made the API reference URLs answer 404. Run 32146391570 caught it, which is the point of running these tests in CI. The guard now asserts that docs.yml publishes the site (including the workbench it serves at the root) and that wasm.yml uploads no Pages artifact of its own. test:js also runs .github/scripts/*.test.mjs now, so `npm test` covers the workflow policy and lychee report parsers locally, not only in CI. Refs #96 --- js/package.json | 2 +- js/test/repositoryLayout.test.mjs | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/js/package.json b/js/package.json index 381edd9..e2c8439 100644 --- a/js/package.json +++ b/js/package.json @@ -12,7 +12,7 @@ "dev": "npm run build:wasm && vite --config vite.config.js --host 0.0.0.0", "preview": "vite preview --config vite.config.js --host 0.0.0.0", "test": "npm run test:wasm && npm run test:js && npm run build", - "test:js": "node --test test/*.test.mjs ../csharp/scripts/*.test.mjs", + "test:js": "node --test test/*.test.mjs ../csharp/scripts/*.test.mjs ../.github/scripts/*.test.mjs", "test:wasm": "wasm-pack test --node ../rust/wasm", "clean": "rm -rf ../dist pkg ../pkg ../pkg-node ../pkg-bundler ../target ../rust/wasm/target" }, diff --git a/js/test/repositoryLayout.test.mjs b/js/test/repositoryLayout.test.mjs index 41ce9eb..853a922 100644 --- a/js/test/repositoryLayout.test.mjs +++ b/js/test/repositoryLayout.test.mjs @@ -96,8 +96,14 @@ test('WebAssembly workflow uses the JavaScript package lockfile from js', () => assert.doesNotMatch(workflow, /(^|\s)- 'web\/\*\*'/); }); -test('WebAssembly workflow deploys GitHub Pages automatically on push to main', () => { - const workflow = readFileSync(join(repoRoot, '.github/workflows/wasm.yml'), 'utf8'); +test('the documentation workflow deploys GitHub Pages automatically on push to main', () => { + // GitHub Pages serves a single site per repository. wasm.yml used to deploy + // the workbench on its own while docs.yml deployed the API references, so + // whichever ran last replaced the other one's files and the documentation + // URLs in README.md answered 404. docs.yml is now the only publisher and + // assembles the workbench and both API references into one artifact + // (issue #96). + const workflow = readFileSync(join(repoRoot, '.github/workflows/docs.yml'), 'utf8'); assert.match(workflow, /name: Deploy GitHub Pages/); assert.match(workflow, /uses: actions\/deploy-pages@/); @@ -109,7 +115,19 @@ test('WebAssembly workflow deploys GitHub Pages automatically on push to main', workflow, /github\.event_name == 'push'[\s\S]*?github\.ref == 'refs\/heads\/main'/ ); - assert.doesNotMatch(workflow, /inputs\.deploy_pages/); + assert.match( + workflow, + /npm run build:pages/, + 'docs.yml must build the WebAssembly workbench it publishes at the site root' + ); + + const wasmWorkflow = readFileSync(join(repoRoot, '.github/workflows/wasm.yml'), 'utf8'); + assert.doesNotMatch( + wasmWorkflow, + /uses: actions\/(deploy-pages|upload-pages-artifact)@/, + 'a second Pages artifact replaces the published site' + ); + assert.doesNotMatch(wasmWorkflow, /inputs\.deploy_pages/); }); test('CSharp release workflow attaches NuGet packages to GitHub Releases', () => { From 57a16251b6c658567f080a6c79d69a3f387f877e Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 14:17:49 +0000 Subject: [PATCH 19/21] ci: scan for committed secrets and mirror the gates in pre-commit security.yml checked code (CodeQL) and dependencies (cargo audit, npm audit) but never the literal contents of the tree. This repository commits CI evidence under dev/log, which is exactly where a token copied out of a workflow log would land unnoticed. Adds the templates' secretlint job and .secretlintrc.json; the current tree scans clean. .pre-commit-config.yaml mirrors the cheap gates locally (formatting, clippy with -D warnings, the workflow policy and layout tests), adapted to this monorepo's per-stack manifests. Evidence for the audit jobs, collected under dev/log/issues/96/pulls/97/local: main's js/package-lock.json fails `npm audit --audit-level=high` with 3 high findings (vite GHSA-fx2h-pf6j-xcff, postcss GHSA-r28c-9q8g-f849) - the four open Dependabot alerts on the default branch. There was no Security workflow at all before this branch, which is why they went unnoticed; this branch's lockfile is already on vite 8.2.1 / postcss 8.5.26 and audits clean. Refs #96 --- .github/workflows/security.yml | 24 +++++++++++++++ .pre-commit-config.yaml | 56 ++++++++++++++++++++++++++++++++++ .secretlintrc.json | 7 +++++ 3 files changed, 87 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 .secretlintrc.json diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a4a6457..c23c2da 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -111,3 +111,27 @@ jobs: - name: Audit current lock working-directory: js run: npm audit --package-lock-only --audit-level=high + + # === COMMITTED SECRETS === + # CodeQL and the audit jobs look at code and dependencies; nothing looked at + # the literal contents of the tree. This repository commits CI evidence under + # dev/log, which is exactly where a token pasted from a workflow log would end + # up unnoticed (issue #96). Ported from the pipeline templates' secrets-scan + # job. .gitignore doubles as the ignore file, so node_modules and build output + # are skipped. + secrets-scan: + name: Scan for committed secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-secrets-scan + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Run secretlint + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint --secretlintignore .gitignore "**/*" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..aa74954 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +# Local mirror of the cheap CI gates, so the same defects fail on `git commit` +# instead of ten minutes into a workflow run. Ported from the AI driven +# development pipeline templates for issue #96 and adapted to this monorepo: +# every hook has to point at the stack-specific manifest, because the three +# projects live under csharp/, rust/ and js/. +# +# Install once with: +# pip install pre-commit && pre-commit install +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-xml + - id: check-merge-conflict + - id: check-added-large-files + + - repo: local + hooks: + # Mirrors the "Lint and Format Check" job of rust.yml. + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --manifest-path rust/Cargo.toml --all -- + language: system + types: [rust] + pass_filenames: false + + - id: cargo-clippy + name: cargo clippy + entry: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings + language: system + types: [rust] + pass_filenames: false + + # Mirrors the "Lint and Format Check" job of csharp.yml. The solution sets + # TreatWarningsAsErrors in csharp/Directory.Build.props, so a plain build + # is already the warning gate. + - id: dotnet-format + name: dotnet format + entry: dotnet format csharp --verify-no-changes + language: system + types: [c#] + pass_filenames: false + + # Mirrors the workflow policy and repository layout guards. These are pure + # Node with no dependencies, so they are fast enough for every commit. + - id: workflow-policy + name: workflow policy and repository layout tests + entry: node --test .github/scripts/*.test.mjs js/test/*.test.mjs csharp/scripts/*.test.mjs + language: system + files: ^(\.github/|js/|csharp/|rust/|\.lycheeignore|README\.md) + pass_filenames: false diff --git a/.secretlintrc.json b/.secretlintrc.json new file mode 100644 index 0000000..7a1a5df --- /dev/null +++ b/.secretlintrc.json @@ -0,0 +1,7 @@ +{ + "rules": [ + { + "id": "@secretlint/secretlint-rule-preset-recommend" + } + ] +} From 595548d057b98dfc287e7ca2b2b573dcdb004eb7 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 14:22:10 +0000 Subject: [PATCH 20/21] ci: clear the remaining CI warnings Three warnings were present in every run and none of them failed anything, so they had been accumulating (issue #96, requirement "fix all warnings"): * "Node.js 20 is deprecated ... actions/github-script@60a0d83 ... forced to run on Node.js 24" in the C# test job. codecov/codecov-action@v5 pins github-script v7.0.1 (node20); v6 and v7 pin v8.0.0 (node24). Bumped to v7; the action's inputs we use are unchanged. * "Cannot build an overlay database because build-mode is set to 'undefined'" plus the DEPRECATED autobuild inputs, on all four CodeQL languages. build-mode is now declared per language (autobuild for C#, none for Rust, JavaScript/TypeScript and Actions) and the deprecated standalone autobuild step is gone. * "rust/src/query_processor.rs has 994 lines (approaching limit of 1000)". The pattern-matching helpers moved to rust/src/query_processor/matching.rs, following the split already applied to transactions and version_control; query_processor.rs is now 794 lines and no tracked .rs file exceeds the 900-line warning threshold. cargo fmt, cargo clippy -D warnings and cargo test --all-features are clean after the extraction. Refs #96 --- .github/workflows/csharp.yml | 2 +- .github/workflows/security.yml | 22 ++- rust/src/query_processor.rs | 206 +------------------------ rust/src/query_processor/matching.rs | 221 +++++++++++++++++++++++++++ 4 files changed, 243 insertions(+), 208 deletions(-) create mode 100644 rust/src/query_processor/matching.rs diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 3e427b8..ca384da 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -209,7 +209,7 @@ jobs: - name: Upload coverage to Codecov if: matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: fail_ci_if_error: false diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c23c2da..6d95cd5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -31,8 +31,22 @@ jobs: security-events: write strategy: fail-fast: false + # build-mode has to be declared per language. Leaving it unset made every + # run log "Cannot build an overlay database because build-mode is set to + # 'undefined'" and fall back to a full database, and drove the standalone + # autobuild step, whose inputs the action now reports as DEPRECATED + # (issue #96). Only C# needs a compiler; the other three are analysed from + # source. matrix: - language: [csharp, rust, javascript-typescript, actions] + include: + - language: csharp + build-mode: autobuild + - language: rust + build-mode: none + - language: javascript-typescript + build-mode: none + - language: actions + build-mode: none steps: - uses: actions/checkout@v6 @@ -42,13 +56,13 @@ jobs: with: dotnet-version: '8.0.x' + # With build-mode set here the action builds the database itself, so the + # deprecated github/codeql-action/autobuild step is no longer needed. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - - - name: Autobuild - uses: github/codeql-action/autobuild@v4 + build-mode: ${{ matrix.build-mode }} - name: Analyze uses: github/codeql-action/analyze@v4 diff --git a/rust/src/query_processor.rs b/rust/src/query_processor.rs index 1e46706..0b62b88 100644 --- a/rust/src/query_processor.rs +++ b/rust/src/query_processor.rs @@ -15,6 +15,9 @@ use crate::named_type_links::NamedTypeLinks; use crate::parser::Parser; use crate::query_types::{Pattern, ResolvedLink}; +// Pattern matching lives in a submodule; see query_processor/matching.rs. +mod matching; + /// QueryProcessor handles LiNo query parsing and execution /// Corresponds to AdvancedMixedQueryProcessor in C# pub struct QueryProcessor { @@ -310,209 +313,6 @@ impl QueryProcessor { .all(|(key, value)| existing.get(key).is_none_or(|existing| existing == value)) } - fn match_pattern( - &self, - storage: &mut impl NamedTypeLinks, - pattern: &Pattern, - current_solution: &HashMap, - ) -> Result>> { - if pattern.is_leaf() { - let resolved_index = - self.resolve_match_id(storage, &pattern.index, current_solution)?; - return Ok(storage - .all_links() - .into_iter() - .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) - .map(|link| { - let mut assignments = HashMap::new(); - Self::assign_variable(&pattern.index, link.index, &mut assignments); - assignments - }) - .collect()); - } - - let resolved_index = self.resolve_match_id(storage, &pattern.index, current_solution)?; - - if !Self::is_variable(&pattern.index) - && !Self::is_any(resolved_index) - && resolved_index != 0 - && storage.exists(resolved_index) - { - let link = storage.get_link(resolved_index).unwrap(); - return self.match_link_against_pattern(storage, pattern, link, current_solution); - } - - let mut results = Vec::new(); - for link in storage.all_links() { - results.extend(self.match_link_against_pattern( - storage, - pattern, - link, - current_solution, - )?); - } - Ok(results) - } - - fn match_link_against_pattern( - &self, - storage: &mut impl NamedTypeLinks, - pattern: &Pattern, - link: Link, - current_solution: &HashMap, - ) -> Result>> { - if !self.check_id_match(storage, &pattern.index, link.index, current_solution)? { - return Ok(Vec::new()); - } - - let mut results = Vec::new(); - let source_matches = self.recursive_match_subpattern( - storage, - pattern.source.as_deref(), - link.source, - current_solution, - )?; - - for source_solution in source_matches { - let target_matches = self.recursive_match_subpattern( - storage, - pattern.target.as_deref(), - link.target, - &source_solution, - )?; - for mut target_solution in target_matches { - Self::assign_variable(&pattern.index, link.index, &mut target_solution); - results.push(target_solution); - } - } - - Ok(results) - } - - fn recursive_match_subpattern( - &self, - storage: &mut impl NamedTypeLinks, - pattern: Option<&Pattern>, - link_id: u32, - current_solution: &HashMap, - ) -> Result>> { - let Some(pattern) = pattern else { - return Ok(vec![current_solution.clone()]); - }; - - if pattern.is_leaf() { - if self.check_id_match(storage, &pattern.index, link_id, current_solution)? { - let mut solution = current_solution.clone(); - Self::assign_variable(&pattern.index, link_id, &mut solution); - return Ok(vec![solution]); - } - return Ok(Vec::new()); - } - - let Some(link) = storage.get_link(link_id) else { - return Ok(Vec::new()); - }; - - self.match_link_against_pattern(storage, pattern, link, current_solution) - } - - fn check_id_match( - &self, - storage: &mut impl NamedTypeLinks, - pattern_id: &str, - candidate_id: u32, - current_solution: &HashMap, - ) -> Result { - if pattern_id.is_empty() || pattern_id == "*" { - return Ok(true); - } - - if Self::is_variable(pattern_id) { - return Ok(current_solution - .get(pattern_id) - .is_none_or(|existing| *existing == candidate_id)); - } - - if let Ok(parsed) = pattern_id.parse::() { - return Ok(parsed == candidate_id); - } - - Ok(storage - .get_by_name(pattern_id)? - .is_some_and(|named_id| named_id == candidate_id)) - } - - fn resolve_match_id( - &self, - storage: &mut impl NamedTypeLinks, - identifier: &str, - current_solution: &HashMap, - ) -> Result { - if identifier.is_empty() || identifier == "*" { - return Ok(u32::MAX); - } - if let Some(value) = current_solution.get(identifier) { - return Ok(*value); - } - if Self::is_variable(identifier) { - return Ok(u32::MAX); - } - if let Ok(parsed) = identifier.parse::() { - return Ok(parsed); - } - Ok(storage.get_by_name(identifier)?.unwrap_or(0)) - } - - fn matched_links( - &self, - storage: &mut impl NamedTypeLinks, - pattern: &Pattern, - solution: &HashMap, - ) -> Result> { - if pattern.is_leaf() { - let resolved_index = self.resolve_match_id(storage, &pattern.index, solution)?; - return Ok(storage - .all_links() - .into_iter() - .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) - .collect()); - } - - let mut links = Vec::new(); - for matched_solution in self.match_pattern(storage, pattern, solution)? { - if let Some(definition) = - self.resolve_pattern_readonly(storage, pattern, &matched_solution, false)? - { - links.extend(self.links_matching_definition(storage, &definition)?); - } - } - Ok(links) - } - - fn solution_is_no_operation( - &self, - storage: &mut impl NamedTypeLinks, - solution: &HashMap, - restrictions: &[Pattern], - substitutions: &[Pattern], - ) -> Result { - let mut restriction_links = self - .resolve_patterns_readonly(storage, restrictions, solution, false)? - .into_iter() - .map(|definition| definition.to_link()) - .collect::>(); - let mut substitution_links = self - .resolve_patterns_readonly(storage, substitutions, solution, true)? - .into_iter() - .map(|definition| definition.to_link()) - .collect::>(); - - restriction_links.sort_by_key(|link| link.index); - substitution_links.sort_by_key(|link| link.index); - - Ok(restriction_links == substitution_links) - } - fn resolve_patterns_readonly( &self, storage: &mut impl NamedTypeLinks, diff --git a/rust/src/query_processor/matching.rs b/rust/src/query_processor/matching.rs new file mode 100644 index 0000000..f6d41f2 --- /dev/null +++ b/rust/src/query_processor/matching.rs @@ -0,0 +1,221 @@ +//! Pattern matching for [`QueryProcessor`]. +//! +//! Extracted from `query_processor.rs` for issue #96: the file had grown to 994 +//! lines and CI warned that it was approaching the 1000-line limit enforced by +//! `rust/scripts/check-file-size.rs`. These are the read-only helpers that +//! decide whether a stored link satisfies a pattern; they carry no state beyond +//! `QueryProcessor`'s tracing flag. + +use anyhow::Result; +use std::collections::HashMap; + +use crate::link::Link; +use crate::named_type_links::NamedTypeLinks; +use crate::query_types::Pattern; + +use super::QueryProcessor; + +impl QueryProcessor { + pub(super) fn match_pattern( + &self, + storage: &mut impl NamedTypeLinks, + pattern: &Pattern, + current_solution: &HashMap, + ) -> Result>> { + if pattern.is_leaf() { + let resolved_index = + self.resolve_match_id(storage, &pattern.index, current_solution)?; + return Ok(storage + .all_links() + .into_iter() + .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) + .map(|link| { + let mut assignments = HashMap::new(); + Self::assign_variable(&pattern.index, link.index, &mut assignments); + assignments + }) + .collect()); + } + + let resolved_index = self.resolve_match_id(storage, &pattern.index, current_solution)?; + + if !Self::is_variable(&pattern.index) + && !Self::is_any(resolved_index) + && resolved_index != 0 + && storage.exists(resolved_index) + { + let link = storage.get_link(resolved_index).unwrap(); + return self.match_link_against_pattern(storage, pattern, link, current_solution); + } + + let mut results = Vec::new(); + for link in storage.all_links() { + results.extend(self.match_link_against_pattern( + storage, + pattern, + link, + current_solution, + )?); + } + Ok(results) + } + + pub(super) fn match_link_against_pattern( + &self, + storage: &mut impl NamedTypeLinks, + pattern: &Pattern, + link: Link, + current_solution: &HashMap, + ) -> Result>> { + if !self.check_id_match(storage, &pattern.index, link.index, current_solution)? { + return Ok(Vec::new()); + } + + let mut results = Vec::new(); + let source_matches = self.recursive_match_subpattern( + storage, + pattern.source.as_deref(), + link.source, + current_solution, + )?; + + for source_solution in source_matches { + let target_matches = self.recursive_match_subpattern( + storage, + pattern.target.as_deref(), + link.target, + &source_solution, + )?; + for mut target_solution in target_matches { + Self::assign_variable(&pattern.index, link.index, &mut target_solution); + results.push(target_solution); + } + } + + Ok(results) + } + + pub(super) fn recursive_match_subpattern( + &self, + storage: &mut impl NamedTypeLinks, + pattern: Option<&Pattern>, + link_id: u32, + current_solution: &HashMap, + ) -> Result>> { + let Some(pattern) = pattern else { + return Ok(vec![current_solution.clone()]); + }; + + if pattern.is_leaf() { + if self.check_id_match(storage, &pattern.index, link_id, current_solution)? { + let mut solution = current_solution.clone(); + Self::assign_variable(&pattern.index, link_id, &mut solution); + return Ok(vec![solution]); + } + return Ok(Vec::new()); + } + + let Some(link) = storage.get_link(link_id) else { + return Ok(Vec::new()); + }; + + self.match_link_against_pattern(storage, pattern, link, current_solution) + } + + pub(super) fn check_id_match( + &self, + storage: &mut impl NamedTypeLinks, + pattern_id: &str, + candidate_id: u32, + current_solution: &HashMap, + ) -> Result { + if pattern_id.is_empty() || pattern_id == "*" { + return Ok(true); + } + + if Self::is_variable(pattern_id) { + return Ok(current_solution + .get(pattern_id) + .is_none_or(|existing| *existing == candidate_id)); + } + + if let Ok(parsed) = pattern_id.parse::() { + return Ok(parsed == candidate_id); + } + + Ok(storage + .get_by_name(pattern_id)? + .is_some_and(|named_id| named_id == candidate_id)) + } + + pub(super) fn resolve_match_id( + &self, + storage: &mut impl NamedTypeLinks, + identifier: &str, + current_solution: &HashMap, + ) -> Result { + if identifier.is_empty() || identifier == "*" { + return Ok(u32::MAX); + } + if let Some(value) = current_solution.get(identifier) { + return Ok(*value); + } + if Self::is_variable(identifier) { + return Ok(u32::MAX); + } + if let Ok(parsed) = identifier.parse::() { + return Ok(parsed); + } + Ok(storage.get_by_name(identifier)?.unwrap_or(0)) + } + + pub(super) fn matched_links( + &self, + storage: &mut impl NamedTypeLinks, + pattern: &Pattern, + solution: &HashMap, + ) -> Result> { + if pattern.is_leaf() { + let resolved_index = self.resolve_match_id(storage, &pattern.index, solution)?; + return Ok(storage + .all_links() + .into_iter() + .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) + .collect()); + } + + let mut links = Vec::new(); + for matched_solution in self.match_pattern(storage, pattern, solution)? { + if let Some(definition) = + self.resolve_pattern_readonly(storage, pattern, &matched_solution, false)? + { + links.extend(self.links_matching_definition(storage, &definition)?); + } + } + Ok(links) + } + + pub(super) fn solution_is_no_operation( + &self, + storage: &mut impl NamedTypeLinks, + solution: &HashMap, + restrictions: &[Pattern], + substitutions: &[Pattern], + ) -> Result { + let mut restriction_links = self + .resolve_patterns_readonly(storage, restrictions, solution, false)? + .into_iter() + .map(|definition| definition.to_link()) + .collect::>(); + let mut substitution_links = self + .resolve_patterns_readonly(storage, substitutions, solution, true)? + .into_iter() + .map(|definition| definition.to_link()) + .collect::>(); + + restriction_links.sort_by_key(|link| link.index); + substitution_links.sort_by_key(|link| link.index); + + Ok(restriction_links == substitution_links) + } +} From de0d1e971e5ddf9666b258e612421c3fd31e2979 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 18 Aug 2026 14:22:36 +0000 Subject: [PATCH 21/21] docs(issue-96): record the new pipeline gates in the release fragments Refs #96 --- csharp/.changeset/issue-96-cicd-hardening.md | 3 +++ .../changelog.d/20260818_120000_issue_96_cicd_hardening.md | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/csharp/.changeset/issue-96-cicd-hardening.md b/csharp/.changeset/issue-96-cicd-hardening.md index 5cd250c..e2a92bf 100644 --- a/csharp/.changeset/issue-96-cicd-hardening.md +++ b/csharp/.changeset/issue-96-cicd-hardening.md @@ -11,3 +11,6 @@ fail while the pipeline still reported success. The C# workflow no longer masks those Windows failures with `continue-on-error`, verifies formatting and file sizes, and finally implements the `changeset-pr` release mode it had been advertising without handling. +Pull requests also re-run the build and tests on a simulated merge with +the tip of `main`, and the coverage upload moved to +`codecov/codecov-action@v7` to stop the Node.js 20 deprecation warning. diff --git a/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md b/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md index 38c3551..a4f2578 100644 --- a/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md +++ b/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md @@ -8,3 +8,10 @@ non-cancellable writer concurrency group, the advertised `changelog-pr` release mode is implemented, and the lint job fails on `Cargo.lock` drift. `anyhow` and `memmap2` were refreshed in both lockfiles to clear the outstanding RUSTSEC advisories. +`cargo clippy` now runs with `-D warnings` and also covers the `rust/wasm` +workspace, which had never been formatted or linted, and pull requests +re-run the fast checks on a simulated merge with the tip of `main` so a +semantic merge conflict fails the pull request instead of `main`. The +pattern-matching helpers moved from `query_processor.rs` into +`query_processor/matching.rs` to clear the file-size warning; no public API +changed.