Skip to content

Qualify UIDs by assembly to disambiguate shared namespaces - #11090

Open
MaceWindu wants to merge 2 commits into
dotnet:mainfrom
MaceWindu:fix/8966-assembly-uid-prefixes
Open

Qualify UIDs by assembly to disambiguate shared namespaces#11090
MaceWindu wants to merge 2 commits into
dotnet:mainfrom
MaceWindu:fix/8966-assembly-uid-prefixes

Conversation

@MaceWindu

@MaceWindu MaceWindu commented Aug 9, 2026

Copy link
Copy Markdown

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:

  • MergeMembers keys a flat Dictionary<string, MetadataItem> by UID. Namespaces and classes are silently merged; everything else is dropped with an Ignore duplicated member warning.
  • The .yml file name is the UID, so pages overwrite each other and .manifest warns.
  • At build time DocumentBuildContext.XRefSpecMap reports DuplicateUids and 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, Abstractions plus implementation pairs.

globalNamespaceId does 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 MergeMembers from 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 level docfx.json property

Names 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.Widget becomes MyLib::MyLib.Widget and MyLib.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" }), where null means 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 metadata rather 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 name

Version specific packages are often one project built several times, sharing an AssemblyName and 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 assemblyUids for the assemblies its own entry documents, so api/ef8 and api/ef9 each get a complete set of pages, while giving MyLib.Ef the component Ef9 in assemblyUids nominates 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-lib or net8.0, and unlike a namespace it may start with a digit, so assembly names like 7zip.Net work. 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 as InvalidAssemblyUid and skipped.

assemblyLabel — per entry, how the assembly is shown

Nothing displayed names the assembly unless this asks for it, so enabling assemblyUids on an existing site leaves every label, title and breadcrumb as it was:

value what a namespace looks like
none (default) MyLib — the namespace alone
shared MyLib (MyLib.Ef8), but only for the namespaces more than one assembly of this entry declares
suffix MyLib (MyLib.Ef8) for every namespace of a qualified assembly
page MyLib, with Assembly: MyLib.Ef8.dll named on the page, the way type pages do

Over one entry documenting MyLib and MyLib.Ef8, where both declare MyLib and MyLib.Data and only MyLib.Ef8 declares MyLib.Ef8.Internal — this is real generated output, ordered by UID:

none (default)                     shared
────────────────                   ──────────────────────────────
MyLib                              MyLib (MyLib.Ef8)
MyLib.Data                         MyLib.Data (MyLib.Ef8)
MyLib.Ef8.Internal                 MyLib.Ef8.Internal     <- one assembly, no label
MyLib                              MyLib (MyLib)
MyLib.Data                         MyLib.Data (MyLib)

# Namespace MyLib                  # Namespace MyLib (MyLib.Ef8)

suffix                             page
──────────────────────────────     ──────────────────────────────
MyLib (MyLib.Ef8)                  MyLib
MyLib.Data (MyLib.Ef8)             MyLib.Data
MyLib.Ef8.Internal (MyLib.Ef8)     MyLib.Ef8.Internal
MyLib (MyLib)                      MyLib
MyLib.Data (MyLib)                 MyLib.Data

# Namespace MyLib (MyLib.Ef8)      # Namespace MyLib
                                   Assembly: MyLib.Ef8.dll

shared compares the namespaces of one metadata entry, 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 from shared even where namespaces collide across entries, and wants suffix on those entries; a project documenting several assemblies in one entry, which is Lucene.NET's shape, is what shared is for. Documented, with the other cases where a namespace is deliberately not labelled.

namespaceLayout: nested is 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 Exec compiled 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, and shared could 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 existing globalNamespaceId insertion. 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).

assemblyUidOverride is applied in Build right after Compile, 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:

  1. SymbolUrlResolver.GetDocfxUrl built 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.
  2. crefs. XmlComment turns T:Foo.Bar into a uid by pure text strip with no symbol context, so <see cref="..."/> produced dangling xrefs. A ResolveAssemblyUid callback on XmlCommentParserContext resolves the target through the compilation using DocumentationCommentId.GetFirstSymbolForDeclarationId — the same call AddReference already makes — and is supplied by both the mref and apiPage comment parsers. It is a no-op when neither setting is used.
  3. File names. VisitorHelper.PathFriendlyId maps : to -, one dash per character, because that is exactly what PathUtility.ToCleanUrlFileName does to the member pages memberLayout: separatePages splits 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.
  4. API filters use a new VisitorHelper.GetRawId, so filterConfig uidRegex rules keep matching the actual API surface and existing filter files keep working when the settings are enabled.

assemblyLabel is applied in ApplyAssemblyLabel, after MergeMembers, rather than in the symbol visitor: shared has to know which namespaces more than one of the entry's assemblies declares, and a visitor only ever sees one assembly. suffix shares that implementation. MergeMembers keys on the uid alone and MergeReferences touches only the references table, so nothing reads a display name in between, and the TOC label, the page name/nameWithType/fullName and the xrefmap all pick it up as before. 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.

With namespaceLayout: nested, YamlMetadataResolver groups each qualified assembly's namespaces under a node naming that assembly. That node carries no UID, so it needs no page and cannot report UidNotFound, 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. XrefInlineShortParser accepts ::, since both colons are characters the @uid shortcut 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: page could not render

Worth calling out as its own defect, because it is what shaped the tests. SymbolVisitorAdapter set ItemViewModel.NamespaceAssembly, it was written to the .yml, and the {{#namespaceAssembly}} blocks were in the default and modern namespace partials — but ManagedReferenceDocumentProcessor.UpdateModelContent replaces the model with ApiBuildOutput.FromModel, which had no such property and never copied it. Being a declared [YamlMember], it could not arrive through the Metadata catch-all either. So the option was documented, tested at the metadata level, and rendered nothing.

That is why every assemblyLabel value 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: GetAssemblyUid returns 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, and assemblyLabel shows nothing until it is set.

No .verified.* file is touched by this PR. SeedMarkdown, which uses namespaceLayout: nested and 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 and Overload: crefs; generics and type parameters qualified exactly once; components with digits and dashes; composition with globalNamespaceId; filter rules still matching unqualified uids; ::-- file names and the hrefs and anchors SymbolUrlResolver derives 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. Plus ApplyAssemblyLabel driven 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: the Ignore duplicated member warning and lost page without the settings, which locks in the regression; distinct pages, .manifest entries and TOC entries with them; that the assembly does not surface as a namespace anywhere; plain labels by default; suffix labelling all of them, in the TOC and on the page; shared labelling 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 the apiPage/markdown TOC path, which had no coverage of this at all.
  • test/docfx.Tests/AssemblyUidBuildTest.cs, new: builds a site from docfx.json (so the JSON binding is covered too) with memberLayout: 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 in data-uid. Plus one theory row per assemblyLabel value asserting the rendered HTML — the heading, the title and the assembly line — including none, which pins that enabling assemblyUids changes nothing displayed.
  • test/Docfx.MarkdigEngine.Extensions.Tests/XrefTest.cs: the :: shortcut in all four xref forms, and a trailing :: still ending the uid.
  • Serialization round-trip fixtures cover both the array and object forms for Newtonsoft ↔ System.Text.Json parity, and all three settings are in schemas/docfx.schema.json (which is additionalProperties: false).

Docs

  • docs/reference/docfx-json-reference.md: assemblyUids under global properties, assemblyUidOverride and assemblyLabel under metadata.
  • 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 each assemblyLabel value renders (the block above, taken from a real run), why shared is 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 LinqToDB namespace, and four Entity Framework Core packages built from one shared props file so that all four produce the assembly name linq2db.EntityFrameworkCore. That last detail is what motivated assemblyUidOverride.

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:

private patch this PR
total warnings 1302 68
InvalidBookmark (dead in-page anchors) 1197 1
Ignore duplicated member 28 0
InvalidAssemblyReference 5 0
DuplicateUids / UidNotFound 0 0
errors 0 0

Of 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 InvalidFileLink from Microsoft Learn relative links inside linq2db's own XML comments, and 1 InvalidBookmark from a hand written #remarks anchor. The difference against the private patch is exactly the consumers listed under Implementation: that patch emits a malformed commentId (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 LinqToDB namespace declared by both linq2db and linq2db.Tools now has a page each, linq2db--LinqToDB.html and linq2db.Tools--LinqToDB.html, and both are titled Namespace LinqToDB and read as LinqToDB in the table of contents, because that is the namespace they document. The site is linq2db/docs#66.

Not included

  • The pre-existing globalNamespaceId inconsistency in GetDocfxUrl — it builds hrefs from the raw comment id, so globalNamespaceId is 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.
  • Unioning AssemblyNameList when MergeMembers merges a namespace or class page. This is a real gap — samples/seed's own CatLibrary.Core namespace page reports only CatLibrary although half its types come from CatLibrary.Core — but it changes default output, would need a snapshot update, and both shipped templates render only assemblies.0, so nothing would become visible without a template change too. Better as its own PR.
  • A CLI option, consistent with the other map and collection valued metadata settings.

@paulirwin

Copy link
Copy Markdown

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:

  • Zero warnings
  • All conflicting types got their own page
  • Prefixing correctly follows the declaring assembly
  • UID surface is byte-identical to the per-project build
  • Many previously-broken links are now fixed

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:

  1. Page titles. <h1> reads "Namespace {Prefix}.{Namespace}" which isn't a namespace
  2. Namespace and full name contradict each other on the same page
  3. TOC labels under namespaceLayout: nested. This does give one root per assembly, which is the grouping we want. But the roots end up like {Prefix}.{Assembly} and that does not match our assembly names (which would cause confusion to our users).
  4. Authored links (i.e. in Markdown) must name a fake namespace

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:

xref:Lucene.Net.TestFramework::Lucene.Net.Analysis.MockAnalyzer

(delimiter of :: is not important to me; don't want to bikeshed that now. I have no preference on what delimiter to use.)

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.

@MaceWindu

Copy link
Copy Markdown
Author

@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>
@MaceWindu
MaceWindu force-pushed the fix/8966-assembly-uid-prefixes branch from d55445f to ea75d84 Compare August 13, 2026 21:20
@MaceWindu MaceWindu changed the title Disambiguate assemblies that share namespaces Qualify UIDs by assembly to disambiguate shared namespaces Aug 13, 2026
@MaceWindu

Copy link
Copy Markdown
Author

@paulirwin thanks for valuable feedback, design updated to address rised issues.

@MaceWindu
MaceWindu marked this pull request as ready for review August 13, 2026 21:33
MaceWindu added a commit to linq2db/docs that referenced this pull request Aug 13, 2026
* 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>
@MaceWindu
MaceWindu marked this pull request as draft August 14, 2026 09:53
`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>
@MaceWindu
MaceWindu marked this pull request as ready for review August 14, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants