From cfa8ffbe62ecd08faa724af1efccfb26dcef810d Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Wed, 25 Feb 2026 13:44:03 -0600 Subject: [PATCH 1/3] Remove links to non-existent pages for parent namespaces --- .../Resolvers/ResolveReference.cs | 37 +++++++++++++ .../GenerateMetadataFromCSUnitTest.cs | 55 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs b/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs index f946b36b8df..2b80d3d55b6 100644 --- a/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs +++ b/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs @@ -46,10 +46,47 @@ public void Run(MetadataModel yaml, ResolverContext context) } } AddIndirectReference(context, page, addingReferences); + + if (current.Type.IsPageLevel()) + { + ClearMissingLocalHrefs(page, context); + } + return true; }); } + // Clears hrefs that point to local pages which have no corresponding member (i.e. no generated page). + // This prevents broken links to parent namespaces that contain no types of their own. + // External URLs (Microsoft Learn, source links) are preserved as they begin with "http". + private static void ClearMissingLocalHrefs(MetadataItem page, ResolverContext context) + { + foreach (var reference in page.References.Values) + { + foreach (var parts in new[] { reference.NameParts, reference.NameWithTypeParts, reference.QualifiedNameParts }) + { + if (parts is null) + { + continue; + } + + foreach (var items in parts.Values) + { + foreach (var item in items) + { + if (item.Name is not null + && item.Href is not null + && !item.Href.StartsWith("http", StringComparison.Ordinal) + && !context.Members.ContainsKey(item.Name)) + { + item.Href = null; + } + } + } + } + } + } + private static void TryAddReference(ResolverContext context, MetadataItem page, List addingReferences, string key) { if (!page.References.ContainsKey(key)) diff --git a/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs b/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs index fbad40a99f4..fd7fc085697 100644 --- a/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs +++ b/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs @@ -3782,4 +3782,59 @@ public class Foo Assert.Contains("TupleLibrary", output.References.Keys); Assert.Contains("TupleLibrary.XmlTasks", output.References.Keys); } + + [Fact] + public void TestNamespacePartsHaveNoHrefForMissingParentNamespaces() + { + // Regression test for https://github.com/dotnet/docfx/issues/10588. + // Foo and Foo.Bar have no types, so no page is generated for them — their href must be null. + // Foo.Bar has a type (IntermediateClass), so Foo.Bar.Baz's "Bar" segment must keep its href. + var code = """ + namespace Foo.Bar + { + public class IntermediateClass { } + } + namespace Foo.Bar.Baz + { + public class MyClass { } + } + """; + + var assembly = Verify(code); + + var allMembers = new Dictionary(); + var allReferences = new Dictionary(); + foreach (var ns in assembly.Items ?? []) + { + allMembers[ns.Name] = ns; + foreach (var type in ns.Items ?? []) + { + allMembers[type.Name] = type; + } + } + if (assembly.References is not null) + { + foreach (var (key, value) in assembly.References) + { + allReferences[key] = value; + } + } + + var model = YamlMetadataResolver.ResolveMetadata(allMembers, allReferences, NamespaceLayout.Flattened); + + // On the Foo.Bar.Baz.MyClass page, parts are: "Foo", ".", "Bar", ".", "Baz" + var deepClassPage = model.Members.Single(m => m.Name == "Foo.Bar.Baz.MyClass"); + var deepNsParts = deepClassPage.References["Foo.Bar.Baz"].NameParts[SyntaxLanguage.CSharp]; + Assert.Equal(["Foo", ".", "Bar", ".", "Baz"], deepNsParts.Select(p => p.DisplayName)); + Assert.Null(deepNsParts.Single(p => p.DisplayName == "Foo").Href); // Foo has no page + Assert.NotNull(deepNsParts.Single(p => p.DisplayName == "Bar").Href); // Foo.Bar has a page + Assert.NotNull(deepNsParts.Single(p => p.DisplayName == "Baz").Href); // Foo.Bar.Baz has a page + + // On the Foo.Bar.IntermediateClass page, parts are: "Foo", ".", "Bar" + var shallowClassPage = model.Members.Single(m => m.Name == "Foo.Bar.IntermediateClass"); + var shallowNsParts = shallowClassPage.References["Foo.Bar"].NameParts[SyntaxLanguage.CSharp]; + Assert.Equal(["Foo", ".", "Bar"], shallowNsParts.Select(p => p.DisplayName)); + Assert.Null(shallowNsParts.Single(p => p.DisplayName == "Foo").Href); // Foo has no page + Assert.NotNull(shallowNsParts.Single(p => p.DisplayName == "Bar").Href); // Foo.Bar has a page + } } From e6f3ea48af89661044cd54730d1f14941d3ad1fb Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Wed, 25 Feb 2026 14:17:21 -0600 Subject: [PATCH 2/3] remove excess comments, match existing style more closely --- .../Resolvers/ResolveReference.cs | 7 +--- .../GenerateMetadataFromCSUnitTest.cs | 38 ++++++++----------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs b/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs index 2b80d3d55b6..9358337e8c5 100644 --- a/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs +++ b/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs @@ -49,17 +49,14 @@ public void Run(MetadataModel yaml, ResolverContext context) if (current.Type.IsPageLevel()) { - ClearMissingLocalHrefs(page, context); + ClearBrokenHrefs(page, context); } return true; }); } - // Clears hrefs that point to local pages which have no corresponding member (i.e. no generated page). - // This prevents broken links to parent namespaces that contain no types of their own. - // External URLs (Microsoft Learn, source links) are preserved as they begin with "http". - private static void ClearMissingLocalHrefs(MetadataItem page, ResolverContext context) + private static void ClearBrokenHrefs(MetadataItem page, ResolverContext context) { foreach (var reference in page.References.Values) { diff --git a/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs b/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs index fd7fc085697..8ac75bf07c1 100644 --- a/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs +++ b/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs @@ -3784,11 +3784,9 @@ public class Foo } [Fact] - public void TestNamespacePartsHaveNoHrefForMissingParentNamespaces() + public void TestGenerateMetadataWithNestedNamespaceHrefs() { - // Regression test for https://github.com/dotnet/docfx/issues/10588. - // Foo and Foo.Bar have no types, so no page is generated for them — their href must be null. - // Foo.Bar has a type (IntermediateClass), so Foo.Bar.Baz's "Bar" segment must keep its href. + // https://github.com/dotnet/docfx/issues/10588 var code = """ namespace Foo.Bar { @@ -3800,11 +3798,11 @@ public class MyClass { } } """; - var assembly = Verify(code); + var output = Verify(code); var allMembers = new Dictionary(); var allReferences = new Dictionary(); - foreach (var ns in assembly.Items ?? []) + foreach (var ns in output.Items ?? []) { allMembers[ns.Name] = ns; foreach (var type in ns.Items ?? []) @@ -3812,9 +3810,9 @@ public class MyClass { } allMembers[type.Name] = type; } } - if (assembly.References is not null) + if (output.References is not null) { - foreach (var (key, value) in assembly.References) + foreach (var (key, value) in output.References) { allReferences[key] = value; } @@ -3822,19 +3820,15 @@ public class MyClass { } var model = YamlMetadataResolver.ResolveMetadata(allMembers, allReferences, NamespaceLayout.Flattened); - // On the Foo.Bar.Baz.MyClass page, parts are: "Foo", ".", "Bar", ".", "Baz" - var deepClassPage = model.Members.Single(m => m.Name == "Foo.Bar.Baz.MyClass"); - var deepNsParts = deepClassPage.References["Foo.Bar.Baz"].NameParts[SyntaxLanguage.CSharp]; - Assert.Equal(["Foo", ".", "Bar", ".", "Baz"], deepNsParts.Select(p => p.DisplayName)); - Assert.Null(deepNsParts.Single(p => p.DisplayName == "Foo").Href); // Foo has no page - Assert.NotNull(deepNsParts.Single(p => p.DisplayName == "Bar").Href); // Foo.Bar has a page - Assert.NotNull(deepNsParts.Single(p => p.DisplayName == "Baz").Href); // Foo.Bar.Baz has a page - - // On the Foo.Bar.IntermediateClass page, parts are: "Foo", ".", "Bar" - var shallowClassPage = model.Members.Single(m => m.Name == "Foo.Bar.IntermediateClass"); - var shallowNsParts = shallowClassPage.References["Foo.Bar"].NameParts[SyntaxLanguage.CSharp]; - Assert.Equal(["Foo", ".", "Bar"], shallowNsParts.Select(p => p.DisplayName)); - Assert.Null(shallowNsParts.Single(p => p.DisplayName == "Foo").Href); // Foo has no page - Assert.NotNull(shallowNsParts.Single(p => p.DisplayName == "Bar").Href); // Foo.Bar has a page + var bazParts = model.Members.Single(m => m.Name == "Foo.Bar.Baz.MyClass").References["Foo.Bar.Baz"].NameParts[SyntaxLanguage.CSharp]; + Assert.Equal(["Foo", ".", "Bar", ".", "Baz"], bazParts.Select(p => p.DisplayName)); + Assert.Null(bazParts.Single(p => p.DisplayName == "Foo").Href); + Assert.NotNull(bazParts.Single(p => p.DisplayName == "Bar").Href); + Assert.NotNull(bazParts.Single(p => p.DisplayName == "Baz").Href); + + var barParts = model.Members.Single(m => m.Name == "Foo.Bar.IntermediateClass").References["Foo.Bar"].NameParts[SyntaxLanguage.CSharp]; + Assert.Equal(["Foo", ".", "Bar"], barParts.Select(p => p.DisplayName)); + Assert.Null(barParts.Single(p => p.DisplayName == "Foo").Href); + Assert.NotNull(barParts.Single(p => p.DisplayName == "Bar").Href); } } From f4728455462c9909af0349c3c8ab191c5187e902 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Fri, 27 Feb 2026 16:08:52 -0600 Subject: [PATCH 3/3] also account for cross-assembly references --- .../Resolvers/ResolveReference.cs | 13 ++-- .../GenerateMetadataFromCSUnitTest.cs | 63 ++++++++++++++----- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs b/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs index 9358337e8c5..0e60576785a 100644 --- a/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs +++ b/src/Docfx.Dotnet/ManagedReference/Resolvers/ResolveReference.cs @@ -10,6 +10,8 @@ internal class ResolveReference : IResolverPipeline { public void Run(MetadataModel yaml, ResolverContext context) { + var pages = new List(); + TreeIterator.Preorder(yaml.TocYamlViewModel, null, s => s.IsInvalid ? null : s.Items, (current, parent) => @@ -21,6 +23,7 @@ public void Run(MetadataModel yaml, ResolverContext context) { page = current; current.References = []; + pages.Add(page); } else { @@ -47,13 +50,13 @@ public void Run(MetadataModel yaml, ResolverContext context) } AddIndirectReference(context, page, addingReferences); - if (current.Type.IsPageLevel()) - { - ClearBrokenHrefs(page, context); - } - return true; }); + + foreach (var page in pages) + { + ClearBrokenHrefs(page, context); + } } private static void ClearBrokenHrefs(MetadataItem page, ResolverContext context) diff --git a/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs b/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs index 8ac75bf07c1..e633bba165a 100644 --- a/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs +++ b/test/Docfx.Dotnet.Tests/GenerateMetadataFromCSUnitTest.cs @@ -3787,48 +3787,81 @@ public class Foo public void TestGenerateMetadataWithNestedNamespaceHrefs() { // https://github.com/dotnet/docfx/issues/10588 - var code = """ + var codeA = """ namespace Foo.Bar { - public class IntermediateClass { } + public class TypeFromA { } } + """; + + var codeB = """ namespace Foo.Bar.Baz { - public class MyClass { } + public class TypeFromB + { + public Foo.Bar.TypeFromA GetA() => null; + } } """; - var output = Verify(code); + var compilationA = CompilationHelper.CreateCompilationFromCSharpCode(codeA, EmptyMSBuildProperties, "AssemblyA.dll"); + var compilationB = CompilationHelper.CreateCompilationFromCSharpCode(codeB, EmptyMSBuildProperties, "AssemblyB.dll", compilationA.ToMetadataReference()); + + var allAssemblies = new HashSet([compilationA.Assembly, compilationB.Assembly], SymbolEqualityComparer.Default); + + var filter = new SymbolFilter(new(), new()); + + var outputA = compilationA.Assembly.Accept(new SymbolVisitorAdapter(compilationA, new(compilationA, MemberLayout.SamePage, allAssemblies), new(), filter, [])); + var outputB = compilationB.Assembly.Accept(new SymbolVisitorAdapter(compilationB, new(compilationB, MemberLayout.SamePage, allAssemblies), new(), filter, [])); var allMembers = new Dictionary(); var allReferences = new Dictionary(); - foreach (var ns in output.Items ?? []) + + foreach (var output in new[] { outputA, outputB }) { - allMembers[ns.Name] = ns; - foreach (var type in ns.Items ?? []) + foreach (var ns in output.Items ?? []) { - allMembers[type.Name] = type; + allMembers.TryAdd(ns.Name, ns); + foreach (var type in ns.Items ?? []) + { + allMembers.TryAdd(type.Name, type); + } } - } - if (output.References is not null) - { - foreach (var (key, value) in output.References) + if (output.References is not null) { - allReferences[key] = value; + foreach (var (key, value) in output.References) + { + if (allReferences.TryGetValue(key, out var existing)) + { + existing.Merge(value); + } + else + { + allReferences[key] = value; + } + } } } var model = YamlMetadataResolver.ResolveMetadata(allMembers, allReferences, NamespaceLayout.Flattened); - var bazParts = model.Members.Single(m => m.Name == "Foo.Bar.Baz.MyClass").References["Foo.Bar.Baz"].NameParts[SyntaxLanguage.CSharp]; + // Verify cross-assembly reference: TypeFromB references TypeFromA from another assembly + var typeBPage = model.Members.Single(m => m.Name == "Foo.Bar.Baz.TypeFromB"); + var bazParts = typeBPage.References["Foo.Bar.Baz"].NameParts[SyntaxLanguage.CSharp]; Assert.Equal(["Foo", ".", "Bar", ".", "Baz"], bazParts.Select(p => p.DisplayName)); Assert.Null(bazParts.Single(p => p.DisplayName == "Foo").Href); Assert.NotNull(bazParts.Single(p => p.DisplayName == "Bar").Href); Assert.NotNull(bazParts.Single(p => p.DisplayName == "Baz").Href); - var barParts = model.Members.Single(m => m.Name == "Foo.Bar.IntermediateClass").References["Foo.Bar"].NameParts[SyntaxLanguage.CSharp]; + var barParts = typeBPage.References["Foo.Bar"].NameParts[SyntaxLanguage.CSharp]; Assert.Equal(["Foo", ".", "Bar"], barParts.Select(p => p.DisplayName)); Assert.Null(barParts.Single(p => p.DisplayName == "Foo").Href); Assert.NotNull(barParts.Single(p => p.DisplayName == "Bar").Href); + + // Same-assembly reference + var typeAPage = model.Members.Single(m => m.Name == "Foo.Bar.TypeFromA"); + var barPartsA = typeAPage.References["Foo.Bar"].NameParts[SyntaxLanguage.CSharp]; + Assert.Null(barPartsA.Single(p => p.DisplayName == "Foo").Href); + Assert.NotNull(barPartsA.Single(p => p.DisplayName == "Bar").Href); } }