Repository navigation
feat: resolve <inheritdoc/> across assembly boundaries - #43
Conversation
Add native cross-assembly <inheritdoc/> resolution for ApiMark.DotNet,
inspired by SauceControl/InheritDoc but without adding that dependency.
- ExternalXmlDocResolver (new): locates and lazily parses external
assemblies' sibling XML doc files, with ref/<->lib folder-swap
fallback and per-member caching (including negative cache hits).
- XmlDocReader: optional external-member-lookup fallback used when a
member isn't found in the local assembly's XML docs.
- DotNetGenerator: seeds an assembly resolver from configured reference
paths and wires the external resolver into XmlDocReader.
- New ReferencePaths option surfaced end-to-end:
- ApiMark.Tool: --reference-paths <path> CLI flag (repeatable)
- ApiMark.MSBuild: ApiMarkReferencePaths task property, auto-harvested
from @(ReferencePath) when unset
Verified end-to-end against real NuGet package references and the
.NET BCL (e.g. System.IDisposable) using reference-assembly XML docs
shipped with the SDK.
Includes full test coverage (DotNet/MSBuild/Tool/PackageTests), and
companion design/reqstream/verification/sysml2/user-guide updates.
Reviewed via the built-in code-review agent and formal reviews across
all 21 affected .reviewmark.yaml review-sets (gpt-5.4-mini); all
feature-caused findings addressed (thread-safety doc accuracy, stale
system-level design/verification docs, missing edge-case requirements/
tests, non-atomic requirement split, subsystem-level doc coverage).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in compatibility, reference resolution, resource cleanup, caching, and MSBuild override behavior.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds cross-assembly .NET <inheritdoc/> resolution through CLI and MSBuild reference paths.
Changes:
- Adds external XML documentation lookup with caching and
ref/libfallback. - Wires repeatable reference-path options through CLI, MSBuild, and the generator.
- Adds fixtures, tests, requirements, design, and verification documentation.
File summaries
| File | Reviewed changes | Review notes |
|---|---|---|
test/ApiMark.Tool.Tests/ProgramTests.cs |
CLI end-to-end coverage | — |
test/ApiMark.Tool.Tests/Cli/ContextTests.cs |
Reference-path parsing tests | — |
test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs |
MSBuild argument tests | — |
test/ApiMark.MSBuild.PackageTests/PackageIntegrationTests.cs |
Package integration scenarios | Nit (3 votes, lines 259 and 296): assertions do not prove reference harvesting or external inherited documentation. |
test/ApiMark.MSBuild.PackageTests/Fixtures/DotNet/SampleLibWithReference/SampleLib.csproj |
Reference fixture project | — |
test/ApiMark.MSBuild.PackageTests/Fixtures/DotNet/SampleLibWithReference/SampleClass.cs |
Reference fixture source | — |
test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs |
External inheritdoc tests | — |
test/ApiMark.DotNet.Tests/FixturePaths.cs |
External fixture paths | — |
test/ApiMark.DotNet.Tests/ExternalXmlDocResolverTests.cs |
Resolver unit tests | Nit (1 vote, line 337): the negative-cache test should rewrite the XML before the second lookup. |
test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs |
Generator integration tests | — |
test/ApiMark.DotNet.Tests/ApiMark.DotNet.Tests.csproj |
External fixture reference | — |
test/ApiMark.DotNet.Fixtures/ExternalInheritDocClass.cs |
Cross-assembly inheritdoc fixture | — |
test/ApiMark.DotNet.Fixtures/ApiMark.DotNet.Fixtures.csproj |
Fixture project reference | — |
test/ApiMark.DotNet.Fixtures.External/IExternalBaseInterface.cs |
External interface fixture | — |
test/ApiMark.DotNet.Fixtures.External/ExternalBaseClass.cs |
External base-class fixture | — |
test/ApiMark.DotNet.Fixtures.External/ApiMark.DotNet.Fixtures.External.csproj |
External fixture project | — |
src/ApiMark.Tool/Program.cs |
CLI option wiring | — |
src/ApiMark.Tool/Cli/Context.cs |
CLI option parsing | — |
src/ApiMark.MSBuild/build/DemaConsulting.ApiMark.MSBuild.targets |
MSBuild harvesting and forwarding | Moderate (3 votes, line 70): explicit empty ApiMarkReferencePaths is repopulated; preserve an explicit-set marker or provide an opt-out. |
src/ApiMark.MSBuild/ApiMarkTask.cs |
MSBuild property and argument support | — |
src/ApiMark.DotNet/XmlDocReader.cs |
External lookup fallback | Critical (1 vote, line 63): preserve the original two-parameter constructor and add a separate three-parameter overload for binary compatibility. |
src/ApiMark.DotNet/ExternalXmlDocResolver.cs |
External XML documentation resolver | Moderate (1 vote, line 50): use a platform-aware path comparer to avoid conflating distinct Linux paths. |
src/ApiMark.DotNet/DotNetGenerator.cs |
Cecil resolver and generator wiring | Moderate (3 votes, line 110): normalize reference paths with Path.GetFullPath before extracting directories.Moderate (1 vote, line 118): protect assembly loading with resolver cleanup on failure. Moderate (1 vote, line 129): dispose the resolver on the successful path. |
requirements.yaml |
Requirements inclusion | — |
docs/verification/api-mark-tool/program.md |
CLI verification documentation | — |
docs/verification/api-mark-tool/cli/context.md |
Context verification documentation | — |
docs/verification/api-mark-tool/cli.md |
CLI verification updates | — |
docs/verification/api-mark-msbuild/api-mark-task.md |
MSBuild task verification | — |
docs/verification/api-mark-msbuild.md |
MSBuild verification updates | — |
docs/verification/api-mark-dot-net/xml-doc-reader.md |
Reader verification documentation | — |
docs/verification/api-mark-dot-net/external-xml-doc-resolver.md |
Resolver verification documentation | — |
docs/verification/api-mark-dot-net/dot-net-generator.md |
Generator verification documentation | — |
docs/verification/api-mark-dot-net.md |
.NET verification updates | — |
docs/user_guide/dotnet.md |
User-facing option documentation | — |
docs/sysml2/model/api-mark-dot-net/external-xml-doc-resolver.sysml |
Resolver model unit | — |
docs/sysml2/model/api-mark-dot-net.sysml |
Model hierarchy update | — |
docs/reqstream/api-mark-tool/program.yaml |
CLI requirements | — |
docs/reqstream/api-mark-tool/cli/context.yaml |
Context requirements | — |
docs/reqstream/api-mark-tool/cli.yaml |
CLI requirement linkage | — |
docs/reqstream/api-mark-msbuild/api-mark-task.yaml |
MSBuild task requirements | — |
docs/reqstream/api-mark-msbuild.yaml |
MSBuild requirement linkage | — |
docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml |
Reader requirements | — |
docs/reqstream/api-mark-dot-net/external-xml-doc-resolver.yaml |
Resolver requirements | — |
docs/reqstream/api-mark-dot-net/dot-net-generator.yaml |
Generator requirements | — |
docs/reqstream/api-mark-dot-net.yaml |
.NET requirement linkage | — |
docs/design/api-mark-tool/program.md |
CLI design update | — |
docs/design/api-mark-tool/cli/context.md |
Context design update | — |
docs/design/api-mark-tool/cli.md |
CLI design update | — |
docs/design/api-mark-tool.md |
Tool architecture update | — |
docs/design/api-mark-msbuild/api-mark-task.md |
Task design update | — |
docs/design/api-mark-msbuild.md |
MSBuild architecture update | — |
docs/design/api-mark-dot-net/xml-doc-reader.md |
Reader design update | — |
docs/design/api-mark-dot-net/external-xml-doc-resolver.md |
Resolver design | Nit (1 vote, line 130): the design calls the public resolver internal; align the design contract or source visibility/tests. |
docs/design/api-mark-dot-net/dot-net-generator.md |
Generator design update | — |
docs/design/api-mark-dot-net.md |
.NET architecture update | — |
ApiMark.slnx |
Adds external fixture project | — |
.reviewmark.yaml |
Adds review coverage | — |
.cspell.yaml |
Adds technical terms | — |
Review details
Suppressed comments (6)
docs/design/api-mark-dot-net/external-xml-doc-resolver.md:131
- This design section calls the resolver an internal class, but
ExternalXmlDocResolveris declaredpublicand exposes public construction and lookup (src/ApiMark.DotNet/ExternalXmlDocResolver.cs:40,66,84). Update the design contract to describe the public helper and its lack of external service interfaces, or change the source visibility and tests consistently.
N/A — this is an internal class with no external interfaces exposed beyond
its assembly.
src/ApiMark.DotNet/DotNetGenerator.cs:120
assemblyResolveris allocated beforeAssemblyDefinition.ReadAssembly, but the cleanuptrystarts only after that call. If reading the primary assembly fails, the resolver is never disposed, so the failure-path cleanup documented above is skipped. Move the read into a protected block or dispose the resolver in a catch around this call.
src/ApiMark.DotNet/DotNetGenerator.cs:133- The successful-parse path never disposes
assemblyResolver;DotNetEmitter.Emitonly disposesModel.Assembly, while the Cecil resolver can cache every externalAssemblyDefinitionloaded during inheritance analysis. Reusing this library in one process can therefore retain dependency file handles and metadata until process exit, despite the implementation being safe only because the CLI is short-lived; transfer resolver ownership to the emitter/model and dispose it with the assembly.
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:50 - This cache uses a case-insensitive comparer for filesystem paths even on Linux, where
/refs/Foo.dlland/refs/foo.dllcan be distinct files. If both paths are configured, the second path reuses the first path's cached member index and is never loaded, so valid external documentation can be missed; use the platform-aware filesystem comparer already established bysrc/ApiMark.Cpp/CppEmitter.cs:95-107.
test/ApiMark.DotNet.Tests/ExternalXmlDocResolverTests.cs:339 - Deleting the XML file after the first miss does not distinguish a negative cache from a fresh lookup: an implementation that re-reads disk would also return null when the file is gone. Rewrite the file to contain
T:Foo.Missingbefore the second call and assert that the second result is still null, so the test fails when the miss is not cached.
test/ApiMark.MSBuild.PackageTests/PackageIntegrationTests.cs:298 - This success/file-exists assertion cannot prove that an explicit empty property suppressed harvesting: the build succeeds either way, and no generated content depends on external documentation. Make the test observe the child arguments or an external-inheritdoc result that disappears when the empty override is honored; otherwise it will not catch the target's current overwrite behavior.
- Files reviewed: 58/58 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… advisory src/ApiMark.Tool/ApiMark.Tool.csproj and src/ApiMark.MSBuild/ApiMark.MSBuild.csproj pinned Microsoft.SourceLink.GitHub 10.0.300, whose transitive Microsoft.Build.Tasks.Git dependency now has a published moderate-severity NuGet advisory (GHSA-23fw-v26w-5fgq). Restore treats this as a hard error (NU1902) due to WarningsAsErrors, breaking dotnet restore/format for every branch including main - unrelated to the inheritdoc feature in this PR, but blocking CI, so bumping both to 10.0.401 (latest, matching the pinned SDK version) to unblock the pipeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Moderate findings remain in path handling, resolver lifecycle and caching, MSBuild opt-out semantics, and integration-test coverage.
Review details
Suppressed comments (8)
Previously missed (4) — in code that hasn't changed since the last review.
src/ApiMark.DotNet/DotNetGenerator.cs:113
- This comparer is case-insensitive on every platform. On Linux, two distinct reference directories that differ only by case can both be valid; deduplicating them here can prevent Cecil from searching the directory containing the needed assembly. Use the platform-aware path comparer used by
CppEmitterinstead.
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:50 - The per-path cache is also case-insensitive on Linux. If the first configured path (for example
/tmp/Foo.dll) has no XML file and a second, case-distinct path (/tmp/foo.dll) does, the second lookup hits the first path's cachednulland never loads its valid documentation. Match the repository's platform-aware file-system comparer here.
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:203 - The fallback swaps the first
ref/libsegment it encounters, so a parent directory namedliborrefcan be changed instead of the package layout segment. For example,/var/lib/.nuget/packages/Pkg/ref/net8.0/Pkg.dllis probed under/var/ref/...rather than the corresponding.../lib/net8.0directory. Select the layout segment nearest the DLL (or otherwise identify the packageref/libsegment) instead of stopping at the first match.
src/ApiMark.MSBuild/build/DemaConsulting.ApiMark.MSBuild.targets:67 - An empty value declared in the project (for example,
<ApiMarkReferencePaths></ApiMarkReferencePaths>) still satisfies this condition and is overwritten by the harvested list; the command-line test does not expose this because global properties are protected from reassignment. If project-level empty is part of the “explicitly set” contract, add a definedness/opt-out mechanism and an integration test that declares it in the fixture.
src/ApiMark.DotNet/DotNetGenerator.cs:112
Path.GetDirectoryName("External.dll")returns an empty directory, so a valid relative--reference-paths External.dllis filtered out here. The XML resolver can still see the sibling file, but Mono.Cecil receives no search directory and bare cross-assembly<inheritdoc />chain construction fails; normalize the path before extracting its directory.
src/ApiMark.DotNet/DotNetGenerator.cs:113- On the success path the new
DefaultAssemblyResolveris never disposed; only the exception path disposes it. Mono.Cecil's resolver caches externalAssemblyDefinitioninstances, so in-process library callers can retain dependency memory/file handles afterEmitand repeated generations can accumulate them. Tie the resolver to the emitter/model lifetime and dispose it after emission rather than relying on the CLI process exiting.
src/ApiMark.DotNet/DotNetGenerator.cs:113 - These path assumptions break valid configurations: a bare relative reference such as
--reference-paths External.dllhas no directory and is filtered out, so Cecil cannot resolve the external base even though the file is valid; additionally,OrdinalIgnoreCasecollapses distinct reference directories on case-sensitive Linux. Normalize/fallback to the current directory and use a platform-aware filesystem comparer here.
test/ApiMark.MSBuild.PackageTests/PackageIntegrationTests.cs:261 - This assertion does not prove that auto-harvesting occurred: the fixture contains no
<inheritdoc />that needs the referenced package, andapi.mdwould still be generated if the new@(ReferencePath)ItemGroup were removed. Add an external-inheritdoc fixture/assertion or otherwise capture the task arguments so this integration test fails when harvesting is broken.
- Files reviewed: 66/67 changed files
- Comments generated: 0 new
- Review effort level: Lite
Fixes 7 issues found by GitHub Copilot code review on PR #43 for the cross-assembly <inheritdoc/> feature: 1. XmlDocReader: restore the original 2-parameter constructor exactly as published (binary-compatible with pre-existing compiled callers) and add a separate 3-parameter overload for the external-lookup delegate, sharing implementation via constructor chaining. Updated two tests that relied on named-argument skipping of the removed merged-optional-parameter overload. 2. ExternalXmlDocResolver: use a platform-aware StringComparer (Ordinal on Linux, OrdinalIgnoreCase elsewhere) for the per-reference-assembly-path doc cache, matching the existing pattern in ApiMark.Cpp.CppEmitter, so case-differing paths are no longer incorrectly treated as duplicates on case-sensitive file systems. 3. DotNetGenerator.Parse: a. Normalize each reference path with Path.GetFullPath before computing its directory for the Mono.Cecil assembly resolver's search paths. b. Wrap assembly reading in a try/catch that disposes the assembly resolver on failure, so a failed AssemblyDefinition.ReadAssembly call no longer leaks the resolver. c. Transfer resolver ownership to the returned model (DotNetAstModel) instead of leaking it on the success path; DotNetEmitter.Emit now disposes both the assembly and its resolver together. 4. DemaConsulting.ApiMark.MSBuild.targets: add a dedicated ApiMarkDisableReferencePathsHarvest opt-out property (mirroring the existing DisableApiMark convention) because MSBuild cannot distinguish "never set" from "explicitly set to empty" for a plain property, so ApiMarkReferencePaths="" alone could not suppress auto-harvest. Documented the new property in the design doc and .NET user guide. 5. PackageIntegrationTests: strengthened the auto-harvest and suppression tests to query the effective ApiMarkReferencePaths value via dotnet build -getProperty after the build, proving harvesting actually occurred (and includes the expected Newtonsoft.Json reference) or was actually suppressed via the new opt-out property, rather than only checking that the build succeeded. 6. ExternalXmlDocResolverTests: the negative-cache test now rewrites the backing XML file (instead of deleting it) between the first and second lookup, proving the cached miss result is served from cache rather than from a (coincidentally also null) fresh disk read. 7. external-xml-doc-resolver.md: corrected the design doc to describe ExternalXmlDocResolver as the public class it actually is, documenting its real consumers, instead of incorrectly describing it as internal. Verified: full solution build, ApiMark.DotNet.Tests (net8/9/10), ApiMark.MSBuild.Tests, ApiMark.Tool.Tests, and ApiMark.MSBuild.PackageTests all pass (one pre-existing, unrelated C++ vcxproj package test failure due to local MSVC/toolchain environment, and pre-existing clang-dependent Cpp test skips/failures, both out of scope). dotnet reviewmark --lint, dotnet reqstream --lint, reviewmark --plan --enforce, and reqstream --enforce --tests show only pre-existing, unrelated coverage gaps (Cpp/Clang parsing and MSBuild C++ include-path harvesting, tied to the missing clang/MSVC toolchain in this environment). fix.ps1 and lint.ps1 (cspell, markdownlint, yamllint, sysml2tools lint, dotnet format --verify-no-changes) are clean.
There was a problem hiding this comment.
🟡 Changes recommended
Moderate findings remain in resolver fallback, external inheritance chaining, MSBuild option handling, and test coverage.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:220
- The fallback swaps the first
ref/libsegment, so a valid package path with an earlier segment of the same name (for example/home/lib/.nuget/.../ref/net8.0/Foo.dll) probes the wrong directory and misses the XML file. Search from the assembly path backwards so the package'sref/libsegment is selected.
docs/design/api-mark-dot-net/dot-net-generator.md:128
- This design note repeats that the assembly resolver is not disposed on the success path, but the changed emitter now owns and disposes it after emission. Keeping this statement makes the documented ownership/lifetime contradict the implementation and can mislead API callers about using the resolver after
Emit; update the design contract to describe disposal with the returned emitter.
explicitly added this way. The resolver is intentionally not disposed on the
success path (see the class-level remarks / Known limitation below); it is
disposed on the failure path alongside the parsed assembly.
docs/verification/api-mark-dot-net/external-xml-doc-resolver.md:99
- Deleting the XML file cannot prove the negative cache: a fresh lookup after deletion would also return
null. The test actually rewrites the file with the previously missing member and asserts the second lookup remainsnull, which is the distinguishing evidence; update this verification text to match that behavior.
src/ApiMark.DotNet/DotNetGenerator.cs:117 - This deduplicates reference directories case-insensitively on every platform. On Linux, valid distinct paths such as
/tmp/Liband/tmp/libcan contain different referenced assemblies, so dropping one can make Mono.Cecil fail to resolve an external base/interface. The established filesystem convention usesStringComparer.Ordinalon Linux (src/ApiMark.Cpp/CppEmitter.cs:95-107); use that platform-aware comparer here as well.
src/ApiMark.DotNet/XmlDocReader.cs:432 - An externally resolved member is returned as a raw
XElement, but if that member's own documentation contains a bare<inheritdoc />,ResolveInheritdocSourcecan only consult_inheritanceChain, which is built for the primary assembly and has no entry for the external member. A chain such as primaryC.M→ externalI1.M→ externalI2.Mtherefore still loses its documentation; either provide inheritance metadata for referenced assemblies or explicitly constrain and test this behavior.
test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs:2305 - This end-to-end test is described as proving external base-type and interface resolution, but it only asserts
ExternalInterfaceMethod. A regression in the externalExternalBaseClass.DescribeBaseinheritance path or its XML lookup would still pass; add an assertion for theDescribeBasepage containingDescribes the base implementation..
- Files reviewed: 68/69 changed files
- Comments generated: 4
- Review effort level: Lite
- Extract a shared platform-aware FileSystemPathComparer helper in ApiMark.DotNet and use it in both DotNetGenerator's reference-path directory Distinct() and ExternalXmlDocResolver's per-path doc cache, eliminating the last unconditional OrdinalIgnoreCase path comparison that could wrongly deduplicate case-distinct directories on Linux. - Extract DotNetGenerator.ResolveReferenceSearchDirectory and add a unit test confirming a bare relative reference path (e.g. External.dll) resolves to the current working directory, verifying the existing GetFullPath-before-GetDirectoryName ordering already fixed this. - Fix ExternalXmlDocResolver.SwapRefLibSegment to scan from the end of the path backwards and swap the ref/lib segment nearest the assembly file, instead of the first matching segment, so an unrelated earlier path segment (e.g. a decoy /home/lib/... folder) is never matched in preference to the real NuGet package-layout segment. Added a regression test with a decoy segment earlier in the path. - Add a second assertion to DotNetGenerator_Parse_ExternalBaseWithReferencePaths_ResolvesInheritedDocumentation proving the external base-CLASS inheritdoc (DescribeBase) resolves, not just the external interface member. - Document (XmlDocReader.cs remarks, design doc, user guide) and pin down with a regression test the known limitation that a bare (cref-less) <inheritdoc/> chain crossing two or more assembly boundaries does not resolve, because the inheritance chain is built only from the primary assembly's Cecil metadata. Explicit cref chains are unaffected and continue to resolve across any number of hops. - Correct stale documentation: the assembly resolver is now disposed on the success path too (ownership transferred to DotNetAstModel/ DotNetEmitter), and the negative-cache verification test rewrites (rather than deletes) the XML file between calls. - Verified test/ApiMark.MSBuild.PackageTests/PackageIntegrationTests.cs auto-harvest test already asserts the effective ApiMarkReferencePaths property value contains the referenced Newtonsoft.Json path (approach b); no further change needed for that finding.
The requirements.yaml entry for ApiMarkMsbuild-ApiMarkTask-AutoPopulateReferencePaths referenced a test method name (ApiMarkMsbuild_NuGetPackage_DotNetProject_ExplicitReferencePaths_SuppressesAutoHarvest) that was renamed to ApiMarkMsbuild_NuGetPackage_DotNetProject_DisableReferencePathsHarvest_SuppressesAutoHarvest in the prior round-2 review-fix commit (14c7f55), without updating the requirements traceability link. This caused CI's aggregated reqstream --enforce (which runs against the full TRX set from all platforms/TFMs) to report both ApiMarkMsbuild-ApiMarkTask-AutoPopulateReferencePaths and its parent ApiMarkMsbuild-ApiMarkTask-IntegrateWithMsbuild (via rollup) as unsatisfied, dropping coverage from 380 to 378 of 380 requirements.
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (7)
.reviewmark.yaml:316
- The new shared
src/ApiMark.DotNet/FileSystemPathComparer.csis not included in any ReviewMark path set, while the other changed .NET source files are explicitly assigned to the generator or resolver sets. As a result, the review plan does not cover this new path-comparison implementation; add it to the appropriate set so ReviewMark enforces review coverage for the changed source.
- src/ApiMark.DotNet/ExternalXmlDocResolver.cs
- test/ApiMark.DotNet.Tests/ExternalXmlDocResolverTests.cs
docs/reqstream/api-mark-msbuild/api-mark-task.yaml:54
- This requirement says the targets must not override any explicitly set
ApiMarkReferencePathsvalue, but the implementation intentionally repopulates the property when the explicit value is empty because MSBuild cannot distinguish empty from unset. Narrow the requirement to non-empty explicit values or state the requiredApiMarkDisableReferencePathsHarvestopt-out so the requirement matches the documented behavior and test.
ApiMarkTask's .targets file shall automatically populate
ApiMarkReferencePaths from the resolved @(ReferencePath) items when
ApiMarkReferencePaths is not explicitly set, and shall not override
an
explicitly set value.
docs/verification/api-mark-msbuild/api-mark-task.md:174
- This verification scenario claims that an explicitly empty
ApiMarkReferencePathssuppresses harvesting and names a test that does not exist. The implementation and the adjacent integration test document the opposite: an empty value cannot be distinguished from unset, andApiMarkDisableReferencePathsHarvest=trueis the opt-out. Please align this scenario with the actual behavior and test name.
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:71 - The constructor keeps the caller-owned
IReadOnlyListby reference while this type caches misses for the lifetime of the instance. If a caller passes a mutableList<string>and adds a reference after a miss, the new path is never searched for that member because_memberCachestill returns the old negative result. Snapshot the paths at construction so the cache and search set stay consistent.
src/ApiMark.MSBuild/build/DemaConsulting.ApiMark.MSBuild.targets:75 - The new
.targetscontract also promises that a user-suppliedApiMarkReferencePathsvalue is not overwritten, but the package integration tests only prove default harvesting and the separate disable switch. The direct task test bypasses this target logic, so add a real MSBuild integration case with a non-empty explicit path and assert that the effective property remains exactly that value.
src/ApiMark.Tool/Cli/Context.cs:603 patternis misleading here: this value is a single assembly file path, not a wildcard pattern, and the surrounding repeatable path option uses path-oriented names. Rename it topathso the parser's local variable matches theReferencePathscontract.
test/ApiMark.MSBuild.PackageTests/PackageIntegrationTests.cs:349- This checks whether all captured stdout is whitespace, but
dotnet build -getPropertyemits a property report (including an object even when the property's value is empty), and the helper also captures normal build output. Consequently the test will fail even when auto-harvest is correctly disabled. Assert the extractedApiMarkReferencePathsvalue is empty instead of checking the entire stdout stream.
- Files reviewed: 69/70 changed files
- Comments generated: 1
- Review effort level: Lite
- reviewmark: add FileSystemPathComparer.cs to the ExternalXmlDocResolver review set so it no longer escapes required review coverage. - reqstream: correct the AutoPopulateReferencePaths requirement wording to say a non-empty explicit value is preserved (not any explicit value, since empty is indistinguishable from unset outside a target), and add a dedicated DisableReferencePathsHarvestOptOut requirement for the real ApiMarkDisableReferencePathsHarvest opt-out mechanism, wired into the ApiMarkMsbuild system requirement's children. - verification docs: correct the api-mark-task.md scenario that wrongly claimed an explicit empty ApiMarkReferencePaths suppresses harvesting; split it into two accurate scenarios referencing the real tests. - ExternalXmlDocResolver: snapshot the reference assembly path list at construction time (ToArray()) instead of holding a live reference to the caller's list, preventing stale negative-cache results if the caller mutates the original list after construction. Add a pinning unit test. - Context.cs: rename the misleading pattern local to path in the --reference-paths case, since it is a single assembly path, not a wildcard pattern. - PackageIntegrationTests.cs: add an ExtractGetPropertyValue helper that parses -getProperty output (plain or JSON) instead of asserting against raw captured stdout, use it in the existing harvest/disable tests, and add a new end-to-end test proving a non-empty explicit ApiMarkReferencePaths value survives the .targets auto-harvest logic unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical lookup issues and moderate type-support, command-line-size, test-isolation, and documentation findings remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
docs/design/api-mark-dot-net/external-xml-doc-resolver.md:88
- The implementation scans backward and swaps the last
ref/libsegment (lines 218-230), but this design description still says it swaps the first segment. Please update the description to say "last" so the documented fallback matches the behavior for paths containing an earlier segment with the same name.
**SwapRefLibSegment** (private static): Swaps the first path segment named
exactly `ref` or `lib` (case-insensitively) for the other, mimicking the
folder layout convention used by many NuGet packages where compile-time
src/ApiMark.DotNet/DotNetGenerator.cs:139
- The new external resolver does not make bare type-level
<inheritdoc/>work:BuildInheritanceChain/BuildTypeInheritanceEntriesonly add method, property, and event IDs, so there is noT:...chain entry for a derived class or interface declaration. A type documented only with bare<inheritdoc/>therefore remains unresolved even withReferencePaths, despite the new contract claiming base types/interfaces are supported. Add type inheritance candidates (and tests), or narrow the documented/required scope to member implementations.
src/ApiMark.MSBuild/build/DemaConsulting.ApiMark.MSBuild.targets:83 - This default serializes every resolved
@(ReferencePath)intoApiMarkReferencePaths, andApiMarkTaskforwards each one throughProcessStartInfo.ArgumentList. Large projects with many or long reference paths can exceed the host OS command-line limit (especially Windows' ~32K limit), causing the post-build task to fail before ApiMark runs. Avoid requiring the full reference set on the command line (for example, use a response/config file or resolve the references inside the task) or explicitly define a bounded strategy.
test/ApiMark.MSBuild.PackageTests/Fixtures/DotNet/SampleLibWithReference/SampleLib.csproj:35 - This new package reference makes the isolated integration tests depend on a network restore:
RunInIsolationredirectsNUGET_PACKAGESto a fresh temp directory, while its generatednuget.configonly has the ApiMark package locally and still lists nuget.org (PackageIntegrationTests.cs:529-538). Consequently every run must download Newtonsoft.Json, so these tests fail in offline/restricted CI despite the package-test harness being designed to use local packages. Use a local fixture/package source (or an SDK/in-repository reference) instead of introducing an uncached nuget.org dependency.
- Files reviewed: 69/70 changed files
- Comments generated: 2
- Review effort level: Lite
- docs: correct external-xml-doc-resolver design doc to say SwapRefLibSegment swaps the LAST ref/lib segment (nearest the DLL), not the first, matching the round-2 decoy-segment fix already in the implementation - docs: document bare <inheritdoc/> on a type declaration (class/interface/ struct) as a known, pre-existing limitation of the inheritance-chain mechanism (not a cross-assembly regression) in the dotnet user guide and dot-net-generator design doc - docs: document the theoretical OS command-line length risk of forwarding many ApiMarkReferencePaths entries as individual --reference-paths arguments, mirroring the same shared risk already documented for ApiMarkIncludePaths/--includes - fix: replace the SampleLibWithReference fixture's Newtonsoft.Json PackageReference with a ProjectReference to a new offline-safe companion ReferencedLib fixture project, removing a hidden network dependency on nuget.org from the isolated/offline PackageIntegrationTests auto-harvest tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:224
- On case-sensitive systems this treats directory segments such as
REForLibas the NuGetref/liblayout even though they are different paths on Linux. If an unrelated lowercase counterpart exists, a reference under/pkg/REF/...can resolve documentation from it incorrectly. Use the shared platform-aware comparer for these segment checks (ordinal on Linux, ignore-case only on Windows/macOS).
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:77 - The per-file cache is keyed by the raw path string, so aliases such as
./ref/Foo.dlland/cwd/ref/Foo.dll(or equivalent separator forms) are treated as different entries and cause the same XML file to be parsed twice. That violates the documented "each reference assembly's XML documentation file is parsed at most once" guarantee and is especially costly when MSBuild supplies many references; normalize and deduplicate paths when taking the snapshot.
src/ApiMark.Tool/Cli/Context.cs:603 - An empty value passed as
--reference-pathsis accepted and added to the configured list. That makesReferencePaths.Count > 0and later letsExternalXmlDocResolverprobePath.ChangeExtension("", ".xml")(and the resolver search directory resolve from the current directory), so a working-directory.xmlfile could be treated as external documentation instead of the option being ignored or rejected. Reject whitespace-only paths before adding them.
- Files reviewed: 71/72 changed files
- Comments generated: 1
- Review effort level: Lite
- SwapRefLibSegment now uses the platform-aware FileSystemPathComparer instead of always OrdinalIgnoreCase, so a ref/lib segment with different case on a case-sensitive file system is no longer incorrectly treated as a match. - ExternalXmlDocResolver's constructor now normalizes each reference assembly path with Path.GetFullPath before caching, so two different string forms of the same underlying file collapse to a single _docsByReferencePath cache key, preserving the documented parsed-at-most-once guarantee. - Context's --reference-paths CLI option now silently skips whitespace-only values instead of adding them to ReferencePaths, preventing MSBuild semicolon-splitting artifacts from producing a spurious empty reference path that could pick up an unrelated .xml file from the working directory. Adds unit tests for all three fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Four unresolved review findings remain, including two moderate issues requiring code and test fixes.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
docs/design/api-mark-dot-net/external-xml-doc-resolver.md:87
- This description says the
ref/libmatch is case-insensitive everywhere, butSwapRefLibSegmentuses the platform-aware comparer, sorefandREFdo not match on Linux. Please document the platform-specific behavior here so the design does not promise a fallback that the implementation intentionally does not provide.
exactly `ref` or `lib` (case-insensitively) for the other, mimicking the
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:85
- Blank entries are filtered only in the CLI/MSBuild argument path; direct callers can set
DotNetGeneratorOptions.ReferencePaths(or construct this public resolver) with"".Path.GetFullPaththen treats that as a real path, so the resolver can probe an unrelated XML file relative to the current working directory, whileDotNetGenerator.Parsealso derives an incorrect search directory. Normalize/filter empty entries at the shared API boundary before both resolver and Cecil setup.
- Files reviewed: 71/72 changed files
- Comments generated: 1
- Review effort level: Lite
The new ExternalXmlDocResolver_Constructor_RelativeAndAbsoluteFormsOfSamePath_ShareSingleCacheKey test (added in the round-6 review fixes) failed on macOS CI because the OS temp directory lives under a /var symlink to /private/var. Directory.SetCurrentDirectory resolves this symlink, but the originally-captured absolute dllPath string did not, so the relative and absolute forms normalized to two different-looking paths instead of colliding on a single cache key as the test intended. Fixed by deriving the absolute form from the OS-resolved current directory (Directory.GetCurrentDirectory() after SetCurrentDirectory) rather than the original, potentially-unresolved directory string. This is a test-only fix; no production code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
One or more issues must be addressed before approval.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
src/ApiMark.MSBuild/ApiMarkTask.cs:667
- A failure to delete the temporary response file escapes the
finallyblock and can turn an otherwise successful tool run (or the intended gracefulfalseresult) into an unhandled MSBuild task exception. This can occur when the file is briefly locked or the temp directory denies deletion. Treat cleanup failures separately—log them and preserve the child-process result, while still attempting deletion.
src/ApiMark.DotNet/DotNetAstModel.cs:100
- The new
AssemblyResolverfield changes the model's ownership and disposal contract, but the companiondocs/design/api-mark-dot-net/dot-net-ast-model.mdstill documents onlyAssemblyas the owned resource and omits this field from the data model. Please update that design artifact so the parse/emit lifetime and resolver dependency are traceable alongside the source change.
src/ApiMark.DotNet/DotNetEmitter.cs:53 - This makes the emitter single-use: after the first
Emitreturns, both the parsed Cecil assembly and resolver are disposed, so a secondEmitcannot produce the alternate output format. That regresses theIApiEmittercontract, which explicitly says the same parsed data can drive different formats without reparsing (src/ApiMark.Core/IApiEmitter.cs:7-12). Please move ownership to an explicit emitter lifetime (or otherwise keep the model alive across emits), or update the public contract and callers if single-use is intentional.
src/ApiMark.DotNet/FileSystemPathComparer.cs:165 - On a case-sensitive file system, the case-insensitive fallback can canonicalize a path that does not exist to an arbitrary existing sibling. For example, if both
/tmp/Foo.dllis valid and/tmp/foo.dllis absent, normalizing/tmp/FOO.dllselectsFoo.dll; if both case variants exist, enumeration order is only avoided when the input exactly matches one. This can makeExternalXmlDocResolverread documentation for an assembly the caller did not actually reference. Only accept the case-insensitive match after confirming the original segment path exists (or otherwise preserve the supplied casing on case-sensitive misses).
- Files reviewed: 72/73 changed files
- Comments generated: 0 new
- Review effort level: Lite
…t AssemblyResolver Address triaged findings from PR review #pullrequestreview-5188632048: - ApiMarkTask.RunToolProcessWithResponseFile: wrap the finally block's File.Delete(responseFilePath) in a try/catch for IOException and UnauthorizedAccessException, logging a warning instead of letting a transient cleanup failure (lock/permissions) escape as an unhandled exception and override the already-determined task result. - FileSystemPathComparer.FindActualEntryName: when no exact match exists and two or more directory entries match the requested segment case-insensitively (only possible on case-sensitive file systems), stop guessing based on unspecified enumeration order. Only resolve the case-insensitive fallback when exactly one candidate exists; otherwise return null so the caller preserves the as-supplied casing. Adds a regression test for the ambiguous third-casing scenario. - DotNetEmitter.Emit / dot-net-emitter.md: document the pre-existing single-use limitation (Model.Assembly and Model.AssemblyResolver are disposed once Emit returns) as a known, accepted tradeoff rather than changing the lifetime model - confirmed via git history that the Model.Assembly disposal predates this branch and is not a regression. - dot-net-ast-model.md: document the AssemblyResolver property, its constructor parameter, and its Mono.Cecil dependency, closing a design doc gap left by the cross-assembly inheritdoc work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
One or more issues must be addressed before approval.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/ApiMark.Tool/Cli/Context.cs:557
- Response-file lines bypass the
@@branch because they are appended after expansion. Therefore a documented response-file value such as@@mylibreaches parsing unchanged and becomes@@mylib, unlike the equivalent command-line token; the current escape test covers only direct arguments. Apply the same leading-@@unescape to response-file lines without recursively expanding single-@lines.
src/ApiMark.MSBuild/ApiMarkTask.cs:655
- The new response-file parser treats every argument beginning with
@as a response-file token, butApiMarkTaskforwards all MSBuild property values verbatim. Consequently an existing value such asApiMarkLibraryName=@mylib(and likewise any path/description beginning with@) now makes the tool try to readmyliband fail; the@@escape only helps callers that construct the CLI arguments themselves. Escape literal-leading-@values in the MSBuild task while preserving the generated response-file token, or constrain expansion to the intended response-file usage.
src/ApiMark.Tool/Cli/Context.cs:538 - Expanding every top-level token that starts with
@changes the meaning of existing literal values.ApiMarkTaskforwards C++ and shared values such as--includes,--library-name, and--outputwithout applying the@@escape, so a valid MSBuild value like@includeis treated as a response-file path and the build fails before parsing. Either make expansion context-aware or escape literal@values in the MSBuild argument-forwarding path, and cover this regression.
- Files reviewed: 74/75 changed files
- Comments generated: 0 new
- Review effort level: Lite
Address findings from PR review #pullrequestreview-5188706708: - ApiMarkTask now escapes every forwarded MSBuild property/item value that legitimately begins with a literal '@' (assembly/xml-doc paths, --exclude/--reference-paths/--includes/--api-headers entries, --library-name/--library-description/--defines/--output/--visibility/ --format) by prefixing an extra '@'. Without this, Context's response- file-token detection (added for --reference-paths support) would misinterpret an ordinary value starting with '@' as a response-file reference or escape sequence, breaking previously-valid MSBuild property values. Added AppendCommonArguments -> AppendOptionalArg reuse for --output/--visibility/--format so they get the same escaping as everything else instead of duplicating the raw args.Add pattern. - Context.ExpandResponseFileArguments now applies the same "@@" literal- escape unescaping to lines read from an expanded response file that it already applied to top-level arguments. Previously a value written to a response file as "@@MyLib" (to preserve a literal leading '@') was passed through unchanged instead of being unescaped to "@MyLib", differing from the equivalent direct command-line token. The non-recursive, single-pass expansion semantics for lines starting with an unescaped single '@' are unchanged. Both fixes close the loop on the same underlying issue: the '@' token convention introduced for --reference-paths response-file support needs to apply symmetrically regardless of which code path (ApiMarkTask-generated args, ApiMarkTask-generated response file, or a user-authored response file) a value travels through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The review includes one critical and two moderate unresolved findings, including unsafe response-file creation.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/ApiMark.DotNet/FileSystemPathComparer.cs:89
- The drive/UNC root is preserved exactly as supplied, so on Windows two spellings such as
c:\...andC:\...(or differently cased UNC roots) normalize to different Ordinal keys even though they name the same location. That breaks the documented deduplication/at-most-once parsing guarantee and can make the resolver parse the same XML more than once; canonicalize the root prefix separately before combining it with the normalized segments.
src/ApiMark.MSBuild/ApiMarkTask.cs:667 - This handler covers both
PrepareArgumentsForProcessandRunToolProcess, so anIOExceptionorUnauthorizedAccessExceptionraised while starting the child process or handling its redirected streams is reported as “unable to create the reference-paths response file.” That produces a misleading diagnostic (and also affects invocations with no response file); limit this catch to response-file creation or use a separate process-failure message/path.
- Files reviewed: 74/75 changed files
- Comments generated: 1
- Review effort level: Lite
Address findings from PR review #pullrequestreview-5188801331, confirmed independently by a local code-review sub-agent pass over the full branch diff: - FileSystemPathComparer.NormalizeCase: canonicalize the drive-letter or UNC root prefix (uppercase drive letters, lowercase UNC server/share) instead of preserving it verbatim. Unlike every subsequent segment, the root prefix cannot be resolved to a 'real' on-disk casing via a directory-entry lookup, so two differently-cased spellings of the same root (e.g. "c:\..." vs "C:\...") previously normalized to two distinct strings under Ordinal comparison, breaking the documented at-most-once/deduplication guarantee relied on by DotNetGenerator and ExternalXmlDocResolver. Added a regression test. - ApiMarkTask.RunToolProcessWithResponseFile: narrowed the IOException/UnauthorizedAccessException catch to cover only PrepareArgumentsForProcess (response-file creation), no longer also wrapping the RunToolProcess call. Previously an I/O failure starting the child process or handling its redirected streams was misreported as "unable to create the reference-paths response file"; it now propagates unhandled, matching the pre-existing (pre-response-file) behavior. The response file is still deleted via the surrounding finally block regardless. Added a regression test. - ApiMarkTask.PrepareArgumentsForProcess: replaced File.WriteAllLines (which opens with FileMode.Create, silently truncating or writing through any pre-existing file or symlink at the target path) with a new CreateResponseFile helper that opens with FileMode.CreateNew, closing the TOCTOU window between generating the random response-file name and writing to it. Retries (bounded) only on a detected name collision; any other failure propagates normally. - .cspell.yaml: added "TOCTOU" (a genuine technical term used in the new code comments/doc). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/ApiMark.DotNet/FileSystemPathComparer.cs:218
- On a case-sensitive filesystem, this fallback treats a wrong-case path as if it named the existing file whenever there is exactly one case-insensitive match. For example, configuring
/tmp/foo.dllwhen only/tmp/Foo.dllexists normalizes toFoo.dlland can load unrelated external XML documentation instead of leaving the configured reference unresolved. Gate the case-insensitive fallback on the candidate path itself existing with the filesystem's normal semantics (or use a filesystem-aware comparison policy) so this normalization does not conflate distinct Linux paths.
src/ApiMark.MSBuild/ApiMarkTask.cs:731 - The response-file format treats each line as a complete argument, but this writes each reference path verbatim. On Unix, a newline is valid in a file name, so a valid
ApiMarkReferencePathsentry containing\nis split into multiple arguments (and could even become an injected flag), causing the child invocation to resolve the wrong path or fail. Encode line breaks in the response-file protocol or reject them before writing the file.
- Files reviewed: 74/75 changed files
- Comments generated: 1
- Review effort level: Lite
…eanup
The CreateResponseFile retry guard ('catch (IOException) when (attempt <
maxAttempts && File.Exists(path))') could not distinguish a name collision
at creation time (nothing written yet, safe to retry) from a failure while
writing/flushing content after the file was already successfully created
(not safe to retry, since doing so would leak the partially-written file).
Both cases satisfy 'File.Exists(path) == true' once the file exists, so a
write-time IOException (e.g. a full disk mid-write) would incorrectly
trigger a retry with a fresh name, masking the real failure and abandoning
the partial file.
Split the method into two scopes: the FileMode.CreateNew call alone is
retried on collision (nothing to clean up if it fails, since nothing was
created), while the subsequent write/flush is not retried — on failure the
partially-written file is deleted on a best-effort basis and the original
exception is rethrown unchanged.
Also made CreateResponseFile internal (previously private) so it can be
exercised directly by a new regression test that forces a write-time
failure via a throwing IEnumerable<string>, asserting no retry occurs, no
partial file is left behind, and the original exception propagates.
Found via an unresolved PR review thread on the prior CreateResponseFile
change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Four unresolved moderate findings affect resolver behavior/performance and MSBuild argument handling; a documentation nit also remains.
Review details
Suppressed comments (4)
src/ApiMark.DotNet/ExternalXmlDocResolver.cs:132
- With the new MSBuild default,
ReferencePathscan contain every resolved dependency. On any external member miss, this loop parses the XML index for every configured reference before cachingnull; a project with many references and distinct unresolved/unsupported<inheritdoc/>targets therefore incurs an O(reference-count) set of disk reads and XML parses. Carry the resolved declaring assembly/path with inheritance targets, or otherwise index candidates by assembly, so a miss does not probe the entire auto-harvested dependency set.
src/ApiMark.DotNet/FileSystemPathComparer.cs:218 - On a case-sensitive file system, a non-existent spelling can still have one case-insensitive match (for example, configured
foo.dllwhen onlyFoo.dllexists). This branch currently treats that other entry as the requested path, soExternalXmlDocResolvercan silently load documentation for an assembly the supplied path does not identify (and the same issue applies to a wrongly-cased intermediate directory). Only accept the case-insensitive fallback after confirming that the original candidate path itself exists; otherwise leave the segment unchanged.
src/ApiMark.DotNet/XmlDocReader.cs:543 - This external fallback still cannot resolve a common transitive case: if the external member returned here has its own bare
<inheritdoc/>, the recursive call reachesResolveInheritdocSourcewith an ID absent from the primary assembly's_inheritanceChainand returnsnull. Consequently a local override silently loses documentation even though the external XML contains an inheritance path; build/cache inheritance metadata for resolved external assemblies (or otherwise recurse their bare targets) so cross-assembly resolution remains correct beyond one hop.
src/ApiMark.MSBuild/ApiMarkTask.cs:732 PrepareArgumentsForProcesssearches the entire logical argument list for the literal--reference-paths, even when that token is actually an unrelated option value. For example, an MSBuildApiMarkLibraryDescriptionof--reference-pathsmakes this code consume the following flag as a reference-path value, write a malformed response file, and cause the tool invocation to fail. Restrict the substitution to the generated .NET reference-path pairs (or parse option/value pairs so values are skipped) instead of scanning arbitrary tokens.
- Files reviewed: 74/75 changed files
- Comments generated: 0 new
- Review effort level: Lite
…h case-sensitivity helpers (#44) * Move path case-normalization into ApiMark.Core.PathHelpers FileSystemPathComparer.NormalizeCase/Comparer was duplicated verbatim as its own internal class in ApiMark.DotNet, used independently by both DotNetGenerator and ExternalXmlDocResolver via near-identical 'filter blank -> resolve -> NormalizeCase with a scoped directoryEntryCache -> Distinct' boilerplate at each call site. Path case-normalization is a general path concern, not a DotNet-specific one, so move it into the existing ApiMark.Core.PathHelpers utility (which already centralizes the other general path concern, directory-traversal-safe combination) rather than duplicating it further. PathHelpers is now public (SafePathCombine remains internal) so NormalizeCase/Comparer can be called from ApiMark.DotNet. - Added NormalizeCase/Comparer (plus the private CanonicalizeRoot/ FindActualEntryName helpers) to src/ApiMark.Core/PathHelpers.cs. - Deleted src/ApiMark.DotNet/FileSystemPathComparer.cs; updated DotNetGenerator.cs and ExternalXmlDocResolver.cs to call ApiMark.Core.PathHelpers instead. - Moved the corresponding NormalizeCase regression tests from test/ApiMark.DotNet.Tests/FileSystemPathComparerTests.cs into test/ApiMark.Core.Tests/PathHelpersTests.cs. - Updated the PathHelpers design/verification/reqstream/sysml2 companion docs, the ExternalXmlDocResolver/DotNetGenerator design docs' references, and .reviewmark.yaml's ExternalXmlDocResolver review-scope entry (FileSystemPathComparer.cs/Tests.cs removed, already covered by the existing PathHelpers review-scope entry). No behavior change; this is a pure move/consolidation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: replace OS-guessed path case-sensitivity with on-disk normalization in ApiMark.Cpp CppEmitter and ClangAstParser each independently guessed file-system case sensitivity from the operating system (Linux => case-sensitive, else case-insensitive), which is incorrect since case sensitivity is a file-system property, not an OS property. Both now resolve actual on-disk casing via the shared ApiMark.Core.PathHelpers.NormalizeCase, matching the approach already used in ApiMark.DotNet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: resolve cross-assembly inheritdoc across multiple external hops without full reference-path scan Fixes two Copilot review findings on PR #43 that were missed pre-merge: - ExternalXmlDocResolver previously scanned every configured reference path in order on any miss. With MSBuild's auto-harvested ReferencePaths default (which can include hundreds of transitive dependencies), an unresolved or unsupported inheritdoc target incurred an O(reference-count) set of disk reads and XML parses. DotNetGenerator.BuildInheritanceChain now also computes a declaring-assembly hint for each inheritance candidate, and ExternalXmlDocResolver gains a new TryGetMember(memberId, hint) overload that tries the hinted reference path first, only falling back to the full scan when the hint is absent, unmatched, or stale. - Bare <inheritdoc/> resolution against an externally-resolved base member could not continue a second hop if that external member's own XML doc entry was itself a bare <inheritdoc/>, because the inheritance chain was built only from the primary assembly's Cecil metadata. BuildTypeInheritanceEntries is now recursive (guarded against revisiting a type) and walks into resolvable external base types/interfaces, adding their own members' inheritance entries to the chain so multi-hop external resolution works. Both fixes are confined to DotNetGenerator's private inheritance-chain construction and a purely additive ExternalXmlDocResolver overload; XmlDocReader's public constructors and chain type are unchanged, preserving binary compatibility. Adds fixture types (ExternalGrandBaseClass/ExternalMidBaseClass/ ExternalTwoHopInheritDocClass) and regression tests covering both the two-hop resolution and the hint-based fast path (including hint-miss fallback). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: only accept case-insensitive path fallback when confirmed on disk PathHelpers.FindActualEntryName previously accepted a single ambiguous case-insensitive directory-entry match unconditionally. On a case-sensitive file system, a non-existent spelling could still have one case-insensitive match (e.g. configured foo.dll when only Foo.dll exists), and this branch would treat that unrelated entry as the requested path -- for example ExternalXmlDocResolver could silently load documentation for an assembly the supplied path does not actually identify. The fallback is now only accepted after confirming, via File.Exists/ Directory.Exists on the literal (un-normalized) candidate path, that the current OS/file system itself resolves it -- which is only true on a genuinely case-insensitive file system. On a case-sensitive file system the check returns false and the segment is left unchanged, so a coincidentally similarly-named entry can never be mistaken for the requested path. Adds a regression test using a pre-populated fake directory-entry cache so the test result does not depend on the host OS's actual case sensitivity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: parse MSBuild tool arguments positionally instead of scanning for literal flag text PrepareArgumentsForProcess previously searched the entire logical argument list for the literal string '--reference-paths', even when that token was actually an unrelated option's VALUE. For example, an MSBuild ApiMarkLibraryDescription value of exactly '--reference-paths' caused this code to consume the following token as a reference-path value, writing a malformed response file and failing the tool invocation. BuildArguments always emits a well-known token sequence: a language subcommand followed by flag/value pairs, with a fixed set of zero-arg boolean flags (--include-obsolete). PrepareArgumentsForProcess now walks that sequence positionally (flag, then its value, skipping zero-arg flags) instead of scanning for flag text anywhere in the list, so a caller-supplied value that happens to collide with a flag's literal text is never misinterpreted as the flag itself. Adds a regression test with an ApiMarkLibraryDescription of exactly '--reference-paths' to prove it is treated as a plain value, not a flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: update stale XmlDocReader inheritdoc limitation comments The class-level remarks and ResolveInheritdocSource's remarks still described the pre-fix behavior as a 'known limitation' -- that a bare <inheritdoc/> could never continue a second hop after landing on an externally-resolved member. That is no longer accurate: DotNetGenerator's BuildTypeInheritanceEntries now recurses into resolvable external base types/interfaces and populates the chain it passes to XmlDocReader with those types' own members too, so a chain entry commonly exists for an externally-resolved member and multi-hop resolution works when the further base type is itself resolvable via Mono.Cecil. Reworded both remarks (and the XmlDocReaderTests regression test that pins down the isolated no-chain-entry case) to describe XmlDocReader's own chain-lookup behavior accurately, without claiming a limitation that the caller (DotNetGenerator) has already worked around for the common case. Found by the code-review sub-agent while reviewing this branch's diff before push. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: fix doc comment summary formatting in CppEmitter Reformats _includeRootDirectoryEntryCache's <summary> tag onto its own line, matching the codebase's established documentation convention. Found by the formal-review agent pass on this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "fix: only accept case-insensitive path fallback when confirmed on disk" This reverts commit 9716379. * docs: link PathHelpers case-normalization requirements to a tagged parent Three PathHelpers case-normalization requirements (NormalizeToActualOnDiskCasing, NormalizeCasePreservesUnresolvableSegments, NormalizeCaseResolvesAmbiguityDeterministically) existed in docs/reqstream/api-mark-core/path-helpers.yaml but were never referenced as children of any requirement tagged 'system' or 'quality', leaving them orphaned per reqstream's traceability check. This pre-existing gap (present since the PathHelpers requirements were first added) only surfaced now because CI's Build Documents job runs reqstream --enforce. Add a new ApiMarkCore-PathHelpers-ProvideCaseNormalizationHelper parent requirement (tags: [system]) in api-mark-core.yaml with the three orphaned requirements as children, alongside the existing ProvideSafePathCombinationHelper parent which only covers path combination (not case normalization). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address remaining PR review findings on PathHelpers and inheritdoc resolution - ExternalXmlDocResolver: precompute an assembly-simple-name -> path(s) lookup in the constructor so the declaring-assembly hint fast path is O(1) instead of an O(n) FirstOrDefault scan over every configured reference path on each lookup. - ExternalXmlDocResolver: key _memberCache by (hint, memberId) instead of memberId alone, so that in the rare case where two unrelated reference assemblies declare a member with an identical XML doc ID, a lookup for one hint can never be served the other hint's cached result. - DotNetGenerator.TryAddAssemblyHint: correct the doc comment's overclaim that first-wins hint assignment is unconditionally safe; XML doc member IDs do not carry assembly identity, so document the narrow, tolerated collision case honestly instead of asserting a false guarantee. - PathHelpers: correct the class-level remark claiming all members are thread-safe; NormalizeCase mutates a caller-supplied directoryEntryCache dictionary when one is passed, so callers sharing a cache across threads must synchronize their own access to it. - docs/design: update path-helpers.md, clang-ast-parser.md, and cpp-emitter.md, which still described the deleted FileSystemPathComparer/FileSystemPathComparison members and an inaccurate 'no fields or properties' claim, to describe the current ApiMark.Core.PathHelpers-based implementation. - docs/reqstream: correct the new PathHelpers case-normalization parent requirement's accessibility claim from 'internal' to 'public', matching PathHelpers/NormalizeCase/Comparer's actual public accessibility. - test: remove a redundant ToList() call in ApiMarkTask_Execute_ColonSeparatedReferencePathsPseudoFlagValue_IsTreatedAsPlainTokenNotPathList. Verified: full solution build clean; ApiMark.Core.Tests (125/125), ApiMark.DotNet.Tests (324/324 across net8/9/10), and ApiMark.MSBuild.Tests (42/42) all pass; fix.ps1/lint.ps1 clean; dotnet reqstream --lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document ExternalXmlDocResolver hint fast-path and composite cache key The hint-based TryGetMember overload and its O(1) per-assembly lookup were introduced without companion design/requirements/verification updates. Add design doc coverage for the hinted overload and the composite (Hint, MemberId) cache key, a new linked requirement for the hint fast-path behavior, and matching verification test-scenario write-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address second Copilot review batch (chain collisions, path ordering, perf caching, stale docs) - DotNetGenerator: split BuildInheritanceChain into an authoritative first pass (primary-assembly members) and a non-authoritative second pass (base/interface recursion via TryAdd), so external base-type entries can no longer overwrite a primary-assembly member's inheritance target. - DotNetGenerator: normalize reference directories via PathHelpers.NormalizeCase before the Directory.Exists filter, so a differently-cased configured directory can still be resolved on a case-sensitive file system. - ClangAstParser: precompute normalized public include roots once in the constructor and memoize IsOwned results per as-supplied source file, removing an O(declarations x roots) hot path; drop the now-unused _options field. - CppEmitter: precompute normalized public include roots once in the constructor and memoize GetIncludePath results per as-supplied source file. - Add ExternalXmlDocResolverTests case for hint-matches-path-but-member-missing fallback (previously untested). - Fix stale XmlDocReader remark referencing the renamed BuildTypeInheritanceEntries method. - Update design/reqstream/verification companion docs for api-mark-core, api-mark-cpp, api-mark-dot-net, and api-mark-msbuild to match the above and trace two previously-untraced tests (external two-hop inheritdoc resolution, MSBuild --reference-paths literal-value collision). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address 6-item review batch and formal-review medium finding - Fix stale wording overstating ExternalXmlDocResolver's hint-fast-path behavior in docs/comments (a hinted-path miss still falls back to a full scan of every configured reference path). - Fix stale 'Assembly resolver seeding' paragraph in dot-net-generator.md that described the old filter-then-normalize order instead of the fixed normalize-then-filter order. - Fix a real bug: CppGenerator.Parse now normalizes PublicIncludeRoots once upfront and threads a normalized-options clone through CollectHeaderFiles, ClangAstParser.Parse, and CppEmitter, so a differently-cased include root is resolved before Directory.Exists validation and before being passed to clang as a -I flag (previously only ClangAstParser's own constructor normalized roots, too late for header discovery and glob-pattern synthesis). - Add regression tests for both the DotNetGenerator ReferencePaths and CppGenerator PublicIncludeRoots case-normalization fixes, and soften their claims (per formal-review feedback) to accurately describe that they only distinguish fixed/unfixed behavior on a case-sensitive file system such as the CI matrix's ubuntu-latest runner. - Add verification/reqstream traceability for both new tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Consolidate C++ include-root normalization into PathHelpers.NormalizeCaseDirectory Fixes a root-corruption bug: ClangAstParser, CppEmitter, and CppGenerator each manually trimmed a path's trailing separator before normalizing and re-appending one, an unsafe composition for a bare filesystem root (e.g. 'C:\' or '/'). The trimmed form ('C:' or '') is not equivalent to the root - re-deriving a trailing separator by passing it through Path.GetFullPath or Directory.Exists a second time silently corrupts it (CppGenerator's CollectHeaderFiles resolved a trimmed drive root to the current working directory, and treated a trimmed Unix root as nonexistent). Adds PathHelpers.NormalizeCaseDirectory, which normalizes the untouched, fully-qualified path directly and appends a trailing separator only when one is not already present, avoiding the corruption entirely. All three Cpp callers now use it instead of duplicating the fix. Also addresses reqstream WHAT-vs-HOW wording findings in path-helpers.yaml, cpp-generator.yaml, dot-net-generator.yaml, and external-xml-doc-resolver.yaml, and adds a missing XML doc comment on PathHelpersTests.CreateTempDirectory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix ClangAstParser -I flags to use normalized include roots regardless of caller ClangAstParser.Parse normalized PublicIncludeRoots only for its internal ownership check, while BuildArguments still read the raw un-normalized options. This only worked in production because CppGenerator.Parse already normalizes options before calling ClangAstParser.Parse; any direct caller with a differently-cased root on a case-sensitive file system got broken -I arguments even though ownership checks used the corrected path. Parse now normalizes PublicIncludeRoots once at the top of the method and threads the same normalized options through both BuildArguments and the constructor. Extracted the shallow-copy helper previously duplicated in CppGenerator into a shared CppGeneratorOptions.WithPublicIncludeRoots so both callers stay in sync. Also corrects a stale design-doc paragraph describing the external XML doc resolver wiring as a direct method-group reference rather than the actual closure that also threads through AssemblyHints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix stale ClangAstParser/CppEmitter design doc paragraphs - clang-ast-parser.md: the directoryEntryCache passed into the constructor is shared with Parse's own cache, not a separate unshared instance; the design doc previously claimed the opposite. - cpp-emitter.md: the CppEmitter instance fields inventory omitted _includeRootDirectoryEntryCache, _normalizedPublicIncludeRoots, and _includePathCache, which back GetIncludePath's case-normalization and memoization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add PathHelpers to Dependencies inventory in Cpp design docs clang-ast-parser.md, cpp-emitter.md, and cpp-generator.md each gained a direct ApiMark.Core.PathHelpers dependency in the case-normalization work but the Dependencies sections were not updated to list it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Malcolm Nixon <Malcolm.Nixon@hiarc.inc> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Adds native cross-assembly
<inheritdoc/>resolution to ApiMark.DotNet, so<inheritdoc/>correctly resolves documentation from base types/interfaces defined in external assemblies (NuGet packages, the .NET BCL, etc.) — not just within the same assembly as before.This is conceptually inspired by SauceControl/InheritDoc (same core idea: resolve reference assemblies via
ReferencePath-style items, find sibling XML doc files withref/<->lib/fallback), but implemented natively with no new external dependency.What changed
ExternalXmlDocResolver(new) — locates and lazily parses external assemblies' sibling XML doc files, withref/<->lib/folder-swap fallback (some NuGet packages ship docs in only one of those folders) and per-member caching, including negative-cache (miss) results.XmlDocReader— gained an optional external-member-lookup fallback, used only when a member isn't found in the local assembly's own XML docs. Fully backward compatible.DotNetGenerator— seeds an assembly resolver from configured reference paths and wires the external resolver intoXmlDocReader.ReferencePathsoption, surfaced end-to-end:ApiMark.Tool:--reference-paths <path>CLI flag (repeatable)ApiMark.MSBuild:ApiMarkReferencePathstask property, auto-harvested from@(ReferencePath)when not explicitly set (never clobbers a user-supplied value)Verification
System.IDisposablefrom the .NET BCL reference-assembly XML docs shipped with the SDK, and against a real external NuGet-style package reference — confirmed<inheritdoc/>correctly pulls in the external documentation text.ExternalXmlDocResolverTests, extendedXmlDocReaderTests/DotNetGeneratorTests, new external fixture project, new MSBuild/CLI option tests, and newApiMark.MSBuild.PackageTestsintegration tests proving the.targetsauto-harvest behavior and its suppression when set explicitly.ApiMark.DotNet.Tests312/312 (×3 TFMs),ApiMark.MSBuild.Tests35/35,ApiMark.Tool.Tests99/99,ApiMark.Core.Tests118/118,ApiMark.MSBuild.PackageTests7/7 (excluding the pre-existing, environment-only clang-toolchain C++ test).dotnet reviewmark --lint/--plan --enforce,dotnet reqstream --lint/--enforce --tests, andpwsh ./lint.ps1(cspell, markdownlint, yamllint, sysml2tools,dotnet format --verify-no-changes) all clean.Review process
code-reviewagent on the full diff — found 1 Medium issue (stale thread-safety claim inXmlDocReader's doc comment now that an external lookup delegate can be wired in) — fixed.gpt-5.4-mini) across all 21.reviewmark.yamlreview-sets touched by this change. Initial sweep: 12 passed clean, 9 failed with Medium/High findings (mostly system/subsystem-level docs not yet updated to mention the new unit/option, plus a couple of missing edge-case requirements/tests). All feature-caused findings were fixed and independently re-verified; a few unrelated pre-existing gaps were confirmed as such and left untouched per scope discipline.Documentation
Design, reqstream (requirements), verification, SysML2 model, and user-guide docs updated for all affected units in
ApiMark.DotNet,ApiMark.MSBuild, andApiMark.Tool.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com