From e0aef5909c2818be794a57b4ccc4f2bd0422699b Mon Sep 17 00:00:00 2001 From: Arctis Fireblight <6182060+Arctis-Fireblight@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:35:52 -0500 Subject: [PATCH 1/2] Add support for module class documentation and enhance synchronization logic --- README.md | 6 +- .../ClassDocumentationParserTests.cs | 16 ++ .../ClassDocumentationSyncServiceTests.cs | 31 +++- .../GitClassDocumentationSourceTests.cs | 135 ++++++++++++++++- .../ClassDocumentationParser.cs | 30 +++- .../ClassDocumentationSyncService.cs | 2 +- .../GitClassDocumentationSource.cs | 140 +++++++++++++++--- 7 files changed, 331 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 48710a5..707f152 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,14 @@ Exactly one entry must be marked as the latest stable version and exactly one as ### Class reference synchronization -The Classes section is generated from the XML class reference in the Redot Engine repository. At startup, the application loads valid cached snapshots and starts a shallow, partial Git checkout of `doc/classes` for every branch configured in `Versions.json` in the background. Snapshots are cached under `Redot-Documentation/App_Data/class-docs` and checked for upstream changes every 24 hours. If Git is temporarily unavailable, the application continues with the last valid cache; without a cache, class documentation remains unavailable until a synchronization succeeds. +The Classes section is generated from the XML class reference in the Redot Engine repository. At startup, the application loads valid cached snapshots and starts a shallow, partial Git checkout of `doc/classes/*.xml` and `modules/*/doc_classes/*.xml` for every branch configured in `Versions.json` in the background. Snapshots are cached under `Redot-Documentation/App_Data/class-docs` and checked for upstream changes every 24 hours. If Git is temporarily unavailable, the application continues with the last valid cache; without a cache, class documentation remains unavailable until a synchronization succeeds. The repository URL, source path, cache path, refresh interval, and Git timeout are configured in the `ClassDocumentation` section of `Redot-Documentation/appsettings.json`. Set `ClassDocumentation__Enabled=false` to disable synchronization for an offline development session. Git must be installed on the host. +Module class documentation is discovered automatically using wildcard sparse checkout; no per-module configuration or engine build is required. Core and module classes share the existing Classes index, search, and URLs. Only immediate `doc_classes/*.xml` files under each module are included, not unrelated XML or module source code. All selected XML is validated together before publication; duplicate class names or invalid module XML retain the last valid snapshot. + +Existing core-only caches remain available while a one-time staged refresh adds module documentation, even if the engine commit is unchanged. Cache metadata records the selection revision and documentation directories so a missing module documentation directory triggers a repair. `ClassDocumentation:RepositoryPath` continues to configure the core XML directory; module discovery always uses `modules/*/doc_classes`. + `/health/class-docs` reports the active commit and class count for each documentation version and returns HTTP 503 if any configured version has no usable snapshot. --- diff --git a/Redot-Documentation-Tests/ClassDocumentationParserTests.cs b/Redot-Documentation-Tests/ClassDocumentationParserTests.cs index 6e35ad4..7b1a40a 100644 --- a/Redot-Documentation-Tests/ClassDocumentationParserTests.cs +++ b/Redot-Documentation-Tests/ClassDocumentationParserTests.cs @@ -128,6 +128,22 @@ public void ParseFile_PreservesEmptyStatusAttributes() Assert.Equal(string.Empty, entry.Deprecated); } + [Fact] + public void ParseDirectories_ReportsBothFilesForDuplicateClasses() + { + string core = Path.Combine(_directory, "core"); + string module = Path.Combine(_directory, "module"); + Directory.CreateDirectory(core); + Directory.CreateDirectory(module); + string first = Path.Combine(core, "Node.xml"); + string second = Path.Combine(module, "Node.xml"); + File.WriteAllText(first, ""); + File.WriteAllText(second, ""); + var error = Assert.Throws(() => new ClassDocumentationParser().ParseDirectories([core, module])); + Assert.Contains(first, error.Message); + Assert.Contains(second, error.Message); + } + public void Dispose() { if (Directory.Exists(_directory)) diff --git a/Redot-Documentation-Tests/ClassDocumentationSyncServiceTests.cs b/Redot-Documentation-Tests/ClassDocumentationSyncServiceTests.cs index 59baaa8..2eda08a 100644 --- a/Redot-Documentation-Tests/ClassDocumentationSyncServiceTests.cs +++ b/Redot-Documentation-Tests/ClassDocumentationSyncServiceTests.cs @@ -85,6 +85,34 @@ public async Task StartAsync_DoesNotPromoteOrPublishMalformedClassData(string xm Assert.False(catalog.TryGetSnapshot(manager.NextPrereleaseVersion.Slug, out _)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StartAsync_ValidatesCoreAndModulesBeforePublishingTogether(bool invalidModule) + { + var manager = CreateVersionManager(); + string core = Path.Combine(_contentRootPath, "core"); + string module = Path.Combine(_contentRootPath, "module"); + Directory.CreateDirectory(core); + Directory.CreateDirectory(module); + File.WriteAllText(Path.Combine(core, "Node.xml"), ""); + File.WriteAllText(Path.Combine(module, "ModuleClass.xml"), invalidModule ? "" : ""); + var source = new TestSource(core) { DocumentationPaths = [core, module] }; + var catalog = new ClassDocumentationCatalog(); + using var service = CreateService(manager, source, catalog); + + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + Assert.Equal(invalidModule ? 0 : manager.Versions.Count, source.PromotionCount); + foreach (var version in manager.Versions) + { + Assert.Equal(!invalidModule, catalog.TryGetSnapshot(version.Slug, out var snapshot)); + if (!invalidModule) + Assert.Equal(["ModuleClass", "Node"], snapshot!.Classes.Keys.Order(StringComparer.Ordinal)); + } + } + private static ClassDocumentationSyncService CreateService( VersionManagerService manager, IClassDocumentationSource source, ClassDocumentationCatalog catalog) => new(manager, source, new ClassDocumentationParser(), catalog, @@ -94,6 +122,7 @@ private static ClassDocumentationSyncService CreateService( private sealed class TestSource(string classesPath) : IClassDocumentationSource { public bool DenyCacheAccess { get; init; } + public IReadOnlyList? DocumentationPaths { get; init; } public int PrepareCount { get; private set; } public int PromotionCount { get; private set; } @@ -109,7 +138,7 @@ public Task PrepareAsync(DocumentationVersion versio { PrepareCount++; return Task.FromResult(ClassDocumentationCheckout.Pending(version, "new-commit", classesPath, classesPath, - Path.Combine(classesPath, "unused-staging"), () => PromotionCount++)); + Path.Combine(classesPath, "unused-staging"), () => PromotionCount++, DocumentationPaths)); } } diff --git a/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs b/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs index 486d44c..0bfd31b 100644 --- a/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs +++ b/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs @@ -119,6 +119,7 @@ public async Task InterruptedPromotion_RecoversMatchingCheckoutAndMetadata(bool File.WriteAllText(Path.Combine(sourcePath, "doc", "classes", "Node.xml"), "Updated node."); + AddModule(sourcePath, "alpha", "Alpha"); RunGit(sourcePath, "add", "."); RunGit(sourcePath, "commit", "-m", "Update class docs"); RunGit(sourcePath, "push", remote, "master"); @@ -145,7 +146,9 @@ public async Task InterruptedPromotion_RecoversMatchingCheckoutAndMetadata(bool using (recovered) { Assert.Equal(expectedCommit, recovered!.CommitSha); - var node = Assert.Single(new ClassDocumentationParser().ParseDirectory(recovered.ClassDocumentationPath)).Value; + var classes = new ClassDocumentationParser().ParseDirectories(recovered.ClassDocumentationPaths); + Assert.Equal(phase >= 2, classes.ContainsKey("Alpha")); + var node = classes["Node"]; Assert.Equal(phase >= 2 ? "Updated node." : "A node.", node.BriefDescription); } using var metadata = JsonDocument.Parse(File.ReadAllText(metadataPath)); @@ -174,6 +177,136 @@ public async Task TryGetCurrent_SupportsLegacyMetadataButDoesNotFallBackFromCorr Assert.False(source.TryGetCurrent(version, out _)); } + [Fact] + public async Task Modules_AreDiscoveredAcrossUpdatesAndMissingCachedDirectoriesAreRepaired() + { + string remote = CreateRemoteRepository(out string work); + AddModule(work, "alpha", "Alpha"); + File.WriteAllText(Path.Combine(work, "modules", "alpha", "source.cpp"), "not documentation"); + File.WriteAllText(Path.Combine(work, "modules", "alpha", "unrelated.xml"), ""); + CommitAndPush(work, remote); + var source = CreateSource(remote); + var version = CreateVersion(); + var parser = new ClassDocumentationParser(); + using (var candidate = await source.PrepareAsync(version, CancellationToken.None)) + { + Assert.Equal(2, candidate.ClassDocumentationPaths.Count); + Assert.Equal(2, parser.ParseDirectories(candidate.ClassDocumentationPaths).Count); + Assert.False(File.Exists(Path.Combine(candidate.RepositoryPath, "modules", "alpha", "source.cpp"))); + Assert.False(File.Exists(Path.Combine(candidate.RepositoryPath, "modules", "alpha", "unrelated.xml"))); + candidate.Promote(); + } + Assert.True(source.TryGetCurrent(version, out var cached)); + using (cached) + Assert.Contains("Alpha", parser.ParseDirectories(cached!.ClassDocumentationPaths).Keys); + + Directory.Delete(Path.Combine(_root, "cache", "latest", "repository", "modules", "alpha", "doc_classes"), true); + Assert.False(source.TryGetCurrent(version, out _)); + using (var repair = await source.PrepareAsync(version, CancellationToken.None)) + { + Assert.True(repair.IsPending); + Assert.Contains("Alpha", parser.ParseDirectories(repair.ClassDocumentationPaths).Keys); + repair.Promote(); + } + + Directory.Delete(Path.Combine(work, "modules", "alpha"), true); + AddModule(work, "new_module", "NewModule"); + CommitAndPush(work, remote); + using var updated = await source.PrepareAsync(version, CancellationToken.None); + var classes = parser.ParseDirectories(updated.ClassDocumentationPaths); + Assert.Contains("NewModule", classes.Keys); + Assert.DoesNotContain("Alpha", classes.Keys); + } + + [Fact] + public async Task LegacyCoreOnlyCache_IsReadableButRefreshedAtTheSameCommit() + { + string remote = CreateRemoteRepository(out string work); + AddModule(work, "alpha", "Alpha"); + CommitAndPush(work, remote); + var source = CreateSource(remote); + var version = CreateVersion(); + using (var initial = await source.PrepareAsync(version, CancellationToken.None)) + initial.Promote(); + string root = Path.Combine(_root, "cache", "latest"); + string marker = Path.Combine(root, "repository", ".redot-class-doc-sync.json"); + var metadata = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(marker))!.AsObject(); + metadata.Remove("SelectionRevision"); + metadata.Remove("CoreDocumentationPath"); + metadata.Remove("DocumentationDirectories"); + File.WriteAllText(marker, metadata.ToJsonString()); + Directory.Delete(Path.Combine(root, "repository", "modules"), true); + + Assert.True(source.TryGetCurrent(version, out var cached)); + using (cached) + Assert.Single(cached!.ClassDocumentationPaths); + using var upgrade = await source.PrepareAsync(version, CancellationToken.None); + Assert.True(upgrade.IsPending); + Assert.Equal(metadata["CommitSha"]!.GetValue(), upgrade.CommitSha); + Assert.Contains("Alpha", new ClassDocumentationParser().ParseDirectories(upgrade.ClassDocumentationPaths).Keys); + upgrade.Promote(); + using var current = await source.PrepareAsync(version, CancellationToken.None); + Assert.False(current.IsPending); + } + + [Theory] + [InlineData("")] + [InlineData("")] + public async Task InvalidModule_LeavesPreviousSnapshotAvailable(string xml) + { + string remote = CreateRemoteRepository(out string work); + var source = CreateSource(remote); + var version = CreateVersion(); + using (var initial = await source.PrepareAsync(version, CancellationToken.None)) + initial.Promote(); + AddModule(work, "bad", "Bad"); + File.WriteAllText(Path.Combine(work, "modules", "bad", "doc_classes", "Bad.xml"), xml); + CommitAndPush(work, remote); + using (var candidate = await source.PrepareAsync(version, CancellationToken.None)) + Assert.Throws(() => new ClassDocumentationParser().ParseDirectories(candidate.ClassDocumentationPaths)); + Assert.True(source.TryGetCurrent(version, out var cached)); + using (cached) + Assert.Single(new ClassDocumentationParser().ParseDirectories(cached!.ClassDocumentationPaths)); + } + + [Fact] + public async Task Versions_DiscoverTheirOwnModules() + { + string remote = CreateRemoteRepository(out string work); + RunGit(work, "push", remote, "HEAD:refs/heads/26.1"); + AddModule(work, "new_feature", "NewFeature"); + CommitAndPush(work, remote); + var source = CreateSource(remote); + var stableVersion = new DocumentationVersion { Slug = "26.1", FriendlyName = "Stable", BranchName = "26.1" }; + using var stable = await source.PrepareAsync(stableVersion, CancellationToken.None); + using var latest = await source.PrepareAsync(CreateVersion(), CancellationToken.None); + var parser = new ClassDocumentationParser(); + Assert.Single(parser.ParseDirectories(stable.ClassDocumentationPaths)); + var latestClasses = parser.ParseDirectories(latest.ClassDocumentationPaths); + Assert.Contains("NewFeature", latestClasses.Keys); + var snapshot = new ClassDocumentationSnapshot(latest.Version, latest.CommitSha, latest.SynchronizedAt, latestClasses); + var catalog = new ClassDocumentationCatalog(); + catalog.Publish(snapshot); + Assert.Contains(catalog.GetClassesAlphabetically("latest"), entry => entry.Name == "NewFeature"); + Assert.True(catalog.TryGetClass("latest", "NewFeature", out var module)); + string html = new ClassDocumentationRenderer().RenderPage(module!, snapshot); + Assert.Contains("/en/latest/Classes/Node", html); + } + + private static void AddModule(string work, string module, string name) + { + string path = Path.Combine(work, "modules", module, "doc_classes"); + Directory.CreateDirectory(path); + File.WriteAllText(Path.Combine(path, name + ".xml"), $""); + } + + private static void CommitAndPush(string work, string remote) + { + RunGit(work, "add", "."); + RunGit(work, "commit", "-m", "Update modules"); + RunGit(work, "push", remote, "master"); + } + private GitClassDocumentationSource CreateSource(string repositoryUrl) { var options = Options.Create(new ClassDocumentationOptions diff --git a/Redot-Documentation/ClassDocumentation/ClassDocumentationParser.cs b/Redot-Documentation/ClassDocumentation/ClassDocumentationParser.cs index 9948372..4eb09cc 100644 --- a/Redot-Documentation/ClassDocumentation/ClassDocumentationParser.cs +++ b/Redot-Documentation/ClassDocumentation/ClassDocumentationParser.cs @@ -15,20 +15,34 @@ public sealed class ClassDocumentationParser /// A class file cannot be read. /// A class file cannot be accessed. public IReadOnlyDictionary ParseDirectory(string classDocumentationPath) - { - if (!Directory.Exists(classDocumentationPath)) - throw new DirectoryNotFoundException($"Class documentation directory was not found: {classDocumentationPath}"); + => ParseDirectories([classDocumentationPath]); + /// Parses and merges core and module directories into one class namespace. + public IReadOnlyDictionary ParseDirectories(IEnumerable paths) + { var classes = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string filePath in Directory.EnumerateFiles(classDocumentationPath, "*.xml", SearchOption.TopDirectoryOnly)) + var sources = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string directory in paths.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) { - ClassDocumentationEntry entry = ParseFile(filePath); - if (!classes.TryAdd(entry.Name, entry)) - throw new InvalidDataException($"Duplicate class documentation entry '{entry.Name}'."); + if (!Directory.Exists(directory)) + throw new DirectoryNotFoundException($"Class documentation directory was not found: {directory}"); + + string[] files = Directory.GetFiles(directory, "*.xml", SearchOption.TopDirectoryOnly); + if (files.Length == 0) + throw new InvalidDataException($"No class XML files were found in '{directory}'."); + + foreach (string filePath in files.Order(StringComparer.Ordinal)) + { + ClassDocumentationEntry entry = ParseFile(filePath); + if (!classes.TryAdd(entry.Name, entry)) + throw new InvalidDataException( + $"Duplicate class documentation entry '{entry.Name}' in '{sources[entry.Name]}' and '{filePath}'."); + sources.Add(entry.Name, filePath); + } } if (classes.Count == 0) - throw new InvalidDataException($"No class XML files were found in '{classDocumentationPath}'."); + throw new InvalidDataException("No class documentation directories were supplied."); return new ReadOnlyDictionary(classes); } diff --git a/Redot-Documentation/ClassDocumentation/ClassDocumentationSyncService.cs b/Redot-Documentation/ClassDocumentation/ClassDocumentationSyncService.cs index c9dc933..3db8aab 100644 --- a/Redot-Documentation/ClassDocumentation/ClassDocumentationSyncService.cs +++ b/Redot-Documentation/ClassDocumentation/ClassDocumentationSyncService.cs @@ -262,7 +262,7 @@ private ClassDocumentationSnapshot CreateSnapshot(ClassDocumentationCheckout che checkout.Version, checkout.CommitSha, checkout.SynchronizedAt, - _parser.ParseDirectory(checkout.ClassDocumentationPath)); + _parser.ParseDirectories(checkout.ClassDocumentationPaths)); /// Shortens a commit identifier for logging. /// The full commit identifier. diff --git a/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs b/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs index c67374d..9b6fdc2 100644 --- a/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs +++ b/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs @@ -35,6 +35,9 @@ public sealed class GitClassDocumentationSource : IClassDocumentationSource /// Defines the synchronization metadata file name. private const string MetadataFileName = "sync.json"; + /// Identifies the core-and-module sparse selection stored in cache metadata. + private const int SelectionRevision = 1; + /// Stores metadata inside the checkout so it is promoted with the repository. private const string RepositoryMetadataFileName = ".redot-class-doc-sync.json"; @@ -87,13 +90,16 @@ public async Task PrepareAsync( string activeClassDocumentationPath = GetClassDocumentationPath(activeRepositoryPath); if (string.Equals(currentCommit, remoteCommit, StringComparison.OrdinalIgnoreCase) && Directory.Exists(Path.Combine(activeRepositoryPath, ".git")) - && Directory.Exists(activeClassDocumentationPath)) + && HasCurrentSelection(currentMetadata!) + && TryGetDocumentationPaths(activeRepositoryPath, currentMetadata!, out var activePaths)) { return ClassDocumentationCheckout.Current( version, remoteCommit, activeRepositoryPath, - activeClassDocumentationPath); + activeClassDocumentationPath, + currentMetadata!.SynchronizedAt, + activePaths); } string versionRoot = GetVersionRoot(version); @@ -108,7 +114,7 @@ await _git.RunAsync( "clone", "--depth", "1", "--filter=blob:none", - "--sparse", + "--no-checkout", "--single-branch", "--no-tags", "--branch", version.BranchName, @@ -120,11 +126,20 @@ await _git.RunAsync( cancellationToken); await _git.RunAsync( - ["-C", stagingRepositoryPath, "sparse-checkout", "set", "--cone", "--", _options.RepositoryPath], + ["-C", stagingRepositoryPath, "sparse-checkout", "set", "--no-cone", "--", + $"/{EscapeSparsePattern(CoreDocumentationPath)}/*.xml", "/modules/*/doc_classes/*.xml"], + versionRoot, + _options.GitTimeout, + cancellationToken); + + await _git.RunAsync( + ["-C", stagingRepositoryPath, "checkout", "--detach", "HEAD"], versionRoot, _options.GitTimeout, cancellationToken); + string[] documentationPaths = DiscoverDocumentationPaths(stagingRepositoryPath); + GitCommandResult commitResult = await _git.RunAsync( ["-C", stagingRepositoryPath, "rev-parse", "HEAD"], versionRoot, @@ -137,7 +152,10 @@ await _git.RunAsync( commitResult.StandardOutput, version.BranchName, _options.RepositoryUrl, - DateTimeOffset.UtcNow)); + DateTimeOffset.UtcNow, + SelectionRevision, + CoreDocumentationPath, + documentationPaths.Select(path => Path.GetRelativePath(stagingRepositoryPath, path).Replace('\\', '/')).ToArray())); return ClassDocumentationCheckout.Pending( version, @@ -145,7 +163,8 @@ await _git.RunAsync( stagingRepositoryPath, GetClassDocumentationPath(stagingRepositoryPath), stagingRoot, - () => Promote(version, stagingRepositoryPath, stagingRoot)); + () => Promote(version, stagingRepositoryPath, stagingRoot), + documentationPaths); } catch { @@ -163,7 +182,8 @@ public bool TryGetCurrent( ClassDocumentationSyncMetadata? metadata = ReadMetadata(version); string repositoryPath = GetActiveRepositoryPath(version); string classDocumentationPath = GetClassDocumentationPath(repositoryPath); - if (!IsMetadataCompatible(metadata, version) || !Directory.Exists(classDocumentationPath)) + if (!IsMetadataCompatible(metadata, version) + || !TryGetDocumentationPaths(repositoryPath, metadata!, out var documentationPaths)) { checkout = null; return false; @@ -174,7 +194,8 @@ public bool TryGetCurrent( metadata!.CommitSha, repositoryPath, classDocumentationPath, - metadata.SynchronizedAt); + metadata.SynchronizedAt, + documentationPaths); return true; } @@ -298,7 +319,8 @@ private void RecoverInterruptedPromotion(DocumentationVersion version) { ClassDocumentationSyncMetadata? metadata = ReadMetadataFile(repositoryMetadataPath, version); if (metadata is not null - && metadata != ReadMetadataFile(Path.Combine(versionRoot, MetadataFileName), version)) + && JsonSerializer.Serialize(metadata) != JsonSerializer.Serialize( + ReadMetadataFile(Path.Combine(versionRoot, MetadataFileName), version))) WriteMetadata(version, metadata); } } @@ -368,6 +390,69 @@ private bool IsMetadataCompatible( && string.Equals(metadata.BranchName, version.BranchName, StringComparison.Ordinal) && string.Equals(metadata.RepositoryUrl, _options.RepositoryUrl, StringComparison.Ordinal); + /// Gets the normalized core directory used by Git and cache metadata. + private string CoreDocumentationPath => _options.RepositoryPath.Replace('\\', '/').TrimEnd('/'); + + /// Checks whether a checkout includes the current documentation selection. + private bool HasCurrentSelection(ClassDocumentationSyncMetadata metadata) + => metadata.SelectionRevision == SelectionRevision + && string.Equals(metadata.CoreDocumentationPath, CoreDocumentationPath, StringComparison.Ordinal) + && metadata.DocumentationDirectories is { Length: > 0 }; + + /// Discovers module XML directories without executing module configuration. + private string[] DiscoverDocumentationPaths(string repositoryPath) + { + var paths = new List { GetClassDocumentationPath(repositoryPath) }; + string modulesPath = Path.Combine(repositoryPath, "modules"); + if (Directory.Exists(modulesPath)) + { + paths.AddRange(Directory.EnumerateDirectories(modulesPath) + .Select(module => Path.Combine(module, "doc_classes")) + .Where(path => Directory.Exists(path) && Directory.EnumerateFiles(path, "*.xml").Any()) + .Order(StringComparer.Ordinal)); + } + return paths.Distinct(StringComparer.Ordinal).ToArray(); + } + + /// Validates recorded directories before reusing a cached snapshot. + private bool TryGetDocumentationPaths(string repositoryPath, ClassDocumentationSyncMetadata metadata, + out string[] paths) + { + paths = []; + // Legacy core-only caches remain readable while a new staged checkout is prepared. + if (metadata.SelectionRevision == 0) + { + string core = GetClassDocumentationPath(repositoryPath); + if (!Directory.Exists(core)) + return false; + paths = [core]; + return true; + } + if (!HasCurrentSelection(metadata)) + return false; + + var relativePaths = metadata.DocumentationDirectories!; + if (!relativePaths.Contains(CoreDocumentationPath, StringComparer.Ordinal) + || relativePaths.Any(path => path != CoreDocumentationPath && !IsModuleDocumentationPath(path))) + return false; + paths = relativePaths.Select(path => Path.Combine(repositoryPath, path)).ToArray(); + return paths.All(path => Directory.Exists(path) && Directory.EnumerateFiles(path, "*.xml").Any()); + } + + private static bool IsModuleDocumentationPath(string? path) + { + if (string.IsNullOrEmpty(path)) + return false; + string[] parts = path.Split('/'); + return parts.Length == 3 && parts[0] == "modules" && parts[2] == "doc_classes" + && parts[1].Length > 0 && parts[1] is not "." and not ".." && !parts[1].Contains('\\'); + } + + /// Keeps the configured core directory literal in a Git sparse-checkout pattern. + private static string EscapeSparsePattern(string path) + => path.Replace("\\", "\\\\").Replace("*", "\\*").Replace("?", "\\?") + .Replace("[", "\\[").Replace("]", "\\]"); + /// Gets and creates a version cache root. /// The documentation version. /// The absolute version root. @@ -395,7 +480,7 @@ private string GetActiveRepositoryPath(DocumentationVersion version) /// The configured path escapes the checkout. private string GetClassDocumentationPath(string repositoryPath) { - string path = Path.GetFullPath(Path.Combine(repositoryPath, _options.RepositoryPath)); + string path = Path.GetFullPath(Path.Combine(repositoryPath, CoreDocumentationPath)); string repositoryPathWithSeparator = Path.GetFullPath(repositoryPath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; if (!path.StartsWith(repositoryPathWithSeparator, StringComparison.Ordinal)) @@ -411,6 +496,9 @@ private void ValidateOptions() throw new InvalidOperationException("ClassDocumentation:RepositoryUrl is required."); if (string.IsNullOrWhiteSpace(_options.RepositoryPath) || Path.IsPathRooted(_options.RepositoryPath)) throw new InvalidOperationException("ClassDocumentation:RepositoryPath must be a relative repository path."); + if (CoreDocumentationPath.Split('/').Any(part => part.Length == 0 || part is "." or "..") + || CoreDocumentationPath.Contains('\n') || CoreDocumentationPath.Contains('\r')) + throw new InvalidOperationException("ClassDocumentation:RepositoryPath must contain normalized path segments."); if (_options.RefreshInterval <= TimeSpan.Zero) throw new InvalidOperationException("ClassDocumentation:RefreshInterval must be greater than zero."); if (_options.GitTimeout <= TimeSpan.Zero) @@ -431,12 +519,18 @@ private static void DeleteDirectoryIfPresent(string path) /// The source commit identifier. /// The source branch name. /// The source repository URL. - /// The promotion time. + /// The checkout preparation time. + /// The sparse selection revision; zero denotes a legacy core-only cache. + /// The configured core directory for this checkout. + /// The selected repository-relative XML directories. private sealed record ClassDocumentationSyncMetadata( string CommitSha, string BranchName, string RepositoryUrl, - DateTimeOffset SynchronizedAt); + DateTimeOffset SynchronizedAt, + int SelectionRevision = 0, + string? CoreDocumentationPath = null, + string[]? DocumentationDirectories = null); } /// Represents a current or pending class-documentation checkout. @@ -460,6 +554,7 @@ public sealed class ClassDocumentationCheckout : IDisposable /// The synchronization time. /// The optional staging root. /// The optional promotion action. + /// All selected XML directories, or the core directory by default. private ClassDocumentationCheckout( DocumentationVersion version, string commitSha, @@ -468,12 +563,14 @@ private ClassDocumentationCheckout( bool isPending, DateTimeOffset synchronizedAt, string? stagingRoot, - Action? promote) + Action? promote, + IReadOnlyList? classDocumentationPaths) { Version = version; CommitSha = commitSha; RepositoryPath = repositoryPath; ClassDocumentationPath = classDocumentationPath; + ClassDocumentationPaths = classDocumentationPaths ?? [classDocumentationPath]; IsPending = isPending; SynchronizedAt = synchronizedAt; _stagingRoot = stagingRoot; @@ -492,6 +589,9 @@ private ClassDocumentationCheckout( /// Gets the class-documentation path. public string ClassDocumentationPath { get; } + /// Gets all core and module class-documentation directories. + public IReadOnlyList ClassDocumentationPaths { get; } + /// Gets whether the checkout awaits promotion. public bool IsPending { get; } @@ -525,6 +625,7 @@ public void Dispose() /// The class-documentation path. /// The staging root. /// The promotion action. + /// All selected XML directories. /// The pending checkout. internal static ClassDocumentationCheckout Pending( DocumentationVersion version, @@ -532,7 +633,8 @@ internal static ClassDocumentationCheckout Pending( string repositoryPath, string classDocumentationPath, string stagingRoot, - Action promote) + Action promote, + IReadOnlyList? classDocumentationPaths = null) => new( version, commitSha, @@ -541,7 +643,8 @@ internal static ClassDocumentationCheckout Pending( isPending: true, DateTimeOffset.UtcNow, stagingRoot, - promote); + promote, + classDocumentationPaths); /// Creates an active checkout. /// The documentation version. @@ -549,13 +652,15 @@ internal static ClassDocumentationCheckout Pending( /// The active repository path. /// The class-documentation path. /// The optional synchronization time. + /// All selected XML directories. /// The active checkout. internal static ClassDocumentationCheckout Current( DocumentationVersion version, string commitSha, string repositoryPath, string classDocumentationPath, - DateTimeOffset? synchronizedAt = null) + DateTimeOffset? synchronizedAt = null, + IReadOnlyList? classDocumentationPaths = null) => new( version, commitSha, @@ -564,5 +669,6 @@ internal static ClassDocumentationCheckout Current( isPending: false, synchronizedAt ?? DateTimeOffset.UtcNow, null, - null); + null, + classDocumentationPaths); } From 56e7c174cc46d9a22469abfd8e14f2f7bfe5c903 Mon Sep 17 00:00:00 2001 From: Arctis Fireblight <6182060+Arctis-Fireblight@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:32:18 -0500 Subject: [PATCH 2/2] Enhance class documentation synchronization and add test for module directory omission repair logic --- .../GitClassDocumentationSourceTests.cs | 39 +++++++++++++++++++ .../GitClassDocumentationSource.cs | 8 +++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs b/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs index 0bfd31b..2dffc62 100644 --- a/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs +++ b/Redot-Documentation-Tests/GitClassDocumentationSourceTests.cs @@ -218,6 +218,45 @@ public async Task Modules_AreDiscoveredAcrossUpdatesAndMissingCachedDirectoriesA Assert.DoesNotContain("Alpha", classes.Keys); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CurrentSelection_WithOmittedModuleDirectory_IsRejectedAndRepaired(bool hasSecondModule) + { + string remote = CreateRemoteRepository(out string work); + AddModule(work, "alpha", "Alpha"); + if (hasSecondModule) + AddModule(work, "beta", "Beta"); + CommitAndPush(work, remote); + var source = CreateSource(remote); + var version = CreateVersion(); + using (var initial = await source.PrepareAsync(version, CancellationToken.None)) + initial.Promote(); + + string repository = Path.Combine(_root, "cache", "latest", "repository"); + string marker = Path.Combine(repository, ".redot-class-doc-sync.json"); + var metadata = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(marker))!.AsObject(); + int revision = metadata["SelectionRevision"]!.GetValue(); + var directories = metadata["DocumentationDirectories"]!.AsArray(); + directories.Remove(directories.Single(path => path!.GetValue() == "modules/alpha/doc_classes")); + File.WriteAllText(marker, metadata.ToJsonString()); + + Assert.True(revision > 0); + Assert.True(Directory.Exists(Path.Combine(repository, "modules", "alpha", "doc_classes"))); + Assert.False(source.TryGetCurrent(version, out _)); + + using var repair = await source.PrepareAsync(version, CancellationToken.None); + Assert.True(repair.IsPending); + Assert.Equal(metadata["CommitSha"]!.GetValue(), repair.CommitSha); + Assert.Equal(hasSecondModule ? 3 : 2, repair.ClassDocumentationPaths.Count); + Assert.Contains("Alpha", new ClassDocumentationParser().ParseDirectories(repair.ClassDocumentationPaths).Keys); + repair.Promote(); + using var current = await source.PrepareAsync(version, CancellationToken.None); + Assert.False(current.IsPending); + var repairedMetadata = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(marker))!; + Assert.Equal(revision, repairedMetadata["SelectionRevision"]!.GetValue()); + } + [Fact] public async Task LegacyCoreOnlyCache_IsReadableButRefreshedAtTheSameCommit() { diff --git a/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs b/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs index 9b6fdc2..b916785 100644 --- a/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs +++ b/Redot-Documentation/ClassDocumentation/GitClassDocumentationSource.cs @@ -435,7 +435,13 @@ private bool TryGetDocumentationPaths(string repositoryPath, ClassDocumentationS if (!relativePaths.Contains(CoreDocumentationPath, StringComparer.Ordinal) || relativePaths.Any(path => path != CoreDocumentationPath && !IsModuleDocumentationPath(path))) return false; - paths = relativePaths.Select(path => Path.Combine(repositoryPath, path)).ToArray(); + string[] discoveredPaths = DiscoverDocumentationPaths(repositoryPath); + var discoveredRelativePaths = discoveredPaths + .Select(path => Path.GetRelativePath(repositoryPath, path).Replace('\\', '/')); + if (!new HashSet(relativePaths, StringComparer.Ordinal).SetEquals(discoveredRelativePaths)) + return false; + + paths = discoveredPaths; return paths.All(path => Directory.Exists(path) && Directory.EnumerateFiles(path, "*.xml").Any()); }