Qualify UIDs by assembly to disambiguate shared namespaces - #11090
Qualify UIDs by assembly to disambiguate shared namespaces#11090MaceWindu wants to merge 2 commits into
Conversation
|
Over at Apache Lucene.NET (docs), we have the exact problem that this PR seeks to address. We ship many different assemblies, documented as separate docfx projects, that we then merge together into one site. We have had to do this workaround due to the issue of namespace collisions. We have ~22 namespaces declared by more than one assembly, plus a type collision (same type name declared in different assemblies). We split this into per-project builds in 2020 because a combined build broke xref lookups. But as a result, we have many unresolvable Uids and brittle doc links. So when I happened to see this PR on the same day I happened to be doing docfx cleanup work and was wondering if we could fix this once and for all (what a weird coincidence!), my ears perked up. I had Claude Code test your branch, and converted our multi-assembly build into a single build using prefixes to disambiguate. The result:
So the core mechanism does what it says, however it isn't a complete solution for our use case (yet). I just wanted to share this feedback so that it could be considered as part of adopting this solution. It doesn't quite yet work to solve the multi-assembly problem. The prefix is a UID prefix, so it necessarily reads as a namespace segment, and that surfaces several problems:
I tried decoupling the display name from the UID, but it's the wrong fix: the prefix is simultaneously the sort key and the only visible group label, so removing it leaves namespaces that look duplicated and mis-sorted. I think the core problem is that the prefix is doing two jobs: disambiguating the UID (which it does well at), and labelling the assembly/namespace in the UI. It's forced to do both because it's one string. An alternative: what if the assembly were a distinct component of the reference instead? Something like:
(delimiter of Then the UID carries the real assembly name and the real namespace. The namespace stays correct, titles follow correctly, TOC roots read the right way, and authored links name things that actually exist. The prefix naming rules disappear too. I recognize this is a substantially bigger change than your PR. A delimiter would need handling in filenames, anchor ids, xref keys and map serialization, etc. But since this PR is proposing a change in design to solve this problem, it seemed worth raising before it becomes a supported API. Happy to continue testing any approach against our site. With 27 assemblies and 22 shared namespaces it's a reasonable stress test. |
|
@paulirwin will look into it. Right now I don't see prefix rendered. E.g. same namespace in different projects:
|
Update: the assembly is a distinct component of the UID now, not a prefix, following @paulirwin's review. In short, what changed from the previous design: - `Ef8.MyLib.Widget` becomes `MyLib.Ef8::MyLib.Widget`. The component is joined with `::` rather than a dot, and defaults to the assembly's own name, so it names something that exists. - Namespaces are displayed as they are declared again. The previous design used the UID as the display name, so titles, breadcrumbs and the table of contents read as a namespace that does not exist, and contradicted the namespace every type in it reported. - New per entry `assemblyLabel` (`auto` | `suffix` | `none` | `page`) decides how the assembly is shown. - `namespaceLayout: nested` groups an assembly's namespaces under a node naming that assembly, and keeps that node even when the assembly contributes a single namespace. - `assemblyUidPrefixes` -> `assemblyUids`, which also accepts a plain array of assembly names. `uidPrefixOverride` -> `assemblyUidOverride`. - Output file names carry `--` where the UID has `::`, so enabling this changes page URLs. A docfx UID is Roslyn's documentation comment id minus its `X:` prefix, so it carries no assembly component. When one docfx project documents several assemblies that declare the same namespace, their APIs collapse onto one identity: `MergeMembers` silently merges namespaces and classes and drops everything else with an `Ignore duplicated member` warning, `.yml` pages overwrite each other, and the build stage reports `DuplicateUids` and then resolves the winner alphabetically by href. That last part is why every link to a shared namespace lands in whichever project won. This is routine for platform or version specific packages that intentionally expose the same API surface. Fixes dotnet#8966, dotnet#9371, dotnet#2041. Adds two opt-in settings: - `assemblyUids`, a project level `docfx.json` property naming the assemblies whose APIs carry the assembly they are declared in as a component of their UID. An array qualifies each assembly by its own name, an object names the component to use instead. It is project level rather than per `metadata` entry because an entry mints UIDs for the APIs it *references* as well as the ones it documents, so every entry has to agree: a reference that comes out unqualified points at a UID no page has and renders as plain text with no warning. - `assemblyUidOverride`, a per entry component for what a name keyed lookup cannot express: several entries documenting assemblies that share an assembly name, such as per target version builds of one project. It wins for its own entry's assemblies, and naming that assembly in `assemblyUids` as well nominates the version that links from other entries resolve to. The component is inserted in `VisitorHelper.GetDocumentationCommentId`, next to the existing `globalNamespaceId` insertion, which covers uids, comment ids, overload ids, the references table, spec ids, TOC node ids and output file names. Four consumers do not go through that hook: - `SymbolUrlResolver.GetDocfxUrl` split the comment id on every `:`, so a qualified UID collapsed to its assembly and every generated URL pointed at one page. - `XmlComment` turns `T:Foo.Bar` into a uid by pure text strip with no symbol context, so a `ResolveAssemblyUid` callback on `XmlCommentParserContext` resolves the target through the compilation. - `VisitorHelper.PathFriendlyId` maps `:` to `-`, one dash per character, because that is what `PathUtility.ToCleanUrlFileName` does to the member pages `memberLayout: separatePages` splits out in the build stage, and the two have to agree or those hrefs miss their pages. - API filters use `VisitorHelper.GetRawId`, so `filterConfig` `uidRegex` rules keep matching the unqualified API surface and existing filter files keep working. Namespace display names keep naming the namespace as declared, and `assemblyLabel` decides where the assembly appears: appended to the label, nowhere, or on the namespace page the way type pages name it. `auto` appends it in a flattened layout, where nothing else tells two assemblies apart, and leaves it out in a nested one, where `YamlMetadataResolver` groups each assembly's namespaces under a node naming it. That node carries no UID, so it needs no page of its own, and the single child collapse no longer promotes a namespace out of it. `XrefInlineShortParser` accepts `::`, so `@a::Ns.Type` does not end at the assembly. The global namespace is qualified as well, so two assemblies no longer share the one page named by `globalNamespaceId`. That surfaced that it had no display name at all, leaving an empty table of contents node: fixes dotnet#9458. Both settings are off by default and inert when unset, so the `CSharp` and `SeedMarkdown` snapshots come out byte identical. Verified end to end against the linq2db docs, which document 16 assemblies whose namespaces collide: 0 errors, 2389 API files and 2407 HTML pages, no `DuplicateUids` and no `UidNotFound`, and the namespaces read as themselves throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d55445f to
ea75d84
Compare
|
@paulirwin thanks for valuable feedback, design updated to address rised issues. |
* docs: qualify APIs by assembly instead of prefixing the namespace The upstream branch reworked the UID prefix into a distinct assembly component, following review feedback on dotnet/docfx#11090, so this switches to it: linq2db.Tools.LinqToDB.Tools.Activity.ActivityStatistics -> linq2db.Tools::LinqToDB.Tools.Activity.ActivityStatistics `assemblyUidPrefixes` becomes `assemblyUids`, and since every prefix here was already the assembly's own name, the map collapses to a plain list. `uidPrefixOverride` becomes `assemblyUidOverride` on the four EntityFrameworkCore entries, which still need it because they all build `linq2db.EntityFrameworkCore`. What this fixes on the site: the prefix used to be joined to the namespace with a dot and used as its display name, so pages read as namespaces that do not exist. Now: - `Namespace linq2db.Tools.LinqToDB.Tools.Activity` reads `Namespace LinqToDB.Tools.Activity (linq2db.Tools)`. - The namespace a type reports and the namespace page it links to agree; before, a type page said `LinqToDB.Tools.Activity` while the page behind that link was titled with the made up name. - Table of contents labels name the real namespace and the assembly it comes from, so the `LinqToDB` declared by both `linq2db` and `linq2db.Tools` is now `LinqToDB (linq2db)` and `LinqToDB (linq2db.Tools)` instead of two invented namespaces. Page URLs change once more, as the file name carries `--` where the UID has `::`. Two hand written xrefs in the articles named the old prefixed UIDs and are updated; they were the only ones. docfx/ rebuilt from fix/8966-uid-prefixes (dd95c86d9). The custom Roslyn 5.6.0 commit is gone: upstream took that bump in dotnet/docfx#11047, so the vendored build is now the upstream branch unmodified. Build output: 0 errors, 2389 API files, 2407 HTML pages, and 6 docfx warnings, all pre-existing (5 `InvalidFileLink` from Microsoft docs relative links in linq2db's XML comments, 1 `InvalidBookmark` from a `#remarks` anchor). No `DuplicateUids` and no `UidNotFound`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: refresh docfx build with the reviewed upstream branch docfx/ rebuilt from fix/8966-uid-prefixes (9907932d6), which folds in a code review of the assembly component design: - a repeated or empty entry in the `assemblyUids` array is reported as an invalid entry instead of throwing out of the JSON converter - with `outputFormat: apiPage` or `markdown`, two assemblies resolving to one component now share a single table of contents node - the new table of contents ordering is skipped when no assembly is qualified, so it cannot affect a project that does not use this No change to `source/docfx.json` and no change to the output: 0 errors, 2389 API files, 2407 HTML pages, and the same 6 pre-existing docfx warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`assemblyLabel` defaulted to `auto`, which resolved to `suffix` in a flattened layout -- both defaults --
so enabling `assemblyUids` relabelled every namespace as `ns (assembly)` in the table of contents, the
page title and the breadcrumb. A project that documents 40 namespaces of which 34 are declared by one
assembly got 34 labels that disambiguate nothing, without asking for any of it.
Qualifying an assembly now changes what its pages are addressed by, not what they read as:
- `none` is the default and shows the namespace alone, so enabling `assemblyUids` leaves every displayed
name exactly as it was.
- `shared` is new: it appends the assembly only to the namespaces that more than one assembly documented
by the same `metadata` entry declares. An entry is all it can compare, since entries are generated one
after another and each writes its output before the next is compiled.
- `suffix` keeps appending it to every namespace of a qualified assembly.
- `auto` is gone. It has never shipped, so there is no migration.
`shared` has to know the whole entry, which a symbol visitor does not, so the append moved out of
`SymbolVisitorAdapter` into `ApplyAssemblyLabel`, run after `MergeMembers` where every namespace of the
entry is keyed by uid. `suffix` moved with it, so both values share one implementation. `MergeMembers`
keys on the uid alone and `MergeReferences` touches only the references table, so nothing reads a display
name in between. `DotnetApiCatalog.Toc.cs` applies the same rule for `apiPage` and `markdown`, grouping
the namespace nodes before picking out the qualified ones -- an unqualified assembly declaring the same
namespace is another declaration of it -- and before `SortToc`, which orders by the name the label is
part of.
Also fixes `assemblyLabel: page`, which could not render at all: `ItemViewModel.NamespaceAssembly` reached
the `.yml`, but `ApiBuildOutput.FromModel` never copied it and `ApiBuildOutput` had no such property, so
the `{{#namespaceAssembly}}` blocks in the default and modern namespace partials were dead. It is a
declared `[YamlMember]`, so it could not arrive through the `Metadata` catch-all either.
That defect is why every value is now asserted on rendered HTML in `AssemblyUidBuildTest`, one theory row
each: metadata level assertions passed the whole time while the page showed nothing. `MetadataCommandTest`
covers `shared` end to end over two assemblies where only one namespace is shared, and the `apiPage` and
`markdown` table of contents path, which had no test at all.
Verified against the three snapshot samples: `SeedMarkdown`, which is the nested and markdown path this
touches most, is byte identical; the `CSharp` drift is byte for byte what the branch produces without this
commit, its baselines dating from November 2024.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #8966. Also fixes #9371 and #9458, and covers the request in #2041 (closes #2041) / discussions #8204 and #8018.
Problem
A docfx UID is Roslyn's documentation comment id minus its
X:prefix (VisitorHelper.GetId), so it carries no assembly component. When one docfx project documents several assemblies that declare the same namespace, their APIs collapse onto one identity:MergeMemberskeys a flatDictionary<string, MetadataItem>by UID. Namespaces and classes are silently merged; everything else is dropped with anIgnore duplicated memberwarning..ymlfile name is the UID, so pages overwrite each other and.manifestwarns.DocumentBuildContext.XRefSpecMapreportsDuplicateUidsand then picks the winner alphabetically by href. That is why every link to a shared namespace lands in whichever project happened to win, which is the symptom users report.This is routine for platform or version specific packages that intentionally expose the same API surface — per-EF-Core-version packages, Xamarin/WinForms/WPF variants,
Abstractionsplus implementation pairs.globalNamespaceIddoes not help: it only applies to symbols in the global namespace, as noted on #8966.Why not resolve hrefs at the metadata stage
The suggestion on #8966 was to "resolve the right project for the API pages by moving the resolve logic from build stage to metadata stage". That fixes hrefs only. It does not stop
MergeMembersfrom dropping or merging same-UID pages, so the affected APIs still never get a page at all. Disambiguating the UID is the only fix that addresses every symptom.Solution
Two opt-in settings that decide identity, plus one that decides display.
assemblyUids— a project leveldocfx.jsonpropertyNames the assemblies whose APIs carry the assembly they are declared in as a component of their UID. An array qualifies each assembly by its own name:
{ "assemblyUids": [ "MyLib", "MyLib.Ef8" ], "metadata": [ { "src": [ "src/MyLib/MyLib.csproj" ], "dest": "api/core" }, { "src": [ "src/MyLib.Ef8/MyLib.Ef8.csproj" ], "dest": "api/ef8" } ] }MyLib.WidgetbecomesMyLib::MyLib.WidgetandMyLib.Ef8::MyLib.Widget, so each keeps its own page, TOC entry and cross references. An object form names the component to use instead ({ "MyLib.Ef8": "Ef8" }), wherenullmeans the assembly's own name. Assemblies that are not listed are untouched, so BCL/NuGet references, Microsoft Learn links and existing xref maps are unaffected.::rather than a dot is the point: the component names an assembly, not a namespace, so it must not read as one. In the output file name the::becomes--, as:is not a legal file name character:MyLib--MyLib.Widget.html.It sits next to
metadatarather than inside an entry, and that placement is load bearing: an entry mints UIDs not only for the APIs it documents but also for the APIs it references, and those must come out identical to the UIDs produced by the entry that documents them. A per entry list cannot do that, because an entry cannot see another entry's settings.That is measured, not assumed. On a real 17 entry project (linq2db, details below), replacing this with a per entry list on every entry leaves 6690 cross-entry references across 163 pages pointing at UIDs no page has. They render as plain text with no link, no warning and no error — the total warning count is identical either way.
assemblyUidOverride— per entry, for entries that build the same assembly nameVersion specific packages are often one project built several times, sharing an
AssemblyNameand differing only by target framework, which qualifying by assembly name cannot separate:{ "assemblyUids": { "MyLib": null, "MyLib.Ef": "Ef9" }, "metadata": [ { "src": [ "src/MyLib/MyLib.csproj" ], "dest": "api/core" }, { "src": [ "src/MyLib.Ef/MyLib.Ef.Ef8.csproj" ], "dest": "api/ef8", "assemblyUidOverride": "Ef8" }, { "src": [ "src/MyLib.Ef/MyLib.Ef.Ef9.csproj" ], "dest": "api/ef9", "assemblyUidOverride": "Ef9" } ] }It takes precedence over
assemblyUidsfor the assemblies its own entry documents, soapi/ef8andapi/ef9each get a complete set of pages, while givingMyLib.Efthe componentEf9inassemblyUidsnominates the version that links from other entries resolve to. The two settings compose without a third option.Its limits are documented, since they are not obvious: other entries cannot see it, and it does not disambiguate two same-named assemblies inside one entry (every assembly an entry documents gets the same component, so a glob matching several target framework folders still collides — narrowing the glob is the fix there).
A component may contain letters, digits, underscores and dashes, separated by dots, e.g.
MyLib.Tools,my-libornet8.0, and unlike a namespace it may start with a digit, so assembly names like7zip.Network. The restriction exists because the UID becomes a file name, an xref key and an HTML anchor. Invalid components, empty assembly names, repeated entries and conflicting mappings are reported asInvalidAssemblyUidand skipped.assemblyLabel— per entry, how the assembly is shownNothing displayed names the assembly unless this asks for it, so enabling
assemblyUidson an existing site leaves every label, title and breadcrumb as it was:none(default)MyLib— the namespace alonesharedMyLib (MyLib.Ef8), but only for the namespaces more than one assembly of this entry declaressuffixMyLib (MyLib.Ef8)for every namespace of a qualified assemblypageMyLib, withAssembly: MyLib.Ef8.dllnamed on the page, the way type pages doOver one entry documenting
MyLibandMyLib.Ef8, where both declareMyLibandMyLib.Dataand onlyMyLib.Ef8declaresMyLib.Ef8.Internal— this is real generated output, ordered by UID:sharedcompares the namespaces of onemetadataentry, and cannot do otherwise: entries are generated one after another, each writing its output before the next is compiled. A project that gives every assembly its own entry therefore gets no labels fromsharedeven where namespaces collide across entries, and wantssuffixon those entries; a project documenting several assemblies in one entry, which is Lucene.NET's shape, is whatsharedis for. Documented, with the other cases where a namespace is deliberately not labelled.namespaceLayout: nestedis the other way to show it, and answers #8018: the namespaces of each qualified assembly are grouped under a node naming that assembly, which has no page of its own. Authored links name the UID, so they name the real assembly and the real namespace:<xref:MyLib.Ef8::MyLib.Widget>,@MyLib.Ef8::MyLib.Widget.Alternative considered
A single per entry setting would be enough if
Execcompiled every entry first, collected assembly → component, and only then generated; cross-entry references would resolve from that table, the project level setting would not be needed, andsharedcould compare the whole project. Not done here: it holds every Roslyn compilation in memory simultaneously (17 projects across multiple target frameworks in the case above), and restructuring the compile pipeline is a much larger change than this bug warrants. Happy to pursue it separately.Implementation
The component is inserted in the private
VisitorHelper.GetDocumentationCommentId, next to the existingglobalNamespaceIdinsertion. That single hook covers uids, comment ids, overload ids, the references table, spec ids, TOC node ids and output file names. It is skipped for type parameters and for symbols with no containing assembly (cref-resolved symbols hit the latter).assemblyUidOverrideis applied inBuildright afterCompile, since an entry's assemblies are only known once compiled. It is constant for the whole entry, so the parallel API page generation reads it safely.Four consumers do not go through that hook and needed to follow the component explicitly:
SymbolUrlResolver.GetDocfxUrlbuilt hrefs from the raw Roslyn comment id, and split it on every:, so a qualified UID collapsed to its assembly and every generated URL pointed at one page. It now splits once and applies the component like the rest.XmlCommentturnsT:Foo.Barinto a uid by pure text strip with no symbol context, so<see cref="..."/>produced dangling xrefs. AResolveAssemblyUidcallback onXmlCommentParserContextresolves the target through the compilation usingDocumentationCommentId.GetFirstSymbolForDeclarationId— the same callAddReferencealready makes — and is supplied by both the mref and apiPage comment parsers. It is a no-op when neither setting is used.VisitorHelper.PathFriendlyIdmaps:to-, one dash per character, because that is exactly whatPathUtility.ToCleanUrlFileNamedoes to the member pagesmemberLayout: separatePagessplits out in the build stage. The two have to agree or those hrefs miss their pages, and the yml page names now go through the same helper so:never reaches the filesystem.VisitorHelper.GetRawId, sofilterConfiguidRegexrules keep matching the actual API surface and existing filter files keep working when the settings are enabled.assemblyLabelis applied inApplyAssemblyLabel, afterMergeMembers, rather than in the symbol visitor:sharedhas to know which namespaces more than one of the entry's assemblies declares, and a visitor only ever sees one assembly.suffixshares that implementation.MergeMemberskeys on the uid alone andMergeReferencestouches only the references table, so nothing reads a display name in between, and the TOC label, the pagename/nameWithType/fullNameand the xrefmap all pick it up as before.DotnetApiCatalog.Toc.csapplies the same rule forapiPageandmarkdown, grouping the namespace nodes before picking out the qualified ones — an unqualified assembly declaring the same namespace is another declaration of it — and beforeSortToc, which orders by the name the label is part of.With
namespaceLayout: nested,YamlMetadataResolvergroups each qualified assembly's namespaces under a node naming that assembly. That node carries no UID, so it needs no page and cannot reportUidNotFound, and the single child collapse no longer promotes a namespace out of it — otherwise an assembly with one namespace lost the only node naming it.XrefInlineShortParseraccepts::, since both colons are characters the@uidshortcut otherwise stops at.The global namespace is qualified as well, so two assemblies whose APIs sit in it no longer share the one page named by
globalNamespaceId. That surfaced that the global namespace had no display name at all, leaving an empty TOC node: fixes #9458.assemblyLabel: pagecould not renderWorth calling out as its own defect, because it is what shaped the tests.
SymbolVisitorAdaptersetItemViewModel.NamespaceAssembly, it was written to the.yml, and the{{#namespaceAssembly}}blocks were in the default and modern namespace partials — butManagedReferenceDocumentProcessor.UpdateModelContentreplaces the model withApiBuildOutput.FromModel, which had no such property and never copied it. Being a declared[YamlMember], it could not arrive through theMetadatacatch-all either. So the option was documented, tested at the metadata level, and rendered nothing.That is why every
assemblyLabelvalue is now asserted on rendered HTML, one theory row each: metadata level assertions passed the whole time while the page showed nothing.Behaviour when unset
Inert:
GetAssemblyUidreturns immediately when neither setting is configured, so there is no behaviour change and no measurable cost. The TOC grouping, the label handling and the file name mapping cannot fire without a component, andassemblyLabelshows nothing until it is set.No
.verified.*file is touched by this PR.SeedMarkdown, which usesnamespaceLayout: nestedand the markdown output format — the two code paths this touches most — comes out byte identical against the committed baselines.Tests
test/Docfx.Dotnet.Tests/AssemblyUidUnitTest.cs, new: namespace/type/member/overload uids and comment ids; the assembly name as the default component; two assemblies sharing a namespace getting distinct uids; cross-assembly references carrying the target's component; unlisted assemblies and framework types untouched; crefs, seealsos, exceptions andOverload:crefs; generics and type parameters qualified exactly once; components with digits and dashes; composition withglobalNamespaceId; filter rules still matching unqualified uids;::→--file names and the hrefs and anchorsSymbolUrlResolverderives from them; the override separating assemblies that share an assembly name, winning over the project level setting, and not leaking to other assemblies; and no component at all when nothing is configured. PlusApplyAssemblyLabeldriven directly over a shared and a unique namespace for every value and both layouts, and the unqualified assembly that counts as a declaration without being labelled.test/docfx.Tests/MetadataCommandTest.cs, end to end over real projects that share a namespace: theIgnore duplicated memberwarning and lost page without the settings, which locks in the regression; distinct pages,.manifestentries and TOC entries with them; that the assembly does not surface as a namespace anywhere; plain labels by default;suffixlabelling all of them, in the TOC and on the page;sharedlabelling only the shared one, over two assemblies of which one declares an extra namespace; the nested per assembly grouping, including the single namespace case; two entries separated by the override; invalid values reported and dropped; and theapiPage/markdownTOC path, which had no coverage of this at all.test/docfx.Tests/AssemblyUidBuildTest.cs, new: builds a site fromdocfx.json(so the JSON binding is covered too) withmemberLayout: separatePages, and asserts the type and member page file names, that@a::Ns.Type,<xref:...>and[text](xref:...)all resolve to the right assembly with the right anchor, and that the namespace link on a type page reads as the namespace while the UID stays indata-uid. Plus one theory row perassemblyLabelvalue asserting the rendered HTML — the heading, the title and the assembly line — includingnone, which pins that enablingassemblyUidschanges nothing displayed.test/Docfx.MarkdigEngine.Extensions.Tests/XrefTest.cs: the::shortcut in all four xref forms, and a trailing::still ending the uid.schemas/docfx.schema.json(which isadditionalProperties: false).Docs
docs/reference/docfx-json-reference.md:assemblyUidsunder global properties,assemblyUidOverrideandassemblyLabelundermetadata.docs/docs/dotnet-api-docs.md: the "Assemblies that share namespaces" section — how to qualify an assembly and why the setting is project level, what eachassemblyLabelvalue renders (the block above, taken from a real run), whysharedis per entry, what the override does and does not fix, and how to nominate the version that links from elsewhere resolve to. Includes the caveat that enabling either identity setting changes UIDs and file names, so<xref>links in markdown, overwrite files and external xref maps need updating by hand — and that nothing displayed changes.Validated against a real project
Tested end to end on the linq2db documentation site, which is the motivating case: 17 metadata entries, 2389 generated API files, several assemblies sharing the
LinqToDBnamespace, and four Entity Framework Core packages built from one shared props file so that all four produce the assembly namelinq2db.EntityFrameworkCore. That last detail is what motivatedassemblyUidOverride.Because the component defaults to the assembly name, that project's twelve entry prefix map collapses to a plain list.
linq2db carried a private patch for this since #8966 was filed, which prefixes the whole comment id with the assembly name. Comparing that patch against this PR, both from source on the same Roslyn version, all 2389 generated API file names line up while the site build improves substantially:
InvalidBookmark(dead in-page anchors)Ignore duplicated memberInvalidAssemblyReferenceDuplicateUids/UidNotFoundOf those 68, 61 are MSBuild noise from the projects themselves (duplicate source files, one analyzer load failure) and 6 are docfx warnings that predate this work: 5
InvalidFileLinkfrom Microsoft Learn relative links inside linq2db's own XML comments, and 1InvalidBookmarkfrom a hand written#remarksanchor. The difference against the private patch is exactly the consumers listed under Implementation: that patch emits a malformedcommentId(linq2db.T:LinqToDB..., with the prefix landing before the kind marker), unqualified namespace and type hrefs pointing at pages that do not exist, and ~1200 member anchors that do not match the bookmarks on their own pages.Reading the result: the
LinqToDBnamespace declared by bothlinq2dbandlinq2db.Toolsnow has a page each,linq2db--LinqToDB.htmlandlinq2db.Tools--LinqToDB.html, and both are titledNamespace LinqToDBand read asLinqToDBin the table of contents, because that is the namespace they document. The site is linq2db/docs#66.Not included
globalNamespaceIdinconsistency inGetDocfxUrl— it builds hrefs from the raw comment id, soglobalNamespaceIdis already missing from them. Left alone to keep the "no change when unset" guarantee exact; happy to fix it here or separately.assemblyLabel's page effects under"outputFormat": "apiPage"or"markdown". Those formats title a page from the symbol itself, so only the TOC label follows the setting there. Documented rather than changed.AssemblyNameListwhenMergeMembersmerges a namespace or class page. This is a real gap —samples/seed's ownCatLibrary.Corenamespace page reports onlyCatLibraryalthough half its types come fromCatLibrary.Core— but it changes default output, would need a snapshot update, and both shipped templates render onlyassemblies.0, so nothing would become visible without a template change too. Better as its own PR.