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());
}