From 5e579f636fef40c3c605375802f8cbc8a9075fe4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:01:59 +0000 Subject: [PATCH 1/2] Initial plan From 3b9fb8520432ac62b2ca0a029d14b7dc5cedb4ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:11:37 +0000 Subject: [PATCH 2/2] Add local code snippet importer prototype with regression tests Co-authored-by: gewarren <24882762+gewarren@users.noreply.github.com> --- .../CodeSnippetImporter.csproj | 10 + CodeSnippetImporter/Program.cs | 38 +++ CodeSnippetImporter/SnippetImporter.cs | 200 ++++++++++++ .../DotnetDocsTools.Tests.csproj | 1 + DotnetDocsToolsTests/SnippetImporterTests.cs | 296 ++++++++++++++++++ README.md | 53 ++++ docs-tools.sln | 6 + 7 files changed, 604 insertions(+) create mode 100644 CodeSnippetImporter/CodeSnippetImporter.csproj create mode 100644 CodeSnippetImporter/Program.cs create mode 100644 CodeSnippetImporter/SnippetImporter.cs create mode 100644 DotnetDocsToolsTests/SnippetImporterTests.cs diff --git a/CodeSnippetImporter/CodeSnippetImporter.csproj b/CodeSnippetImporter/CodeSnippetImporter.csproj new file mode 100644 index 00000000..ed9781c2 --- /dev/null +++ b/CodeSnippetImporter/CodeSnippetImporter.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/CodeSnippetImporter/Program.cs b/CodeSnippetImporter/Program.cs new file mode 100644 index 00000000..0326b7ad --- /dev/null +++ b/CodeSnippetImporter/Program.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using CodeSnippetImporter; + +if (args.Contains("--help")) +{ + PrintUsage(); + return 0; +} + +bool dryRun = args.Contains("--dry-run"); +string[] paths = args.Where(arg => arg != "--dry-run").ToArray(); +if (paths.Length is < 3 or > 4 || paths.Any(path => path.StartsWith("--", StringComparison.Ordinal))) +{ + PrintUsage(); + return 2; +} + +try +{ + Dictionary? sourceMap = paths.Length == 4 + ? JsonSerializer.Deserialize>(File.ReadAllText(paths[3])) + ?? throw new InvalidDataException("The source map must be a JSON object.") + : null; + ImportResult result = new SnippetImporter().Import(paths[0], paths[1], paths[2], sourceMap, dryRun); + foreach (string error in result.Errors) + Console.Error.WriteLine(error); + Console.WriteLine($"{(dryRun ? "Would import" : "Imported")} {result.ImportedFiles} file(s), " + + $"{(dryRun ? "would update" : "updated")} {result.UpdatedReferences} reference(s), {result.Errors.Count} error(s)."); + return result.Errors.Count == 0 ? 0 : 1; +} +catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException or JsonException) +{ + Console.Error.WriteLine(ex.Message); + return 1; +} + +static void PrintUsage() => Console.WriteLine( + "Usage: CodeSnippetImporter [source-map.json] [--dry-run]"); diff --git a/CodeSnippetImporter/SnippetImporter.cs b/CodeSnippetImporter/SnippetImporter.cs new file mode 100644 index 00000000..67c40d2b --- /dev/null +++ b/CodeSnippetImporter/SnippetImporter.cs @@ -0,0 +1,200 @@ +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace CodeSnippetImporter; + +public sealed record ImportResult(int ImportedFiles, int UpdatedReferences, IReadOnlyList Errors); + +public sealed class SnippetImporter +{ + private static readonly Regex MarkdownReference = new( + @"\[!code-[\w-]+\[[^\]\r\n]*\]\((?[^)\r\n]+)\)\]", + RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + + public ImportResult Import(string documentationDirectory, string sourceDirectory, string snippetDirectory, + IReadOnlyDictionary? sourceMap = null, bool dryRun = false) + { + string docsRoot = Path.GetFullPath(documentationDirectory); + string sourceRoot = Path.GetFullPath(sourceDirectory); + string outputRoot = Path.GetFullPath(snippetDirectory); + EnsureSafePath(docsRoot, docsRoot); + EnsureSafePath(sourceRoot, sourceRoot); + EnsureSafePath(outputRoot, outputRoot); + if (!Directory.Exists(docsRoot) || !Directory.Exists(sourceRoot)) + throw new DirectoryNotFoundException("Documentation and source directories must exist."); + if (IsWithin(sourceRoot, outputRoot) || IsWithin(outputRoot, sourceRoot)) + throw new InvalidDataException("Source and snippet directories must not overlap."); + + var errors = new List(); + var imported = new HashSet(PathComparer); + int updated = 0; + var enumeration = new EnumerationOptions + { + RecurseSubdirectories = true, + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false + }; + foreach (string docPath in Directory.EnumerateFiles(docsRoot, "*.xml", enumeration).Order(StringComparer.Ordinal)) + { + try + { + EnsureSafePath(docsRoot, docPath); + XDocument document; + using (var reader = XmlReader.Create(docPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit })) + document = XDocument.Load(reader, LoadOptions.PreserveWhitespace); + bool changed = false; + int documentUpdates = 0; + foreach (XElement docs in document.Descendants("Docs")) + { + foreach (XAttribute reference in docs.Descendants("code").Attributes("source").ToArray()) + { + string replacement = Resolve(reference.Value, docs, reference.Parent?.Attribute("region")?.Value); + if (replacement != reference.Value) + { + reference.Value = replacement; + changed = true; + } + } + foreach (XText text in docs.DescendantNodes().OfType().ToArray()) + { + string replacement = MarkdownReference.Replace(text.Value, match => + { + Group target = match.Groups["target"]; + string resolved = Resolve(target.Value, docs, null); + return match.Value[..(target.Index - match.Index)] + resolved + + match.Value[(target.Index - match.Index + target.Length)..]; + }); + if (replacement != text.Value) + { + text.Value = replacement; + changed = true; + } + } + } + if (changed && !dryRun) + { + using var writer = XmlWriter.Create(docPath, new XmlWriterSettings + { + OmitXmlDeclaration = document.Declaration == null, + Indent = false + }); + document.Save(writer); + } + updated += documentUpdates; + + string Resolve(string reference, XElement docs, string? region) + { + try + { + int fragmentIndex = reference.IndexOf('#'); + string path = fragmentIndex < 0 ? reference : reference[..fragmentIndex]; + string fragment = fragmentIndex < 0 ? "" : reference[fragmentIndex..]; + if (string.IsNullOrWhiteSpace(path) || path.StartsWith('~') || + Path.IsPathRooted(path) || Uri.TryCreate(path, UriKind.Absolute, out _)) + return reference; + path = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + if (File.Exists(Path.GetFullPath(path, Path.GetDirectoryName(docPath)!))) + return reference; + + string sourceFile = ResolveSourceFile(docs, sourceMap); + string original = Path.GetFullPath(sourceFile.Replace('\\', '/'), sourceRoot); + EnsureSafePath(sourceRoot, original); + if (!File.Exists(original)) + throw new FileNotFoundException($"Original API source file not found: {sourceFile}"); + string snippet = Path.GetFullPath(path, Path.GetDirectoryName(original)!); + EnsureSafePath(sourceRoot, snippet); + if (!File.Exists(snippet)) + throw new FileNotFoundException($"Snippet not found: {snippet}"); + string? selector = region ?? (fragment.Length > 1 ? fragment[1..] : null); + ValidateSelector(snippet, selector, region == null); + + string destination = Path.Combine(outputRoot, Path.GetRelativePath(sourceRoot, snippet)); + EnsureSafePath(outputRoot, destination); + if (File.Exists(destination)) + { + if (!File.ReadAllBytes(destination).AsSpan().SequenceEqual(File.ReadAllBytes(snippet))) + throw new InvalidDataException($"Destination has different content: {destination}"); + } + else if (!imported.Contains(destination)) + { + if (!dryRun) + { + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(snippet, destination, overwrite: false); + } + imported.Add(destination); + } + documentUpdates++; + return Path.GetRelativePath(Path.GetDirectoryName(docPath)!, destination).Replace('\\', '/') + fragment; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException) + { + errors.Add($"{docPath}: '{reference}': {ex.Message}"); + return reference; + } + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or XmlException or RegexMatchTimeoutException) + { + errors.Add($"{docPath}: {ex.Message}"); + } + } + return new ImportResult(imported.Count, updated, errors); + } + + private static string ResolveSourceFile(XElement docs, IReadOnlyDictionary? sourceMap) + { + XElement scope = docs.Parent ?? throw new InvalidDataException("Documentation has no API owner."); + string? docId = scope.Elements().Where(e => e.Name.LocalName is "MemberSignature" or "TypeSignature") + .FirstOrDefault(e => (string?)e.Attribute("Language") == "DocId")?.Attribute("Value")?.Value; + if (docId != null && sourceMap != null && sourceMap.TryGetValue(docId, out string? mapped) && + !string.IsNullOrWhiteSpace(mapped)) + return mapped; + throw new InvalidDataException($"No source mapping for {docId ?? scope.Name.LocalName}. Supply a DocId source map."); + } + + private static void ValidateSelector(string snippet, string? selector, bool allowLineSelection) + { + if (string.IsNullOrEmpty(selector)) + return; + Match lines = Regex.Match(selector, @"^L(?\d+)(?:-L?(?\d+))?$", + RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + if (allowLineSelection && lines.Success) + { + if (!int.TryParse(lines.Groups["start"].Value, out int start) || + !int.TryParse(lines.Groups["end"].Success ? lines.Groups["end"].Value : lines.Groups["start"].Value, out int end) || + start < 1 || end < start || end > File.ReadLines(snippet).Count()) + throw new InvalidDataException($"Invalid line selector '{selector}' in {snippet}"); + } + else if (Path.GetExtension(snippet).Equals(".cs", StringComparison.OrdinalIgnoreCase) && + !File.ReadLines(snippet).Any(line => Regex.IsMatch(line, + @"^\s*(?:#region\s+" + Regex.Escape(selector) + @"\s*|//\s*<" + Regex.Escape(selector) + @">\s*)$", + RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)))) + { + throw new InvalidDataException($"Region '{selector}' not found in {snippet}"); + } + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private static bool IsWithin(string root, string path) + { + string relative = Path.GetRelativePath(root, path); + return !Path.IsPathRooted(relative) && relative != ".." && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal); + } + + private static void EnsureSafePath(string root, string path) + { + if (!IsWithin(root, path)) + throw new InvalidDataException($"Path escapes the allowed directory: {path}"); + for (string? current = path; current != null; current = Path.GetDirectoryName(current)) + { + if (new FileInfo(current).LinkTarget != null || + ((File.Exists(current) || Directory.Exists(current)) && + (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)) + throw new InvalidDataException($"Symbolic links are not supported: {current}"); + } + } +} diff --git a/DotnetDocsToolsTests/DotnetDocsTools.Tests.csproj b/DotnetDocsToolsTests/DotnetDocsTools.Tests.csproj index bb708ebf..ac776d5f 100644 --- a/DotnetDocsToolsTests/DotnetDocsTools.Tests.csproj +++ b/DotnetDocsToolsTests/DotnetDocsTools.Tests.csproj @@ -20,5 +20,6 @@ + \ No newline at end of file diff --git a/DotnetDocsToolsTests/SnippetImporterTests.cs b/DotnetDocsToolsTests/SnippetImporterTests.cs new file mode 100644 index 00000000..38b76b55 --- /dev/null +++ b/DotnetDocsToolsTests/SnippetImporterTests.cs @@ -0,0 +1,296 @@ +using System.Xml.Linq; +using CodeSnippetImporter; +using Xunit; + +namespace DotnetDocsTools.Tests; + +public sealed class SnippetImporterTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "snippet-importer-tests", Guid.NewGuid().ToString("N")); + private string Docs => Path.Combine(_root, "docs"); + private string Source => Path.Combine(_root, "source"); + private string Output => Path.Combine(Docs, "snippets"); + private string DocPath => Path.Combine(Docs, "Example.xml"); + private string ImportedPath => Path.Combine(Output, "tests", "Example.cs"); + private static Dictionary SourceMap => new() { ["T:Example"] = "src/Example.cs" }; + + public SnippetImporterTests() + { + Directory.CreateDirectory(Docs); + Directory.CreateDirectory(Path.Combine(Source, "src")); + Directory.CreateDirectory(Path.Combine(Source, "tests")); + File.WriteAllText(Path.Combine(Source, "src", "Example.cs"), "public class Example {}"); + File.WriteAllText(Path.Combine(Source, "tests", "Example.cs"), "#region Sample\nConsole.WriteLine(1);\n#endregion\n"); + } + + [Fact] + public void ImportsXmlAndMarkdownOnceAndPreservesSelectors() + { + WriteDocumentation(""" + + + + + """); + + ImportResult result = Run(); + + Assert.Empty(result.Errors); + Assert.Equal(1, result.ImportedFiles); + Assert.Equal(2, result.UpdatedReferences); + Assert.Equal(File.ReadAllBytes(Path.Combine(Source, "tests", "Example.cs")), File.ReadAllBytes(ImportedPath)); + XDocument document = XDocument.Load(DocPath); + XElement code = Assert.Single(document.Descendants("code")); + Assert.Equal("snippets/tests/Example.cs", code.Attribute("source")!.Value); + Assert.Equal("Sample", code.Attribute("region")!.Value); + Assert.Equal("[!code-csharp[](snippets/tests/Example.cs#Sample)]", Assert.Single(document.Descendants("format")).Value); + Assert.IsType(Assert.Single(document.Descendants("format")).FirstNode); + + byte[] before = File.ReadAllBytes(DocPath); + ImportResult second = Run(); + Assert.Empty(second.Errors); + Assert.Equal(0, second.ImportedFiles); + Assert.Equal(0, second.UpdatedReferences); + Assert.Equal(before, File.ReadAllBytes(DocPath)); + } + + [Fact] + public void DryRunReportsChangesWithoutWriting() + { + WriteDocumentation(""""""); + byte[] before = File.ReadAllBytes(DocPath); + + ImportResult result = Run(dryRun: true); + + Assert.Empty(result.Errors); + Assert.Equal(1, result.ImportedFiles); + Assert.Equal(1, result.UpdatedReferences); + Assert.False(Directory.Exists(Output)); + Assert.Equal(before, File.ReadAllBytes(DocPath)); + } + + [Theory] + [InlineData("L1")] + [InlineData("L1-L3")] + [InlineData("L1-3")] + public void MarkdownLineSelectorsArePreserved(string selector) + { + WriteDocumentation($""); + + ImportResult result = Run(); + + Assert.Empty(result.Errors); + Assert.Contains($"snippets/tests/Example.cs#{selector}", File.ReadAllText(DocPath)); + } + + [Theory] + [InlineData("L0")] + [InlineData("L3-L1")] + [InlineData("L1-L100")] + public void InvalidMarkdownLineSelectorsAreReported(string selector) + { + WriteDocumentation($""); + + ImportResult result = Run(); + + Assert.Contains("Invalid line selector", Assert.Single(result.Errors)); + Assert.Equal(0, result.UpdatedReferences); + } + + [Fact] + public void CommentRegionMarkersAreSupported() + { + File.WriteAllText(Path.Combine(Source, "tests", "Example.cs"), "// \nConsole.WriteLine(1);\n// "); + WriteDocumentation(""""""); + + Assert.Empty(Run().Errors); + } + + [Theory] + [InlineData("../tests/Missing.cs", null, "Snippet not found")] + [InlineData("../tests/Example.cs", "Missing", "Region 'Missing' not found")] + [InlineData("../../outside.cs", null, "Path escapes")] + public void UnresolvedReferenceIsReportedAndUnchanged(string reference, string? region, string diagnostic) + { + WriteDocumentation(new XElement("code", new XAttribute("source", reference), + region == null ? null : new XAttribute("region", region)).ToString()); + byte[] before = File.ReadAllBytes(DocPath); + + ImportResult result = Run(); + + Assert.Contains(diagnostic, Assert.Single(result.Errors)); + Assert.Equal(0, result.ImportedFiles); + Assert.Equal(0, result.UpdatedReferences); + Assert.Equal(before, File.ReadAllBytes(DocPath)); + } + + [Fact] + public void MissingMappingDoesNotGuessFromType() + { + WriteDocumentation(""""""); + + ImportResult result = new SnippetImporter().Import(Docs, Source, Output); + + Assert.Contains("No source mapping", Assert.Single(result.Errors)); + Assert.False(File.Exists(ImportedPath)); + } + + [Fact] + public void MemberUsesItsOwnMappingForPartialTypes() + { + File.WriteAllText(DocPath, """ + + + + + + + + + """); + var map = new Dictionary(SourceMap) { ["M:Example.Method"] = "src/Example.cs" }; + + ImportResult result = new SnippetImporter().Import(Docs, Source, Output, map); + + Assert.Empty(result.Errors); + Assert.Equal(1, result.UpdatedReferences); + } + + [Fact] + public void MemberWithoutMappingDoesNotInheritTypeMapping() + { + File.WriteAllText(DocPath, """ + + + + + + + + """); + + Assert.Contains("No source mapping for M:Example.Method", Assert.Single(Run().Errors)); + } + + [Fact] + public void ConflictingDestinationIsNotOverwritten() + { + WriteDocumentation(""""""); + Directory.CreateDirectory(Path.GetDirectoryName(ImportedPath)!); + File.WriteAllText(ImportedPath, "existing"); + + ImportResult result = Run(); + + Assert.Contains("different content", Assert.Single(result.Errors)); + Assert.Equal("existing", File.ReadAllText(ImportedPath)); + Assert.Equal(0, result.UpdatedReferences); + } + + [Fact] + public void MatchingDestinationIsReused() + { + WriteDocumentation(""""""); + Directory.CreateDirectory(Path.GetDirectoryName(ImportedPath)!); + File.Copy(Path.Combine(Source, "tests", "Example.cs"), ImportedPath); + + ImportResult result = Run(); + + Assert.Empty(result.Errors); + Assert.Equal(0, result.ImportedFiles); + Assert.Equal(1, result.UpdatedReferences); + } + + [Fact] + public void SuccessfulReferencesAreImportedWhenAnotherReferenceFails() + { + WriteDocumentation(""" + + + """); + + ImportResult result = Run(); + + Assert.Single(result.Errors); + Assert.Equal(1, result.ImportedFiles); + Assert.Equal(1, result.UpdatedReferences); + string[] references = XDocument.Load(DocPath).Descendants("code") + .Select(code => code.Attribute("source")!.Value).ToArray(); + Assert.Equal(["../tests/Missing.cs", "snippets/tests/Example.cs"], references); + } + + [Fact] + public void SourceMapCannotEscapeCheckout() + { + WriteDocumentation(""""""); + var map = new Dictionary { ["T:Example"] = "../outside.cs" }; + + ImportResult result = new SnippetImporter().Import(Docs, Source, Output, map); + + Assert.Contains("Path escapes", Assert.Single(result.Errors)); + Assert.Equal(0, result.ImportedFiles); + } + + [Theory] + [InlineData("https://example.com/sample.cs")] + [InlineData("~/snippets/sample.cs")] + public void ExternalAndDocumentationRootReferencesAreUnchanged(string reference) + { + WriteDocumentation(new XElement("code", new XAttribute("source", reference)).ToString()); + byte[] before = File.ReadAllBytes(DocPath); + + ImportResult result = Run(); + + Assert.Empty(result.Errors); + Assert.Equal(0, result.UpdatedReferences); + Assert.Equal(before, File.ReadAllBytes(DocPath)); + } + + [Fact] + public void SourceSymlinkCannotEscapeCheckout() + { + if (OperatingSystem.IsWindows()) + return; + WriteDocumentation(""""""); + string outside = Path.Combine(_root, "outside.cs"); + File.WriteAllText(outside, "not a snippet"); + File.CreateSymbolicLink(Path.Combine(Source, "tests", "Linked.cs"), outside); + + ImportResult result = Run(); + + Assert.Contains("Symbolic links", Assert.Single(result.Errors)); + Assert.Equal(0, result.ImportedFiles); + } + + [Fact] + public void DestinationSymlinkIsRejected() + { + if (OperatingSystem.IsWindows()) + return; + WriteDocumentation(""""""); + Directory.CreateSymbolicLink(Output, Path.Combine(_root, "not-created")); + + Assert.Throws(() => Run()); + } + + [Fact] + public void DtdIsRejected() + { + File.WriteAllText(DocPath, """]>&example;"""); + + ImportResult result = Run(); + + Assert.Single(result.Errors); + Assert.Equal(0, result.UpdatedReferences); + } + + private ImportResult Run(bool dryRun = false) => new SnippetImporter().Import(Docs, Source, Output, SourceMap, dryRun); + + private void WriteDocumentation(string content) => File.WriteAllText(DocPath, $""" + + + {content} + + """); + + public void Dispose() => Directory.Delete(_root, recursive: true); +} diff --git a/README.md b/README.md index 0da18f2c..28e4ac79 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,56 @@ This repo contains the sequester GitHub Action and other tools that the .NET doc The other .NET docs GitHub Actions are maintained in [dotnet/docs-actions](https://github.com/dotnet/docs-actions). For information about the code of conduct, see [Code of conduct](CODE-OF-CONDUCT.md). + +## Code snippet importer (prototype) + +`CodeSnippetImporter` runs after ECMAXML generation to copy missing example files +from a local source checkout and update their documentation references. It supports +XML `` elements and Markdown +`[!code-csharp[](...#region)]` references, including Markdown in CDATA. + +Run it with the .NET 10 SDK: + +```shell +dotnet run --project /path/to/docs-tools/CodeSnippetImporter -- /path/to/api/xml /path/to/runtime /path/to/api/snippets /path/to/source-map.json --dry-run +``` + +Remove `--dry-run` to copy files and update ECMAXML **in place**. Review the +resulting diff before publishing. The importer preserves XML whitespace and CDATA, +but XML serialization can normalize formatting such as attribute quotes. + +The source map is a JSON object mapping the `DocId` signature of each API whose +documentation references a snippet to the original source file, relative to the +checkout root. For example: + +```json +{ + "T:System.Reflection.Context.CustomReflectionContext": "src/libraries/System.Reflection.Context/src/System/Reflection/Context/CustomReflectionContext.cs", + "M:System.Text.RegularExpressions.Regex.Match(System.String,System.Int32)": "src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/Regex.Match.cs" +} +``` + +Member documentation needs its own mapping; the importer does not guess which file +in a partial type contains a member's comment. Use a checkout at the revision used +to generate the API documentation. The prototype does not verify its revision. + +Snippet paths are resolved relative to the mapped API source file. Whole files +are copied under the snippet directory using their source-repository-relative +paths, so shared references use one copy. Region attributes and Markdown fragments +are preserved; named C# selectors are checked for a matching `#region` or +`// ` start marker. Markdown line selectors such as `#L1-L5` are also validated. +Existing local references, URLs, absolute paths, and `~/` documentation-root +references are left unchanged. + +The importer rejects paths escaping the source or output roots, symbolic links, +overlapping source/output directories, XML DTDs, and conflicting destination files. +Identical destination files are reused. It prints counts and diagnostics, returning +`0` on success, `1` for import/input errors, and `2` for invalid command-line syntax. +Successful imports are retained if other references fail; failed references remain +unchanged. Dry runs perform validation without creating directories or changing files. +Rerunning an import leaves already-resolving references unchanged. + +Automatic SourceLink mapping, remote downloads, a provenance manifest, project-file +copying, and compilation are deferred. SourceLink integration needs verification +against the metadata produced by the API-generation pipeline; explicit mappings +make the prototype usable without assuming an unverified metadata schema. diff --git a/docs-tools.sln b/docs-tools.sln index b7db05c7..46876337 100644 --- a/docs-tools.sln +++ b/docs-tools.sln @@ -44,6 +44,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XmlDocConflictResolver", "X EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RepoManCheckerCLI", "RepoManChecker\RepoManCheckerCLI.csproj", "{1C59182B-9266-43FB-8398-86AE0F5012D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeSnippetImporter", "CodeSnippetImporter\CodeSnippetImporter.csproj", "{92E63A8C-B0BA-4180-A34A-3E4B8B832E1A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -94,6 +96,10 @@ Global {1C59182B-9266-43FB-8398-86AE0F5012D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C59182B-9266-43FB-8398-86AE0F5012D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C59182B-9266-43FB-8398-86AE0F5012D6}.Release|Any CPU.Build.0 = Release|Any CPU + {92E63A8C-B0BA-4180-A34A-3E4B8B832E1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92E63A8C-B0BA-4180-A34A-3E4B8B832E1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92E63A8C-B0BA-4180-A34A-3E4B8B832E1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92E63A8C-B0BA-4180-A34A-3E4B8B832E1A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE