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