Skip to content

Release 4.0.47

Latest

Choose a tag to compare

@github-actions github-actions released this 03 Sep 11:44
· 11 commits to main since this release

CI: PR package versioning and publishing

  • PR builds now publish NuGet packages to GitHub Packages with a branch-identifying prerelease tag (e.g. 3.1.1-pr.42.fix-serialization), making it easy to test packages from in-progress work.
  • Master push builds continue to publish prerelease packages (3.1.1-prerelease) to GitHub Packages only.
  • Release builds continue to publish stable packages (e.g. 3.1.0) to NuGet.org via the manual release workflow.
  • Build and test steps now consistently use Release configuration.
  • Release workflow: GitHub Release is now created before the version bump commit, and the release commit uses deterministic timestamps for idempotent reruns.

Fix: IsInExtensions equality check and null guard

  • Bug fix: In and NotIn extension methods now correctly match default values (e.g. 0, null) by using EqualityComparer<TValue?>.Default.Equals instead of the previous value.Equals(v) approach which skipped default values.
  • Null guard: Added comparer.NotNull() validation for IComparer-based overloads to fail fast with ArgumentNullException instead of NullReferenceException.
  • Test coverage: Added comprehensive tests for enum values, IEnumerable overloads, IComparer overloads, and NotIn variants.

TestingSupport.FluentAssertions: NullEmptyCollectionEquivalencyStep, expanded README, and solution doc wiring

Ploch.TestingSupport.FluentAssertions

  • New equivalency step: NullEmptyCollectionEquivalencyStep (IEquivalencyStep) that treats a null collection as equivalent to an empty collection (and vice versa) during Should().BeEquivalentTo(...) comparisons. The step intentionally does not equate null strings with empty strings (strings implement IEnumerable, so an explicit guard prevents that footgun). Falls through to the rest of the pipeline for all other cases, preserving configured options like DateTimeOffset tolerance and cyclic-reference handling.
  • Use case: object graphs where one side leaves a collection property null and the other initialises it to an empty collection — common with EF Core navigation collections that were not eager-loaded.
  • Comprehensive XML documentation with <remarks> and <example>.
  • 14 unit tests covering: both null/empty directions, both-null, both-empty, populated-vs-different, populated-vs-equal, the explicit string guard, arrays, dictionaries, plus three direct Handle() unit tests.

Documentation

  • Expanded src/TestingSupport.FluentAssertions/README.md from a one-line stub to a proper reference with feature descriptions, usage examples, and the why behind each helper (NullEmptyCollectionEquivalencyStep, StringAssertionExtensions.ContainAllEquivalentOf, PropertyInfoCollectionExtensions.ContainProperty/ContainProperties).
  • Wired docs/ and docs/libraries/ (35+ per-library Markdown pages) into Ploch.Common.slnx as solution folders so the IDE surfaces them alongside source.

Tooling

  • Added a Copilot Cloud Agent PR pipeline: repo-investigator → pr-review-planner → plan-critic → pr-remediation, plus an optional pr-pipeline-orchestrator for one-entry use. Implemented as .github/agents/*.agent.md agent definitions plus docs/copilot-cloud-agent-pipeline.md design doc and docs/copilot-cloud-agent-mcp.example.json MCP config example.
  • Refreshed cross-tool agent instruction surfaces (.github/copilot-instructions.md, .cursorrules, .windsurf/rules/contextstream.md, CLAUDE.md, GEMINI.md, .claude/rules/todo-tasks-execution.md) to follow the same ContextStream + plan-mode + project-scope discipline.

Refs

  • #123 (umbrella documentation overhaul) — per-library README workstream for Ploch.TestingSupport.FluentAssertions.

Fix: Guard.NotOutOfRange now supports [Flags] enums

  • Bug fix: Guard.NotOutOfRange<TEnum> previously used Enum.IsDefined, which returns false for any combined [Flags] value that doesn't exactly match a single declared member (e.g. MyFlags.A | MyFlags.B). The method now detects [Flags] enums via FlagsAttribute and validates that every set bit belongs to a declared member by combining all defined values into a mask.
  • Behaviour clarification: Non-flags enums continue to require an exactly-defined value. Flags enums accept any value whose bits are a subset of the union of all declared members; values with bits outside that mask (e.g. (MyFlags)0x10000 when no member uses bit 16, or (MyFlags)(-1) when no member equals ~0) are still rejected with ArgumentOutOfRangeException.
  • Note on zero values: For [Flags] enums, a value of 0 (no bits set) is now accepted even when the enum does not declare an explicit None = 0 member. This matches the C# convention that "zero means no flags chosen". Non-flags enums without a 0 member still reject 0 as before.
  • Internal helpers: Added private IsFlagsEnum, HasOnlyDefinedFlagValues, GetEnumValuesMask, GetEnumValues (a netstandard2.0 polyfill for the Enum.GetValues<T>() generic overload introduced in .NET 5), and ToUInt64 covering all eight enum underlying types.
  • Test coverage: Re-enabled the previously skipped NotOutOfRange_should_work_with_flags_enum_combined_values test and added GuardFlagsEnumTests covering each underlying type (sbyte, byte, short, ushort, int, uint, long, ulong), undefined-bit rejection, and the negative-cast edge case.

Refs: #159

Test coverage for the netstandard2.0 build of Ploch.Common

Tests

  • Multi-targeted Ploch.Common.Tests to net10.0;net8.0. The net10.0 leg keeps consuming the net8.0 binary of Ploch.Common; the new net8.0 leg uses SetTargetFramework=netstandard2.0 on its ProjectReference to force consumption of the netstandard2.0 binary. As a result every test case that does not depend on a #if NET7_0_OR_GREATER member now runs against both compiled outputs of the package, exercising the previously-unreached #if NETSTANDARD2_0 and BitConverter.ToString fall-through code paths in Guard, PathGuard, and Hashing.
  • New PathGuardNetStandard2Tests (compiled on the net8.0 leg only) covers the netstandard2.0-only PathGuard.RequireValidPath extension method (which was renamed to RequiredIsValidPath on the net7+ partial), plus parallel coverage of EnsureFileExists and IsValidPath against the netstandard2.0 implementations.
  • New GuardNetStandard2Tests (compiled on the net8.0 leg only) asserts the netstandard2.0 partial's (memberName, message) parameter ordering for Guard.RequiredNotNull<T> and Guard.RequiredNotNullOrEmpty, parallel to GuardNet7Tests.cs which covers the net7+ (messageFormat, [CallerArgumentExpression] memberName) ordering.
  • Hashing parity is now implicit: the existing HashingTests asserts specific expected hash strings and runs on both legs, so the netstandard2.0 BitConverter.ToString(...).Replace("-", string.Empty) fall-through and the net6+ Convert.ToHexString(...) branch are both verified to produce identical output.
  • Scoping fixes in GuardTests: removed a duplicate RequiredNotNull test that exercised net7+-specific message-format ordering (already covered by GuardNet7Tests.cs) and relaxed the wildcard on NotNullOrEmpty_should_throw_for_empty_string so it matches both BCL message texts.
  • Removed the orphaned tests/central-mgmt-disabled/ scaffold (parked since the xunit v3 migration; superseded by the multi-target approach above).

Test infrastructure

  • Multi-targeted Ploch.TestingSupport.XUnit3 to net10.0;net8.0 and Ploch.TestingSupport.MockConsoleApp to net10.0;net8.0 so they can be referenced from the net8.0 leg of Ploch.Common.Tests.

Refs

  • #207 (test: cover the netstandard2.0 build of Ploch.Common)
  • Builds on #159 / #205 (the flag-enums Guard.NotOutOfRange fix that motivated discovering this gap).

Discoveries surfaced by the new coverage

The first runs on the netstandard2.0 binary uncovered two latent API inconsistencies between the two Guard partials. Both are documented as code comments in the test files for now and are candidates for follow-up issues:

  • Guard.RequiredNotNull<T> has its (memberName, message) parameter order inverted between the netstandard2.0 partial (memberName first) and the net7+ partial (messageFormat first, memberName from [CallerArgumentExpression]). A single positional argument therefore has different semantics depending on which binary is loaded.
  • Guard.NotNullOrEmpty(string?, …) raises a different message on each binary: net7+ delegates to ArgumentException.ThrowIfNullOrEmpty ("The value cannot be an empty string."), netstandard2.0 throws explicitly with "Argument cannot be null or empty.".

Inject coverlet.msbuild centrally for all test projects

CI

  • Updated Directory.Build.props so every test project (any project whose name ends with Tests) automatically gets a coverlet.msbuild PackageReference. Previously, individual test projects had to opt in by adding the reference manually, and 6 in-solution test projects were missing it — dotnet test /p:CollectCoverage=true was a silent no-op for them.
  • Added AggregatedCoverage/ to .gitignore so locally-generated merged coverage reports don't get staged.

Why

After PR #218 fixed the per-TFM glob, SonarCloud climbed from 6.77% to 67.07% — but local Rider DotCover still reported 86%. The remaining gap traced to six in-solution test projects that lack coverlet.msbuild:

  • tests/Common.Net9.Tests (covered Ploch.Common.Net9.AssemblyLoading.AppDomainTypesLoader — reported as 0% on Sonar despite having tests)
  • tests/Common.Serialization.Tests
  • tests/Common.Serialization.NewtonsoftJson.Tests
  • tests/Common.Serialization.NewtonsoftJson.ExtensionsDependencyInjection.Tests
  • tests/Common.Serialization.SystemTextJson.Tests
  • tests/Common.Serialization.SystemTextJson.ExtensionsDependencyInjection.Tests

Coverlet's MSBuild integration ships in the coverlet.msbuild package. Without that PackageReference, the /p:CollectCoverage=true flag is parsed harmlessly by MSBuild but no instrumentation runs. The CI workflow produced 10 OpenCover reports out of 14 in-solution test projects.

How

Existing Directory.Build.props already classifies projects via:

<IsTestProject>$(MSBuildProjectName.EndsWith('Tests'))</IsTestProject>

Used the same property to inject the package once for every test project:

<ItemGroup Condition="$(IsTestProject)">
    <PackageReference Include="coverlet.msbuild" PrivateAssets="all" />
</ItemGroup>

coverlet.msbuild is already pinned at 8.0.0 in mrploch-development/dependencies/Common.Packages.props, so Central Package Management handles the version. The 10 test/test-support projects that previously declared coverlet.msbuild manually have had those references removed to avoid NU1504 duplicate-PackageReference warnings; the central injection in Directory.Build.props is now the single declaration site. src/Common.WebUI.Tests/ already had its reference commented out and was left untouched.

Local verification

Ran the exact CI flags locally on Ploch.Common.slnx after applying the fix and aggregated the resulting OpenCover reports with dotnet-reportgenerator-globaltool:

Source Line Coverage
SonarCloud (before this fix) 67.07%
JetBrains Rider DotCover 86%
Coverlet aggregate (after this fix) 88.7%

AppDomainTypesLoader specifically jumped from 0% → 70.2%. The local aggregate landing slightly above Rider's number is expected: PR #209 multi-targets Common.Tests so Coverlet runs against the netstandard2.0 binary too, exercising #if NETSTANDARD2_0 branches that Rider does not. CI on master should land in the 85–89% range.

Refs

  • Closes the remaining part of #217 (SonarCloud reported coverage is too low). The Qodana badge problem is still pending — relates to #194 and will be addressed separately.
  • Builds on #218 (per-TFM glob fix) and #207 / #209 (multi-targeted Common.Tests).

Exclude build scripts and test-fixture assemblies from SonarCloud coverage

CI

  • Updated .github/workflows/build-dotnet.yml to add scripts/** and **/TestAssemblies/** to sonar.coverage.exclusions. The full pattern is now:
**/*.ps1,**/JetbrainsAnnotations.cs,**/*.Tests/**,**/*.Tests.csproj,scripts/**,**/TestAssemblies/**

Why

After PRs #218 and #219, SonarCloud coverage climbed from 6.77% to 72.3% composite (68.6% line). Local Rider DotCover and dotnet-reportgenerator-globaltool both reported ~86% line coverage — a remaining ~17-percentage-point gap.

Querying the SonarCloud API for per-folder coverage revealed the gap was driven by two folders being measured but not actually meaningful production code:

Folder Lines counted Uncovered Coverage
scripts/ 427 427 0.0%
tests/TestAssemblies/ 112 35 68.8%
src/ 1331 125 90.6%
  • scripts/ contains build/CI helpers — eight PowerShell files (caught by the existing **/*.ps1 pattern, correctly excluded) and generate-api-reference.py, a ~24 KB Python script that Sonar's Python analyzer instruments. With ~400 effective lines reported as uncoverable, this single file accounted for 427 / 587 = 73 % of the total uncovered-line count.
  • tests/TestAssemblies/ holds Reflection test fixtures (Common.Tests.TestAssembly1, Common.Tests.TestAssembly2, Common.Tests.TestTypes) used as instrumentation targets by Common.Tests. Despite living under tests/, the path does not match the existing **/*.Tests/** glob, so it leaked into the coverage measurement.

Neither set of files should drag the coverage badge down: build tooling is not consumer-visible, and test fixtures are intentionally not exhaustively tested.

Expected impact

With these exclusions in place, the next master analysis should report:

  • Covered lines: 1283 - 77 = 1206
    (the 77 covered lines come from tests/TestAssemblies/, which previously contributed 112 lines with 35 uncovered; scripts/ contributed 0 covered lines so it has no effect here)
  • Total lines: 1870 - 427 - 112 = 1331
  • Line coverage: 1206 / 1331 ≈ 90.6 %

That brings SonarCloud in line with the local dotnet-reportgenerator numbers (with-filter 88.7 %, no-filter 86.2 %) and the Rider DotCover figure (~86 %).

Refs

  • Closes the final part of #217 (SonarCloud reporting artificially low coverage). The Qodana badge issue noted in #217 is still pending — relates to #194 and will be addressed separately.
  • Builds on #218 (per-TFM glob fix) and #219 (central Coverlet injection + duplicate-reference cleanup).

Fix SonarCloud and Codacy coverage on multi-targeted test projects

CI

  • Updated .github/workflows/build-dotnet.yml so the SonarCloud and Codacy coverage globs pick up per-TFM OpenCover reports emitted by Coverlet when a test project is multi-targeted. Both globs changed from **/CoverageResults/coverage.opencover.xml to **/CoverageResults/coverage*.opencover.xml.

Why

After #207 / #209, Ploch.Common.Tests was multi-targeted to net10.0;net8.0 (so CI exercises both the net8.0 and netstandard2.0 builds of Ploch.Common), and Coverlet started emitting per-TFM filenames for that project:

  • tests/Common.Tests/CoverageResults/coverage.net10.0.opencover.xml
  • tests/Common.Tests/CoverageResults/coverage.net8.0.opencover.xml

The previous globs matched only the literal name coverage.opencover.xml, so neither file was ingested. SonarCloud therefore saw coverage from the small satellite test projects only and reported 6.77% instead of the local ~86%, which tripped the Quality Gate. Codacy was silently affected the same way.

Sonar and Codacy both ingest multiple OpenCover reports and merge them by source file path (set-union of covered lines), so reading both per-TFM reports correctly combines coverage of the #if NETSTANDARD2_0 and #if NET7_0_OR_GREATER branches — which is precisely what #209 set out to achieve.

Refs

  • Closes part of #217 (SonarCloud badges reporting wrong coverage). The Qodana badge problem mentioned in the same issue is tracked separately and relates to #194.
  • Builds on #207 / #209 (multi-targeted Ploch.Common.Tests).

CI: Pass NBGV version to SonarScanner so SonarCloud's "Previous version" new-code window works

CI

  • .github/workflows/build-dotnet.yml now captures NBGV's NuGetPackageVersion into a step output and forwards it to dotnet sonarscanner begin via the /v: parameter. Previously sonar.projectVersion was never supplied, which left SonarCloud with no version history to diff against. With the New Code definition set to "Previous version", that caused the entire baseline (including 432 legacy code smells dating back to 2020 and 10 legacy security hotspots) to register as "new code" on every analysis, permanently failing the quality gate.
  • With this change, each master commit produces a unique NuGetPackageVersion (e.g. 3.1.43-prerelease) via NBGV's commit-height counter, and each stable release jumps to its tagged version (e.g. 3.2.0). SonarCloud's "Previous version" comparison now diffs against the prior analysis, so the new-code window reflects what each PR or commit actually touched rather than the entire history.
  • The release workflow (release.yml) does not invoke SonarScanner, so no change is required there.

Follow-up

After this change ships, the existing 432 legacy smells and 10 hotspots will still appear in the new-code window on the first post-fix master analysis (because SonarCloud has no recorded prior version). A one-time admin step in SonarCloud will rebase the new-code window: switch New Code → Specific analysis to the first post-fix build, then switch back to Previous version. The 10 security hotspots will still need a manual triage in the Security Hotspots tab.

Refs

  • #222 (ci: pass NBGV version to SonarScanner)

Chore: Delete obsolete local SonarScanner helper scripts

Removed

  • scripts/run-sonar-build-test.ps1
  • scripts/build-test-sonar.ps1

Both scripts referenced Ploch.Common.sln, which has been replaced by Ploch.Common.slnx. Neither script could run as-is — dotnet restore failed before SonarScanner was even invoked. Both also lacked the /v: (sonar.projectVersion) argument that #222 added to the GitHub Actions workflow, which meant any local analysis they did manage to upload would have polluted SonarCloud's sonar.projectVersion history with empty values.

The decision (recorded on issue #224) is to delete rather than fix: the GitHub Actions workflow (.github/workflows/build-dotnet.yml) is the canonical SonarCloud entry point. Manual local runs against the production project are discouraged because they affect shared SonarCloud state (issue creation dates, new-code window, quality gate history).

Solution file updates

Removed the corresponding <File Path="run-sonar-build-test.ps1" /> solution-items entries from:

  • Ploch.Common.slnx
  • Ploch.Common.Endpoints.slnx
  • Ploch.Common.LocalDev.slnx
  • Ploch.Common.WebApi.Endpoints.slnx

Refs

  • #224 (chore: delete obsolete sonar helper scripts)
  • Follow-up to #222 / #223 (CI SonarScanner version fix)

Disable IDE0058 ("Expression value is never used") globally

Code quality

  • Updated .editorconfig to set dotnet_diagnostic.IDE0058.severity = none. The rule was producing 137+ false positives on SonarCloud and SonarLint, primarily on intentional discards of ArgumentChecking extension-method results.

Why

ArgumentChecking extension methods such as Guard.NotNull, PathGuard.RequireValidPath, Guard.NotNullOrEmpty, and Guard.NotOutOfRange return their input argument so callers can fluent-chain them when convenient, but their primary purpose is the validation side-effect — throwing on bad input. Callers idiomatically write arg.NotNull(nameof(arg)); and discard the result. Roslyn's IDE0058 (surfaced by SonarCloud as external_roslyn:IDE0058) flags every such call as "Expression value is never used", producing many false positives.

The rule also fires on numerous other intentional fluent-API discards in the codebase:

  • StringBuilder.Append(...).Append(...) chains where the final result isn't reassigned
  • ICollection<T>.Add(...) (returns bool indicating "was added")
  • LINQ-for-side-effect helpers (ForEach over results of Where)
  • Reflection helpers and TestingSupport setup methods that mutate state and return this

Why per-method suppression isn't possible

IDE0058 fires on the caller, not on the method being called. C# has no built-in attribute that lets a method declare "discarding my return value is allowed" ([Pure] does the opposite — it tells callers they must use the result). Suppression would therefore require either:

  • _ = method(); discards at every call site (130+ sites; impractical to apply retroactively, and noisy on every new call going forward), or
  • Per-file [SuppressMessage] / #pragma warning disable IDE0058 (same problem at scale).

Trade-off

Other analysers cover most of the cases IDE0058 does, but on a narrower scope:

  • csharpsquid:S2201 ("Return values from functions without side effects should not be ignored") — Sonar's nearest equivalent. Correctly recognises that side-effecting methods like Guard.NotNull are fine to call without using the return value, which is why it does not fire on the false-positive cases.
  • CA1806 ("Do not ignore method results") — fires only on a specific list of known pure / no-side-effect APIs (string methods, Immutable* types, etc.).

The genuine residual risk is lazy-LINQ chains: something like myList.Where(x => x.Active) with no .ToList() / no assignment is a real bug that IDE0058 would catch but S2201 and CA1806 will not. We accept this trade-off because:

  1. The rule produced 137+ false positives on master at the time of this change, almost all on intentional fluent / side-effect discards. Signal-to-noise is too low to be useful.
  2. The wider Sonar issue list — surfaced after the coverage-instrumentation fixes (#218 / #219 / #220) — is what motivated this; the rule was actively obscuring the genuine code smells in the project.
  3. The existing IDE preference csharp_style_unused_value_expression_statement_preference = discard_variable:suggestion (line 163 of .editorconfig) is left in place, so _ = method() remains the documented refactoring suggestion when anyone wants it. The hint just no longer fails / clutters Sonar.

Verification

  • Local dotnet build ./Ploch.Common.slnx -c Release after the change: zero IDE0058 occurrences, zero errors.
  • The next master analysis after merge should show external_roslyn:IDE0058 drop out of the Sonar issue list entirely.

CI: Move Qodana to the stable 2026.1 Community tag, deduplicate workflows, and re-enable auto-triggers

CI

  • qodana.yaml — linter pin moves from jetbrains/qodana-cdnet:2026.1-eap to the new stable jetbrains/qodana-cdnet:2026.1 tag that JetBrains published as a Docker Community release. The cdnet image had historically shipped EAP-only, which forced #194 to disable auto-triggers because each pinned EAP tag expired within 4-7 weeks and broke CI on a rolling cycle. The stable tag ends the expiry treadmill.
  • .github/workflows/qodana_code_quality.yml — action bumped from JetBrains/qodana-action@v2025.3.2 to @v2026.1.0 (Node.js v24, ends the deprecated-Node warning surfaced in recent runs), and auto-triggers re-enabled for pull_request and push to master and releases/*. The --baseline,qodana.sarif.json,--solution,Ploch.Common.slnx argument set and the mrploch-development shared-config clone are unchanged.
  • .github/workflows/code_quality.yml — deleted. This was the duplicate workflow flagged in #198. Its config was incomplete (no --solution arg, no mrploch-development clone) so every manual dispatch failed with solution/project relative file path is not specified. The remaining qodana_code_quality.yml already handled workflow_dispatch plus PR/push triggers, so nothing is lost.
  • README.md — Qodana badge URL repointed from code_quality.yml (deleted) to qodana_code_quality.yml. The badge will go green on the first successful auto-triggered run.

Issues closed

  • #194 — Qodana: structural fix for recurring EAP licence expiry. Resolved by the stable cdnet tag.
  • #198 — ci(qodana): deduplicate Qodana workflows. Resolved by deleting code_quality.yml.

Refs

  • Closes #194, #198
  • Builds on #193 (the EAP stopgap that preceded this structural fix)

Remove redundant explicit <DocumentationFile> properties and gate PrintSettings target

Build configuration

  • Removed <DocumentationFile>$(OutputPath)$(AssemblyName).xml</DocumentationFile> from every library project. The central <GenerateDocumentationFile>true</GenerateDocumentationFile> in Directory.Build.props (already in effect for all non-test projects) is sufficient on the modern .NET SDK — the SDK auto-generates the XML doc file next to the assembly and packs it into the .nupkg. The explicit per-project line was a pre-SDK-style holdover.
  • Affected projects: Ploch.Common, Ploch.Common.AppServices, Ploch.Common.DawnGuard, Ploch.Common.DependencyInjection, Ploch.Common.Serialization, Ploch.Common.Serialiation.NewtonsoftJson, Ploch.Common.Serialization.NewtonsoftJson.ExtensionsDependencyInjection, Ploch.Common.Serialization.SystemTextJson, Ploch.Common.Serialization.SystemTextJson.ExtensionsDependencyInjection, Ploch.Common.WebUI, Ploch.TestingSupport, Ploch.TestingSupport.FluentAssertions.
  • Ploch.Common.Serialization additionally had configuration-specific <DocumentationFile>bin\Debug\…xml</DocumentationFile> and <DocumentationFile>bin\Release\…xml</DocumentationFile> overrides; the entire Debug/Release PropertyGroups collapsed to nothing once the property was removed and were deleted.

Test-fixture assemblies

  • Ploch.Common.Tests.TestAssembly1 and Ploch.Common.Tests.TestAssembly2 are not auto-detected as test projects by the MSBuildProjectName.EndsWith('Tests') rule (their names end in TestAssembly1/2). They previously used <DocumentationFile /> (empty) inside Debug/Release PropertyGroups as a workaround to suppress the centrally-enabled XML doc generation. Replaced with a single explicit <GenerateDocumentationFile>false</GenerateDocumentationFile> to mirror the test-project convention — these are fixture assemblies used only for reflection-based tests and never published.

Build log noise

  • Gated the PrintSettings debug <Target> behind a PrintBuildSettings opt-in property. Previously it emitted ~10 Importance="high" <Message> lines on every build of every project, padding CI logs with ~270 lines per full build (27 projects × 10 messages). Now off by default; enable for a targeted diagnostic build with -p:PrintBuildSettings=true.

Documentation

  • Updated docs/libraries/common-msbuild.md § "Build Diagnostics Target" to describe the new opt-in behaviour and the enable command.

Why

Surfaced while reviewing #117 (Feb 2024 stale PR that tried to change <DocumentationFile> to use $(BaseOutputPath) — that path drops the TFM subfolder, so multi-targeted projects would have collided both inner builds' XML doc onto one path). #117 was closed; this issue tracks the correct cleanup: remove the redundant property entirely rather than tweaking its value.

The PrintSettings target was useful when diagnosing the unexpected build behaviour that motivated the central GenerateDocumentationFile=true switch (see issue #117 history), but on a clean configuration it adds only log noise. Gating it behind PrintBuildSettings preserves the diagnostic capability for on-demand use without polluting every CI build.

Follow-ups picked up during review

  • Orphaned <None Remove="*.xml" /> directives. The original cleanup left companion <None Remove="Ploch.<Name>.xml" /> items in six library .csproj files (Ploch.Common, Ploch.Common.AppServices, Ploch.Common.AppServices.Web, Ploch.Common.Serialization.SystemTextJson, Ploch.Common.Serialization.SystemTextJson.ExtensionsDependencyInjection, Ploch.Common.Serialization.NewtonsoftJson.ExtensionsDependencyInjection). The SDK writes the XML doc to bin/<tfm>/ (not the project source root), so the default <None> glob never picked them up and the Remove directives have been no-ops since the SDK switch. Flagged by Bito review; swept in a follow-up commit on this PR.
  • Design-time build guard on PrintSettings. Gemini Code Assist suggested extending the opt-in condition with And '$(DesignTimeBuild)' != 'true' so the diagnostic dump never fires during IDE IntelliSense / restore passes even if PrintBuildSettings=true is set globally (env var / system Directory.Build.props override). Applied.

Out of scope

  • DocFX-driven API site (DocumentationSite/docfx.json) is unaffected — it extracts metadata from *.csproj source via Roslyn, not from bin/*.xml. The published site at https://common.github.ploch.dev/ continues to work.
  • templates/Directory.Build.props is a frozen legacy template (uses the pre-NBGV VersionPrefix/BuildNumber pattern) and was not modified.

Verification

  • dotnet build Ploch.Common.slnx -c Release — zero new warnings, zero errors. CI logs no longer contain per-project PrintSettings dumps.
  • Each library .nupkg continues to ship lib/<tfm>/<Assembly>.xml per target framework (the SDK auto-derives the path from GenerateDocumentationFile=true).

Docs: Add rel="noopener noreferrer" to Buy Me a Coffee anchor

Changed

  • README.md — the Buy Me a Coffee anchor in the ## Support the Project section now sets rel="noopener noreferrer" alongside target="_blank".

Why

Anchors with target="_blank" should set rel="noopener noreferrer" to defend against reverse tabnabbing: without noopener, the opened page can access window.opener in the original tab and silently navigate it (e.g. to a phishing destination). Modern browsers default to noopener for target="_blank", but the explicit attribute survives renderer differences across GitHub, NuGet, and other README renderers, and matches the safe-link pattern documented by MDN and OWASP.

Caught by gemini-code-assist while reviewing the equivalent change in ploch-data (mrploch/ploch-data#86 → mrploch/ploch-data#89). Brings the ploch-common README in line with ploch-data.

Impact

Documentation-only. No code change, no behaviour change, no breaking change.

Refs

Fix: Align Guard argument-checking semantics across netstandard2.0 and net7.0+ targets

Ploch.Common multi-targets netstandard2.0;net8.0, and Guard is a partial class whose body is selected by target framework (Guard.cs for netstandard2.0, GuardNet7.cs for net7.0+). Two divergences between the two partials meant that the same call site behaved differently depending on which asset NuGet resolved for the consumer's TFM. Both are now aligned on the net7.0+ behaviour.

Breaking Changes

  • Guard.RequiredNotNull<T> (reference-type and value-type overloads) and Guard.RequiredNotNullOrEmpty — netstandard2.0 parameter order changed (issue #210). The netstandard2.0 partial previously declared (this T? argument, string? memberName, string? message = null) — the inverse of the net7.0+ partial's (this T? argument, string? messageFormat = null, string? memberName = null). The two are now identical: the first optional string is always messageFormat, the second is always memberName.
    • A netstandard2.0 consumer calling value.RequiredNotNull("argName") previously treated "argName" as the member name; it is now treated as a composite format string, matching net7.0+. Pass the name explicitly via value.RequiredNotNull(memberName: "argName") to preserve the old intent.
    • This is a binary-breaking change to the netstandard2.0 asset (the IL signatures of the affected methods change), in addition to the source/semantic change. It warrants a major version bump at release time.

Fixes

  • Guard.NotNullOrEmpty(string?) empty-string message aligned with the BCL (issue #211). The netstandard2.0 partial threw ArgumentException("Argument cannot be null or empty.") for an empty string, whereas the net7.0+ partial delegates to ArgumentException.ThrowIfNullOrEmpty, producing "The value cannot be an empty string.". The netstandard2.0 partial now raises the same "The value cannot be an empty string." text. The enumerable NotNullOrEmpty<TEnumerable> overload is unchanged (it already produced consistent text across both targets).
  • Default exception messages unified. The netstandard2.0 RequiredNotNull/RequiredNotNullOrEmpty now use the shared "Variable {0} cannot be null." / "Variable {0} cannot be empty." formats (and drop the netstandard2.0-only "both memberName and message cannot be null" guard), matching net7.0+.

Versioning

Because the parameter-order change is binary-breaking, version.json has been bumped from 3.1-prerelease to 4.0-prerelease: the next release is 4.0.0, not 3.1. The previously planned 3.1 scope rolls into 4.0.

Known limitation (documented)

The netstandard2.0 target cannot auto-capture memberName via CallerArgumentExpression (that would require raising the language version), so on netstandard2.0 the name must be supplied explicitly; when omitted, the {0} placeholder is empty ("Variable cannot be null."). The net7.0+ target auto-captures it. This residual difference is documented in the affected methods' XML <remarks>.

Refs

  • #210 (Guard.RequiredNotNull parameter order inverted between partials)
  • #211 (Guard.NotNullOrEmpty empty-string message divergence)

Test projects: clean up pre-existing analyzer warnings

Removed the entire backlog of pre-existing analyzer warnings across the test
projects so that future regressions stand out in the build output. The
test-project warning count went from ~814 to 0; the build is now clean for
every test assembly.

This is a test-only change — no shipping-library (src/) code was modified,
so there is no consumer-visible behaviour change. The equivalent cleanup for the
shipping libraries is tracked separately in #242.

Approach (per the issue's "balanced" policy)

  • Real in-code fixes were preferred over suppressions. Examples: genuine bugs
    surfaced by the analysers were fixed — a malformed #pragma (CS1696), a
    FluentAssertions argument-count mismatch (CA2241), a reference-vs-value
    comparison that should have been a string cast (CS0252), a redundant
    == true (S1125), and a duplicate test method (S4144). Other fixes were
    mechanical: nameof(...) over string literals, var usage, string.Empty
    over "", member-group conversions, ordering, comment spacing, dead-code and
    redundant-local removal, and consistent collection-initialiser indentation.
  • Analyzer config scoping (in tests/.editorconfig, with justifying
    comments) was used only for rules that are inherently inapplicable to
    non-shipping test assemblies — e.g. XML-doc rules, argument-validation rules,
    async-suffix/threading rules, and a small set of rules that fire as false
    positives on deliberately-shaped reflection/serialization test fixtures (whose
    "smells" are the subject under test, not accidental misuse). The scoping lives
    in the nested tests/.editorconfig so it never affects src/ analysis.

Refs

Refactor: Resolve analyzer warnings in shipping libraries (src/)

Cleaned up the analyzer-warning backlog across the shipping src/ libraries (split out from the test-project cleanup in #233). The bulk are documentation, style, and code-quality fixes with no behavioural impact, but a few are user-visible:

  • New public API — EnumName.FromString(string?) (Ploch.Common.TypeConversion): a named alternative to the implicit string→EnumName conversion operator (satisfies CA2225). Non-breaking addition.
  • ProcessExtensions (Ploch.Common.Diagnostics): SetSingleProcessorAffinity, SetEnabledProcessors, and GetEnabledProcessors are now annotated [SupportedOSPlatform("windows")] / [SupportedOSPlatform("linux")] (on net8.0), so consumers calling them from code reachable on unsupported platforms now get a platform-compatibility analyzer warning (CA1416). The methods also now validate their process argument (ArgumentNullException) and wrap the affinity-mask-to-IntPtr conversion in unchecked (with a justified CA2020 suppression, because the bitmask's high bit must wrap to the native pointer pattern rather than throw OverflowException).
  • Hashing (Ploch.Common.Cryptography): ToHashString/ToMD5HashString now guard their arguments (ArgumentNullException); the legacy BitConverter-based fallback was moved into a #else branch so it only compiles for pre-net6 targets (removing dead-code and culture-sensitivity warnings on current TFMs). Output is unchanged.
  • StopwatchUtil.TimeAsync: now awaits with ConfigureAwait(false) (CA2007); no behavioural change for callers.

Internal-only changes: added argument null-guards to several public methods (CA1062), XML documentation for previously undocumented public members (CS1591 et al.), and a number of style/formatting fixes. Where a rule genuinely did not apply, a narrowly-scoped, justified suppression was used instead of a fix — notably CA5394 on the non-security Randomizers and collection-shuffle helpers, CA1032 on the property-access exception hierarchy (which is constructed from a property name, making the standard message-only constructor ambiguous), and CA1034 on the DateTimeFormats.DateOnly format-constant grouping.

Breaking changes

A few public types were sealed or made static to satisfy analyzer recommendations. These are source- and binary-breaking for consumers that subclassed (or, for the static conversions, instantiated) them, and are shipped as part of the 4.0 major release:

  • Ploch.TestingSupport.Moq.FluentVerifier and Ploch.TestingSupport.XUnit3.Moq.FluentVerifier — changed from public class to public static class. All members were already static; the implicit public parameterless constructor is removed, so new FluentVerifier() and any subclass no longer compile.
  • Ploch.Common.DataAnnotations.RequiredNotDefaultDateAttribute — now sealed.
  • Ploch.TestingSupport.XUnit3.AutoMoq.AutoMockDataAttribute — now sealed.
  • Ploch.TestingSupport.TestData.TextFileLinesDataAttribute and Ploch.TestingSupport.XUnit3.TestData.TextFileLinesDataAttribute — now sealed.

Consumers that derived from any of these types must stop doing so; the types were not designed for extension.

Refs: #242

Fix: TakeRandom never selected the last element (off-by-one)

  • Bug fix in Ploch.Common.Collections.EnumerableExtensions.TakeRandom<T>: the random index was drawn with Random.Next(0, indexes.Count - 1), whose upper bound is exclusive. Because index removal preserves order, the source's last element could never be selected unless the requested count equalled the source size — skewing the selection distribution.
  • The upper bound is now indexes.Count, making every element reachable with uniform probability.
  • Added regression tests asserting that every element (including the last) can be selected, and that a single-element source returns its only element.

Refs: #246

Fix: Harden JoinWithFinalSeparator and SelectListHelper edge cases

  • Bug fix in Ploch.Common.Collections.EnumerableExtensions.JoinWithFinalSeparator: an empty source threw IndexOutOfRangeException and a single-element source incorrectly prepended the final separator (e.g. " and x"). An empty source now returns string.Empty and a single-element source returns just that element, matching string.Join boundary semantics.
  • Bug fix in Ploch.Common.WebUI.TagUtilities.SelectListHelper.CreateFor: a textFunc/valueFunc delegate returning null for an item caused a NullReferenceException. Null delegate results now map to string.Empty for the item's Text/Value.
  • Added regression tests for the empty, single-element, and two-element JoinWithFinalSeparator boundaries and for null SelectListItem text/value delegate results.

Refs: #248

fix(common): ProcessExtensions affinity mask respects the native mask width

Issue: #249

Summary

SetSingleProcessorAffinity, SetEnabledProcessors and GetEnabledProcessors in
Ploch.Common.Diagnostics.ProcessExtensions validated processor numbers only against
Environment.ProcessorCount, ignoring the width of the native Process.ProcessorAffinity
bitmask (IntPtr.Size * 8). On machines with more than 64 logical processors the
1L << n shift wrapped (n & 63), setting the wrong bit; in a 32-bit process, bits
32–63 were silently truncated and GetEnabledProcessors mis-reported.

Changes

  • Processor numbers are now validated against the lesser of Environment.ProcessorCount
    and the native affinity-mask width. Unaddressable processor numbers throw
    ArgumentOutOfRangeException with a message stating both limits.
  • GetEnabledProcessors bounds its read loop the same way, so it never reports
    processors that cannot be represented in the mask.

Behaviour change (not breaking for supported configurations)

Previously, on hardware/process combinations where a processor number passed the
ProcessorCount check but exceeded the mask width, the methods silently applied a
wrong affinity mask. These calls now throw ArgumentOutOfRangeException. On machines
with ≤64 logical processors running 64-bit processes (the overwhelmingly common case)
behaviour is unchanged.

ProcessExtensions: validate processor affinity against the native mask width only (#257)

Summary

SetSingleProcessorAffinity, SetEnabledProcessors and GetEnabledProcessors in
Ploch.Common.Diagnostics.ProcessExtensions no longer use Environment.ProcessorCount as an upper bound for
processor numbers. Since .NET 6, Environment.ProcessorCount is the number of processors available to the
process
(it already reflects the process's own affinity and CPU limits) — it is a count, not an index bound, so
using it rejected valid processor numbers and under-reported enabled processors for processes constrained to
non-contiguous processor sets (for example CPUs 8–15, where ProcessorCount is 8).

Changes

  • Processor numbers are now validated only against the native affinity-mask width (IntPtr.Size * 8 — 32 in a
    32-bit process, 64 in a 64-bit process). Processor numbers for CPUs that do not exist on the machine are
    rejected by the operating system when the affinity is applied (typically as Win32Exception).
  • GetEnabledProcessors reports every bit set in the native mask (bounded by the mask width alone), so
    non-contiguous affinity sets are reported correctly.
  • SetEnabledProcessors verifies the applied mask after setting it and throws InvalidOperationException if the
    operating system did not enable every requested processor. Linux applies the intersection of the requested mask
    and the processors available to the process (sched_setaffinity(2)) rather than rejecting unavailable ones, so
    a mixed request would otherwise succeed only partially and silently.
  • XML documentation updated on all three methods to describe the new contract.

Behaviour change (breaking)

Calls with a processor number that is within the native mask width but greater than or equal to
Environment.ProcessorCount previously threw ArgumentOutOfRangeException up front. They are now passed to the
operating system: valid targets (processors the machine has, e.g. under a constrained affinity) succeed, and
nonexistent processors surface as an OS-level error (typically Win32Exception) when the affinity is applied.
Code that relied on the eager ArgumentOutOfRangeException for such inputs must be updated.

Ploch.Common.WebUI now ships as a NuGet package

Issue: #250

New

  • Ploch.Common.WebUI is now published as a NuGet package. The project always had packaging
    intent (a package README), but Microsoft.NET.Sdk.Web defaults IsPackable to false,
    which prevented the package from being produced (surfacing only as a pack warning on every
    build). IsPackable is now explicitly enabled, so the package (containing AppPage,
    SelectListHelper, and related Razor/MVC page utilities) ships alongside the other
    Ploch.* packages from this release onwards.

Fixed

  • The NuGet.Build.Tasks.Pack warning ("This project cannot be packaged because packaging
    has been disabled") emitted on every build of the solution is resolved.

TakeRandom optimised with a partial Fisher-Yates shuffle

Issue: #252

Changed

  • EnumerableExtensions.TakeRandom<T> now selects items with a partial Fisher-Yates shuffle
    performed directly on a private copy of the source list instead of repeated
    List<T>.RemoveAt calls, improving complexity from O(count × n) to O(n) setup +
    O(count) selection. A full-size draw returns the shuffled copy without an extra
    prefix copy. Uniform selection probability and no-duplicate-picks behaviour are
    unchanged.
  • Behaviour for a zero or negative count is now explicitly documented: an empty sequence is
    returned, matching Enumerable.Take semantics (deliberate decision — no breaking change).

Newtonsoft.Json serializer package renamed to fix a long-standing typo

Issue: #263

Breaking Changes

  • The Newtonsoft.Json serialization package, assembly, and namespace are renamed from the
    misspelled Ploch.Common.Serialiation.NewtonsoftJson ("Serialiation", missing the z)
    to the correct Ploch.Common.Serialization.NewtonsoftJson. The typo originated in the
    project file name and flowed into the package ID published on NuGet.org, the assembly name,
    and the public namespace of NewtonsoftJsonObjectSerializer.

    Migration:

    1. Replace the PackageReference to Ploch.Common.Serialiation.NewtonsoftJson with
      Ploch.Common.Serialization.NewtonsoftJson.
    2. Update using Ploch.Common.Serialiation.NewtonsoftJson; directives to
      using Ploch.Common.Serialization.NewtonsoftJson;.

    The old package ID remains on NuGet.org for existing consumers but receives no further
    updates and will be marked deprecated with a pointer to the new ID.

chore(webui): Switch Ploch.Common.WebUI to the plain Microsoft.NET.Sdk

  • Issue: #264
  • Type: Internal build change — no consumer-facing impact

Summary

Ploch.Common.WebUI now uses Microsoft.NET.Sdk with an explicit
<FrameworkReference Include="Microsoft.AspNetCore.App" /> instead of
Microsoft.NET.Sdk.Web. The project is a class library with no Razor files, so per
Microsoft's guidance
the plain SDK (not Microsoft.NET.Sdk.Razor, which is only for libraries containing
Razor files) is the correct choice. The now-redundant IsPackable and OutputType
overrides and the runnable-app launchSettings.json were removed.

Consumer impact

None. The produced package was diffed against a pre-change baseline: contents and
nuspec (dependencies, Microsoft.AspNetCore.App framework reference, packed README)
are identical.

chore(serialization): Suppress NU5123 long-path pack warnings for the ExtensionsDependencyInjection packages

  • Issue: #270
  • Type: Internal build change — no consumer-facing impact

Summary

NuGet pack emitted NU5123 ("path, name, or both are too long") for the
lib/netstandard2.0 .dll and .xml of
Ploch.Common.Serialization.SystemTextJson.ExtensionsDependencyInjection and
Ploch.Common.Serialization.NewtonsoftJson.ExtensionsDependencyInjection. The warning
is now suppressed via a targeted, PR-build-only <NoWarn> in those two projects
(Condition="'$(GITHUB_EVENT_NAME)' == 'pull_request'"), with the rationale documented
in each project file. Master, release, and local packs keep NU5123 live, so a genuine
overrun of shipped artefacts would still surface as a warning.

Analysis

NU5123 fires when the installed path <package_id>/<version>/<file_path_in_package>
reaches 200 characters. Both package ids are 71 characters and the dll/xml file names
are 75, giving:

Version shape Version length Installed path NU5123?
Stable release (4.0.0) 5 172 No
Master prerelease (4.0.16-prerelease.gXXXXXXXXXX) 29 196 No
PR build (4.0.17-pr.268.chore-264-webui-plai.gXXXXXXXXXX) 46 213 Yes

Only PR-validation builds cross the threshold, because Nerdbank.GitVersioning embeds
the branch name in the version for non-public-release refs. Those packages are
ephemeral test artefacts published to GitHub Packages only — the packages shipped to
consumers (master prereleases and stable releases) never exceed the limit. Renaming
the packages to shorten paths would be a breaking change (as with the #263 rename)
and is disproportionate to a warning that never affects shipped artefacts.

Consumer impact

None. Package ids, file names, and contents are unchanged; only the pack-time warning
for internal PR builds is suppressed.

MockConsoleApp: solution membership, redirected-stdin crash, and a consumable package

Fixed

  • Ploch.TestingSupport.MockConsoleApp is now a member of Ploch.Common.slnx. It never had been, on any revision of that file, despite Ploch.Common.Tests carrying a ProjectReference to it. dotnet build was unaffected because MSBuild traverses ProjectReference edges transitively, so CI stayed green; Rider and Visual Studio build only solution members and reported CS0006: Metadata file '…\obj\Debug\{net10.0,net8.0}\ref\Ploch.TestingSupport.MockConsoleApp.dll' could not be found. Six other solutions across the workspace already listed the project — only its own home solution omitted it.

  • The stub no longer crashes when standard input is redirected. Program.cs ended in Console.ReadKey(), which throws InvalidOperationException under redirected stdin — the normal condition when a test harness launches a child process, and precisely what the documented usage example did. The app now branches on Console.IsInputRedirected, waiting on Console.ReadLine() when redirected and Console.ReadKey() when attached to a real console. Previously such a launch died with exit code -532462766 and an unhandled exception on stderr.

Changed

  • The NuGet package is now actually consumable. Build output is packed to tools/<tfm>/ instead of lib/, so a PackageReference yields a launchable binary rather than a compile-time reference to an Exe. The package carries build/Ploch.TestingSupport.MockConsoleApp.targets, imported automatically, which stages the stub into the consumer's output directory at MockConsoleApp/. Consumers on net10.0 or later receive the net10.0 asset and everything else the net8.0 asset — a preference that picks the closest packaged asset so the host need not roll forward at all, with the majors that have no packaged asset covered by RollForward=Major below.

  • RollForward=Major. The default roll-forward policy is Minor, which never crosses a major runtime version, so the net8.0 asset would have refused to start on a machine with only the .NET 9 runtime (You must install or update .NET to run this application) and the net10.0 asset would hit the same wall on a future .NET 11-only machine. Per-TFM selection still prefers an exact major where one is packaged; this covers the majors that are not. Safe here because the application has no dependencies and uses only Console members.

  • UseAppHost=false. A single package now behaves identically on Windows, Linux and macOS; the stub is launched through the dotnet muxer rather than as a platform-specific .exe. Previously the packed apphost would have been baked for whichever OS ran the build.

  • Ploch.Common.Tests stages the stub to the same MockConsoleApp/ location via a StageMockConsoleApp target, so in-repo ProjectReference consumers and external PackageReference consumers resolve it identically. Its reference is now ReferenceOutputAssembly="false" — the stub is launched, never compiled against.

  • ProcessExtensionsTests no longer hardcodes a build path. It previously reached into ../../../../../src/TestingSupport.MockConsoleApp/bin/Debug/net10.0/Ploch.TestingSupport.MockConsoleApp.exe, which pinned the Debug configuration, the net10.0 leg and the .exe extension. It now resolves the staged stub from AppContext.BaseDirectory and holds the process open through a live stdin pipe rather than relying on ReadKey blocking.

  • A package README is included, resolving the "missing a readme" pack warning.

Documentation

  • Rewrote docs/libraries/testing-support-mock-console-app.md. The previous version documented a tools directory that the package did not produce, an installation route that could not work (the package is not on NuGet.org), and a net10.0-only target that had been net10.0;net8.0 since #209. Its second usage example redirected standard input and would have crashed the stub.

Notes for maintainers

Two packaging approaches were rejected. PackAsTool produces a DotnetTool-typed package installed with dotnet tool install and invoked by command name, which contradicts the documented dotnet add package workflow, and on .NET 10 it fans out into RID-specific packages with strict publish ordering. The IsTool property flattens every target framework into a single tools/ folder, so on this multi-targeted project the net8.0 output collided with net10.0 and was silently dropped (NU5118). The per-TFM layout is produced by a TargetsForTfmSpecificContentInPackage hook instead.

Refs

  • #275 (MockConsoleApp: missing from solution, crashes on redirected stdin, ships an unusable package)
  • Related to #266 (secondary solution files referencing removed projects — the inverse problem)
  • Builds on #207 / #209 (the multi-targeting that this packaging has to accommodate)

Release pipeline: TestingSupport packages are published to NuGet.org again

Fixed

  • The release workflow no longer silently drops every TestingSupport.* package. .github/workflows/release.yml selected packages to push with find . -name '*.nupkg' -not -path '*/test*' -not -path '*/Test*'. The intent was to skip test projects, but find -path matches the whole path and * spans /, so */Test* also matched ./src/TestingSupport*/bin/Release/*.nupkg. Every TestingSupport.* library was therefore excluded from the NuGet.org publish, with no error and no failed check — the only symptom was a package quietly not appearing. Both the .nupkg and .snupkg selection now exclude ./tests/* structurally instead.

Impact on the next release

The next release publishes three packages that previous releases skipped:

Package Previously on NuGet.org Next release
Ploch.TestingSupport 2.0.1 current version
Ploch.TestingSupport.FluentAssertions 2.0.1 current version
Ploch.TestingSupport.MockConsoleApp never published first publish

Ploch.TestingSupport and Ploch.TestingSupport.FluentAssertions had been frozen at 2.0.1 on NuGet.org across several releases while the repository moved on to 4.0.x. Consumers pinned to those versions will see a two-major jump become available.

Verified against a real dotnet build -c Release of the solution: the old filter selected 15 packages, the new one selects 18, and the three added are exactly the TestingSupport.* libraries above. tests/TestAssemblies/Common.Tests.TestTypes — which is packable only because its project name ends in TestTypes rather than Tests, and which must not ship — remains excluded.

Notes

The corrected filter deliberately expresses the exclusion structurally (./tests/*) rather than by matching the word Test anywhere in a path, and carries a comment recording the original defect so it is not reintroduced.

Two related problems were found while fixing this and are tracked separately rather than folded in: the GitHub Packages publish in build-dotnet.yml applies no filter at all and ships test scaffolding to that feed (#279), and Ploch.TestingSupport.XUnit3 / .AutoMoq evaluate to IsPackable=false because of an xUnit v3 props file and produce no package at all, so they cannot be published by any pipeline (#280).

Refs

  • #278 (release workflow filter excludes all TestingSupport packages from NuGet.org)
  • Follow-ups: #279, #280
  • Unblocks the NuGet.org publish of Ploch.TestingSupport.MockConsoleApp added in #275 / #276

Branch rename master → main: CI and versioning accept both names (phase 1 of 3)

ploch-common is the last repository in the mrploch organisation still defaulting to master;
every sibling repo (ploch-data, ploch-commandline, ploch-lists, ploch-endpoints,
ploch-ai-tools, ploch-ini-parser, ploch-tools-editorconfig, ploch-dotnet-templates,
ploch-github-actions, ploch-tiny-tools, mrploch-development) is already on main.

This change is the transitional first phase: every branch reference in CI and versioning now
accepts both names, so the actual rename can happen without a gap in which builds, publishes,
or package versions are wrong. Nothing is renamed yet and no behaviour on master changes.

Changed

  • .github/workflows/build-dotnet.yml — push and pull_request branch filters accept
    master and main; both GitHub Packages publish steps now gate on either ref.
  • .github/workflows/publish-docs.yml — GitHub Pages deployment triggers on either branch.
  • .github/workflows/qodana_code_quality.yml — Qodana scan triggers on either branch
    (releases/* unchanged).
  • .github/workflows/release.yml — the release job's ref guard accepts either branch, and the
    post-release version bump pushes to HEAD:${GITHUB_REF_NAME} instead of the hard-coded
    master. That step is now branch-agnostic and needs no further change after the rename.
  • version.json — publicReleaseRefSpec gains ^refs/heads/main$ alongside
    ^refs/heads/master$.
  • docs/libraries/common-msbuild.md — the NBGV section reproduces version.json verbatim in a
    code block, so it is updated to match. Two drifts were corrected: the new ^refs/heads/main$
    entry and the accompanying prose (caused by this change), and a pre-existing one where the doc
    still showed base version 3.1 after the repository moved to 4.0. The block is now verified
    byte-equivalent to version.json.

Why version.json had to change first

NBGV uses publicReleaseRefSpec to decide whether a build is a public release. Measured on this
repository at commit ea77f33:

Branch Matches refspec PublicRelease NuGetPackageVersion
master yes True 4.0.20-prerelease
a non-matching branch no False 4.0.20-prerelease.gea77f33807

Renaming the branch before this landed would have moved every main build into the second row —
prerelease packages silently gaining a .g<commit> suffix and no longer matching the established
4.0.x-prerelease shape. Landing the refspec first means there is never a window where it is wrong.

Why the workflow filters dual-accept rather than switch

build-dotnet.yml produces three of this repository's four required status checks
(Test Results, SonarCloud Code Analysis, Codacy Static Code Analysis) and is gated on
pull_request: branches: [...]. For pull_request events GitHub selects which workflows run using
the workflow files from the merge ref — the pull request's own proposed version. A single change
that switched the filter straight to main while still targeting master would therefore have
stopped triggering its own required checks, and branch protection would have blocked it
permanently. Accepting both names is safe under any reading of that mechanic.

Not included

No branch was renamed and no documentation, permalink, or agent-instruction file was updated. Those
are phases 2 and 3 of #282: the rename itself is the repository owner's call, and the cleanup that
removes these master fallbacks must land on main afterwards.

Three categories are deliberately left alone. Historical change-log/ entries that mention
master record what was true at the time and are not rewritten. The .github-test/workflows/
copies still reference master only; GitHub reads workflows exclusively from .github/workflows,
so those files are inert and are handled in phase 3.

azure-pipelines.yml also keeps its master-only trigger, deliberately. It is dead configuration
— it targets the deleted Ploch.Common.sln (the repository uses Ploch.Common.slnx), pins .NET
SDK 7.x, and uses the stale SonarCloud key ploch_common rather than mrploch_ploch-common — so
it cannot complete a restore, let alone produce a usable CI result. Adding main to it was
considered and rejected: if the pipeline is no longer wired to an Azure DevOps project the change
has no effect, and if it is still wired it has been failing on every master push for years, so
adding main would only add failing runs on a second branch without recovering any signal. Leaving
the trigger stale means the rename quietly stops it firing at all, which is the desired outcome
pending its retirement under #283.

Refs

  • #282 (rename default branch from master to main for org-wide consistency) — phase 1 of 3
  • Follow-up: #283 (retire the dead azure-pipelines.yml)

Build: repair the projects that blocked DocFX metadata extraction

Five projects outside Ploch.Common.slnx did not compile. Because DocFX globs
src/**/*.csproj from the filesystem rather than reading the solution, every docs
build reported "Build failed" and exited non-zero regardless of the rest of the run.
PR #202 worked around this with temporary docfx.json excludes; those are now lifted
for the shipping WebApi libraries.

Ploch.Common.Maui

  • Fixed a NullReferenceException in the x:NameOf markup extension. NameOfExtension.ProvideValue
    guaranteed that either a matching property or a matching field existed, then unconditionally
    dereferenced the property. Resolving a field by name threw instead of returning its name.
    The lookup now short-circuits on the property and falls through to the field.
  • BaseContentView no longer dereferences Application.Current (nullable in .NET MAUI) or a
    null ViewModel when the view appears.
  • BaseContentView no longer reads the deprecated Application.MainPage. It now inspects
    Application.Current.Windows (guarding the empty case, since no window exists before the first is
    created). In a multi-window application this observes the first window rather than the single
    MainPage, which is the closest equivalent to the previous behaviour.
  • BaseContentView.OnPropertyChanged now matches the base signature's optional parameter.
  • Added the XML documentation these public members were missing.

Ploch.Common.Maui.Tests.TestAssembly1 / TestAssembly2

  • Added the missing ProjectReference to Ploch.Common.Maui. The two fixture assemblies referenced
    BaseViewModel, BaseContentPage, IViewModel and IView without referencing the project that
    defines them. The Ploch.Lists.UI.MauiUI.ViewModels namespace they import was not stale —
    IViewModel really is declared under that namespace inside Ploch.Common.Maui (see the follow-up issue).
  • Ploch.Common.Maui.Tests gained xunit.runner.visualstudio, without which its four
    TypeDiscoverer tests were built but never discovered or executed.

Ploch.Common.WebApi

  • Resolved a hard package conflict. Microsoft.AspNetCore.OpenApi 10.0.5 requires
    Microsoft.OpenApi 2.x, where OpenApiInfo moved out of the Microsoft.OpenApi.Models
    namespace, while Swashbuckle.AspNetCore.SwaggerGen 7.2.0 was still compiled against
    Microsoft.OpenApi 1.6.x. Swashbuckle is now pinned to 10.2.3 for this project, which unifies
    both packages on Microsoft.OpenApi 2.7.5.
  • Cleared a high-severity advisory. The previously resolved Microsoft.OpenApi 2.0.0 was subject
    to GHSA-v5pm-xwqc-g5wc (NU1903); 2.7.5 is not.
  • The project now builds as a class library (Microsoft.NET.Sdk + a framework reference) instead of
    using the Web SDK. Packaging stays explicitly disabled: both CI workflows glob **/*.nupkg, so
    enabling it would publish a package that has never shipped. Whether to ship it is tracked in #285.
  • Removed an unreachable OperationIdSelector that was overwritten by the following
    CustomOperationIds call, and which referenced a non-existent ToPascalCase extension.
  • Fixed a KeyNotFoundException in operation-id generation. The surviving CustomOperationIds
    delegate read ActionDescriptor.RouteValues["controller"] through the dictionary indexer. Endpoints
    registered outside MVC — Minimal API and FastEndpoints routes, the styles this package family exists
    to serve — carry no controller route value, so Swagger generation threw. Operation ids now fall
    back to the HTTP method and route, sanitised so route-parameter braces cannot reach the
    operationId and break client generators.
  • Added XML documentation and argument guards to ConfigureOpenApiOptions.

QueryStringBinder

  • Fixed a KeyNotFoundException. Binding any type with a property absent from the query string
    threw, because the binder used the IDictionary indexer instead of TryGetValue.
  • Fixed culture-dependent parsing. Numbers, dates and times are now parsed with
    CultureInfo.InvariantCulture; previously a query string was interpreted differently depending on
    the server's locale (03/04/2024 meant March in one locale and April in another).
  • Null query values are skipped rather than dereferenced, and a blank value (?page=) leaves a
    non-string property at its default instead of throwing FormatException from int.Parse("").
  • A property type that cannot be bound is now detected even when the caller omits it from the query
    string; previously an absent key skipped the check, so TryParse reported success for a type it
    documents as unsupported.
  • Conversions use the Try* pattern, so a malformed value raises a FormatException naming the
    property and expected type (Query string value 'abc' for property 'Page' is not a valid Int32.)
    rather than the framework's message, which echoed only the bad input.
  • Added tests/Common.WebApi.Tests with 27 tests covering every supported property type, each of the
    defects above, the malformed-value message, and the ConfigureOpenApiOptions guards. The project is registered in Ploch.Common.slnx, so CI builds
    Ploch.Common.WebApi and runs these tests — the class of rot behind this issue was precisely that
    none of these projects were in the solution.

Ploch.Common.WebApi.Endpoints.CrudEndpoints

  • GetByIdEndpointHandler no longer treats the IMemoryCache.TryGetValue out-parameter as
    non-nullable, which had blocked compilation of this project and the two framework integrations
    that depend on it.

Documentation build

  • DocumentationSite/docfx.json no longer excludes **/Common.WebApi/**, so Ploch.Common.WebApi
    and Ploch.Common.WebApi.Endpoints are documented again.
  • The CrudEndpoints family (WebApi.Endpoints.CrudEndpoints and its FastEndpoints and
    MinimalApiEndpoints integrations) stays excluded for a different reason: those projects
    ProjectReference two projects in the sibling ploch-data repository, and no workflow clones it,
    so DocFX cannot resolve them on a CI runner. Documenting them needs either that clone step or a
    switch to package references — tracked separately.
  • The test-project excludes remain: test assemblies do not belong in the published API reference.

NuGet Trusted Publishing (OIDC) replaces the long-lived API key

Type: CI / release infrastructure. No library code, public API, or package content is
affected — consumers see no change.

release.yml no longer authenticates to nuget.org with the long-lived NUGET_API_KEY
organisation secret. It now uses
NuGet Trusted Publishing:
GitHub Actions issues a short-lived, signed OIDC token, nuget.org validates it against a
policy bound to (repository owner, repository, workflow filename) and returns an API key
valid for one hour.

Changes

  • permissions gains id-token: write, required for GitHub to issue the OIDC token.
  • A NuGet/login step, pinned to a full commit SHA, runs immediately before the push
    steps. The returned key is valid for one hour, so requesting it earlier — before a full
    build and test run — risks expiry mid-release.
  • Both the package and symbol pushes take their key from
    steps.nuget-login.outputs.NUGET_API_KEY.
  • A new early validation step fails the run in seconds if the NUGET_USER variable is
    missing, instead of after a full build.

Operational notes

  • Requires the org variable NUGET_USER (set to the nuget.org profile name, not the
    email address) and a trusted publishing policy on nuget.org for this repository and the
    release.yml filename. The policy matches on the workflow filename, so renaming
    release.yml breaks publishing.
  • The NUGET_API_KEY organisation secret is deliberately retained. It is shared with
    other repositories that have not yet migrated; removing it is a one-way door and must
    wait until every publishing repo uses OIDC.
  • Rollback is reverting this commit — the secret still exists and the previous workflow
    keeps working.

Refs: #301

Removed the dead Plocch.Common.DependencyInjection.Autofac project

Type: housekeeping. No package-consumer impact — the package was never published
to nuget.org or GitHub Packages, so no project can depend on it. Documentation readers
were affected: the published docs site listed it as an available library (see below).

src/Common.DependencyInjection.Autofac/ contained a single .csproj and no source
files at all
. It was absent from every checked-in solution file
(Ploch.Common.slnx, Ploch.Common.Endpoints.slnx, Ploch.Common.LocalDev.slnx,
Ploch.Common.WebApi.Endpoints.slnx) and no project referenced it, and no checked-in
script or workflow builds it directly — so no build produced a package from it. (It could
still be compiled by pointing dotnet build at the .csproj by hand, though no build
script or workflow did so.) One piece of automation did touch it: DocumentationSite/docfx.json
globs ../src/**/*.csproj from the filesystem rather than reading a solution, so DocFX
ran MSBuild over this project on every docs build and logged
does not contain any documents. Deleting it therefore also removes a standing DocFX
warning (see #290). Its project file also carried a typo'd prefix
(double c), which the package ID would have inherited had it ever shipped.

Removed, along with the documentation that advertised it:

  • src/Common.DependencyInjection.Autofac/ (project deleted)
  • docs/libraries/common-dependency-injection-autofac.md (page deleted — it described an
    API that did not exist, flagged in its own text as "aspirational/planned")
  • Entries removed from docs/INDEX.md, docs/toc.yml, DocumentationSite/index.md, and
    the "see also" links in common-dependency-injection.md and
    common-dependency-injection-hosting.md
  • The stale <File> entry in Ploch.Common.slnx — that entry listed the docs page
    as a solution item, not the project; the .csproj itself was never in any solution

The published documentation site listed Ploch.Common.DependencyInjection.Autofac among
the libraries, so readers could reasonably have believed the package existed. That listing
is now gone.

Autofac support is not implemented. If it is wanted later, it should be added as a
correctly-named project that is registered in the solution from its first commit.

The shared Autofac.Extensions.DependencyInjection version pin in
mrploch-development/dependencies/Common.Packages.props is left untouched — it is
shared with other repositories.

Refs: #303

#200 — Per-namespace DocFX overwrite files for the priority namespaces

Type: documentation
Breaking changes: none

Summary

The published API reference now opens each priority namespace with an authored Overview section explaining
why the namespace exists and when to reach for it, rather than dropping the reader straight into the
auto-generated type table.

Details

Added DocFX overwrite files under DocumentationSite/namespaces/ for thirteen namespaces:

  • Ploch.Common
  • Ploch.Common.Collections
  • Ploch.Common.ArgumentChecking
  • Ploch.Common.Reflection
  • Ploch.Common.Serialization
  • Ploch.Common.DependencyInjection
  • Ploch.Common.IO
  • Ploch.Common.Randomizers
  • Ploch.Common.Extensions.Configuration
  • Ploch.Common.Apps.Model
  • Ploch.TestingSupport
  • Ploch.TestingSupport.XUnit3
  • Ploch.TestingSupport.FluentAssertions

Each file cross-links to the corresponding authored page under docs/libraries/.

The pre-existing DocumentationSite/namespaces/Ploch.Common.md stub declared
uid: Ploch.Common.Data.Model, a namespace that does not exist in this repository. DocFX silently discards an
overwrite whose uid matches nothing, so the stub had never contributed anything to the generated site. Its
content has been replaced with a genuine Ploch.Common overview.

No changes to shipped library code — packages are unaffected.

Retire the dead Azure Pipelines definitions

The repository moved to GitHub Actions years ago. Three Azure DevOps pipeline definitions survived
that migration untouched. None of them can run successfully against the current repository, and
their continued presence misleads anyone reading the repository's CI setup into thinking Azure
DevOps is part of it. All three are removed.

Removed

File Last meaningful commit Why it is dead
azure-pipelines.yml 988e168, 2022-12-10 Restores, builds and packs ./Ploch.Common.sln, which does not exist — the repository uses Ploch.Common.slnx. It also pins .NET SDK 7.x (the repository targets netstandard2.0, net8.0 and net10.0), uses the stale SonarCloud key ploch_common and organisation ploch rather than mrploch_ploch-common, and versions packages from a PackageVersion environment variable that Nerdbank.GitVersioning replaced. It pushed to NuGet.org on every trigger, contradicting the deliberate manual-dispatch-only flow in .github/workflows/release.yml.
pipelines/original-sln-pipeline.yml 80a7701, 2020-12-27 A superseded earlier draft of the same pipeline, referenced by nothing. It uses reportgenerator@4 and PublishCodeCoverageResults@1, both long deprecated. Removing it empties the pipelines/ directory, which git therefore drops.
src/Common/azure-pipeline.yml bc48477, 2020-12-19 A three-byte file containing nothing but a UTF-8 byte-order mark. It has been empty since the initial commit.

Verification performed

No workflow, script, documentation page, or solution file references any of the three. The two
solution-item references to an azure-pipelines.yml (in Ploch.Common.Endpoints.slnx and
Ploch.Common.WebApi.Endpoints.slnx) point at ../ploch-data/azure-pipelines.yml — a file in the
sibling ploch-data repository, not in this one — and are therefore unaffected.
Ploch.Common.slnx lists none of the removed files as solution items. The only other mention is
the historical change-log/2026-08-22-282-... entry, which records what was true at the time and
is left as-is.

Not included

Whether an Azure DevOps project still points at this repository is an owner-side check that cannot
be answered from the git tree. If one exists it has been failing on every master push for years
and should be disabled at the Azure DevOps end too.

my-schema.json at the repository root is the Azure Pipelines VS Code JSON schema and is now
similarly unused, but removing it is outside this change's scope.

Refs

  • Closes #283
  • Follows #282 (rename default branch from master to main), phase 1, which surfaced the file

#286 — Move MAUI types out of the Ploch.Lists.UI.MauiUI.* namespaces

Type: refactor (breaking)
Packages: Ploch.Common.Maui, Ploch.Common.Maui.Fonts

Summary

Two namespaces left over from the original extraction of these types out of the
ploch-lists repository have been realigned with the packages that actually ship them:

Before After
Ploch.Lists.UI.MauiUI.ViewModels Ploch.Common.Maui.ViewModels
Ploch.Lists.UI.MauiUI.Common.Fonts Ploch.Common.Maui.Fonts

The first held the public IViewModel interface, so a consumer of Ploch.Common.Maui had to
import an unrelated product's namespace alongside Ploch.Common.Maui.Views to derive from
BaseContentPage or BaseContentView. The second was declared across the six icon-font glyph
classes in Ploch.Common.Maui.Fonts plus IFontsBuilder in Ploch.Common.Maui.

Beyond the confusion, the stray namespace actively misled diagnosis: while investigating #197,
CS0234: The type or namespace name 'Lists' does not exist in the namespace 'Ploch' read like a
missing cross-repository dependency when the type had been in the referenced assembly all along.

IViewModel also gained the XML documentation it was missing.

BREAKING CHANGE

Ploch.Lists.UI.MauiUI.ViewModels.IViewModel is now
Ploch.Common.Maui.ViewModels.IViewModel, and the types in
Ploch.Lists.UI.MauiUI.Common.Fonts (FontAwesomeBrands, FontAwesomeRegular,
FontAwesomeSolid, MaterialDesignRegularFont, MaterialDesignWebFont, MauiMaterialAssets,
IFontsBuilder) are now in Ploch.Common.Maui.Fonts. Consumers must update their using
directives; no type names, signatures, or behaviour changed.

Impact in practice: none. Neither Ploch.Common.Maui nor Ploch.Common.Maui.Fonts has ever
been published to nuget.org or GitHub Packages, and neither project is part of
Ploch.Common.slnx — they live only in Ploch.Common.Endpoints.slnx. There are no consumers to
break, which is precisely why the rename was done now rather than after first publication.

Refs: #286, #197

Ploch.Common.WebApi: controller-based operation ids are now unique

Fixed colliding OpenAPI operationIds for MVC controller endpoints.
OpenApiConfigurator.BuildOperationId built the id for a controller action as controller
name plus HTTP method, which is not unique whenever one controller exposes more than one
route for the same verb — the ordinary shape of a REST controller:

Route operationId (before)
GET /orders OrdersGET
GET /orders/{id} OrdersGET
GET /orders/{id}/items OrdersGET

OpenAPI requires operationId to be unique across the document. Duplicates make client
generators either fail outright or silently drop operations, so a generated client could
end up missing endpoints with no error reported.

Controller ids now carry the same deterministic, route-derived suffix the route-based
fallback branch already used (a truncated SHA-256 of the route, so it is stable across
processes and runs and generated documents stay diffable). The routes are therefore
separated without having to detect which of them collided, and the controller name stays
at the front of the id for readability:

Route operationId (after)
GET /orders OrdersGET_1c168adb00d208e4
GET /orders/{id} OrdersGET_ef7dbd91889b7768
GET /orders/{id}/items OrdersGET_920da46d7d51d91e

When an ApiExplorer provider does not populate RelativePath, the attribute-route
template is used instead, and failing that the action route value; with none of the
three available the id keeps its previous {Controller}{Method} form.

Widened the route suffix from four bytes to eight. A 32-bit digest is small enough for
two ordinary routes to share one — orders/collision-32524 and orders/collision-68690
both truncated to 61565973 — which reintroduced the duplicate operationId the suffix
exists to prevent. This also changes ids generated by the route-based fallback branch
(Minimal API and FastEndpoints endpoints), from GET_orders_id_ef7dbd91 to
GET_orders_id_ef7dbd91889b7768.

Compatibility

This changes the generated operationId for every controller-based endpoint, and so the
member names produced by OpenAPI client generators. Ploch.Common.WebApi has never been
published (IsPackable=false), so there are no package consumers to break; anyone
generating a client from a locally built document will see renamed operations. Whether to
ship the package is tracked in #285.

Refs: #298

#204 — Argument validation article for the documentation site

Type: documentation
Breaking changes: none

Summary

Added an authored article covering the Ploch.Common.ArgumentChecking namespace to the Articles section of the
documentation site, aimed at developers deciding which guard to use rather than simply listing the API.

Details

New page DocumentationSite/articles/argument-validation.md, wired into DocumentationSite/articles/toc.yml.

It covers:

  • the caller-error (NotNull → ArgumentNullException) versus invalid-internal-state
    (RequiredNotNull → InvalidOperationException) distinction, and why choosing the wrong family produces
    misleading exceptions;
  • every guard Guard and PathGuard expose — NotNull, NotNullOrEmpty, NotNullOrDefault, Positive,
    NotOutOfRange, RequiredTrue/RequiredFalse, RequiredNotNull, RequiredNotNullOrEmpty, IsValidPath,
    EnsureFileExists, RequiredIsValidPath, RequiredFileExists, RequireValidPath;
  • non-obvious behaviour: NotNullOrEmpty enumerating a deferred sequence (and so executing a LINQ query
    twice), [Flags] enum handling in NotOutOfRange, NotNullOrDefault rejecting 0/false/Guid.Empty,
    and the automatically-captured expression/member/file/line default message on RequiredTrue;
  • the netstandard2.0 versus net7.0+ API asymmetries, including the mandatory positional parameter name on
    the netstandard2.0 target;
  • migration away from the deprecated Ploch.Common.DawnGuard package.

Every code sample was compiled against the real Ploch.Common API, and the documented exception messages were
verified by executing them.

No changes to shipped library code — packages are unaffected.

Ploch.Common.Serialization.NewtonsoftJson.ExtensionsDependencyInjection: dropped the FluentAssertions dependencies

Removed FluentAssertions and FluentAssertions.Json from the shipped package's dependency graph.
The project declared both as PackageReferences, but its only source file
(NewtonsoftJsonSerializerRegistration.cs) never used either — they were leftovers, most
likely copied from a test project. Because the project is packable, every consumer of
Ploch.Common.Serialization.NewtonsoftJson.ExtensionsDependencyInjection transitively
pulled two test-assertion libraries into their own dependency graph.

The generated nuspec's <dependencies> group changes by exactly those two entries and
nothing else:

       <group targetFramework=".NETStandard2.0">
         <dependency id="Ploch.Common.Serialization.NewtonsoftJson" version="…" exclude="Build,Analyzers" />
-        <dependency id="FluentAssertions" version="8.10.0" exclude="Build,Analyzers" />
-        <dependency id="FluentAssertions.Json" version="8.0.0" exclude="Build,Analyzers" />
         <dependency id="Microsoft.Extensions.DependencyInjection.Abstractions" version="10.0.5" exclude="Build,Analyzers" />
       </group>

Ploch.Common.Serialization.NewtonsoftJson.ExtensionsDependencyInjection.Tests did use
FluentAssertions, but relied on inheriting it transitively from the project under test.
Both packages are now declared directly on the test project, where they belong.

Compatibility

Consumers that (accidentally) relied on FluentAssertions arriving transitively through
this package must now reference it themselves. That is the intended correction — a
production package should not put a test-assertion library on its consumers' compile
path. The public API of the package is unchanged.

Refs: #273

Documentation build: the DocFX metadata warning backlog is cleared and warnings are errors again

dotnet docfx metadata DocumentationSite/docfx.json now completes with zero warnings, and
--warningsAsErrors has been restored to that step in the Publish Docs workflow so the API
metadata cannot regress silently again. PR #202 dropped that flag to let the site publish for the
first time in months, leaving a backlog of warnings behind it.

The site build step, now invoked separately as dotnet docfx build DocumentationSite/docfx.json,
is not guarded yet: it still emits two InvalidFileLink warnings that predate this work and are
unrelated to it — the ADR template's deliberate placeholder link, and a .json example that sits
outside the site's content globs. Those are tracked in #316, and --warningsAsErrors moves onto
that step once they are cleared.

The backlog measured on this branch was 8 warnings, not the 14 the issue recorded. The
difference is a scope change in DocumentationSite/docfx.json, not a fix: #290 was written on
2026-08-23 against the in-progress #197 branch, which had lifted the DocFX exclude from the whole
Common.WebApi family. PR #291 (commit 887198e, 2026-08-26) narrowed that exclude to
**/WebApi.Endpoints.CrudEndpoints*/** before merging, which removed the only AutoMapper-referencing
project in src/ — Ploch.Common.WebApi.Endpoints.CrudEndpoints — from the metadata pass, taking
its two NU1903 advisories with it. The same PR also converted Ploch.Common.WebApi off
Microsoft.NET.Sdk.Web, which accounts for one of the two FailedToLoadAnalyzer occurrences.

#287 is still open and AutoMapper is still pinned to the vulnerable 14.0.0 in
mrploch-development/dependencies/Common.Packages.props. Nothing in this branch or in #291 resolves
it; the advisory is merely no longer visible to docfx metadata, because the project that pulls
AutoMapper in is excluded from that pass. Do not read this entry as evidence that #287 is done.

The remaining eight warnings are addressed as follows.

Ploch.Common.Maui.Fonts: explicit hiding on the Equals glyph member (CS0108 × 3)

FontAwesomeRegular, FontAwesomeSolid, and FontAwesomeBrands each expose a member named
Equals — the glyph for the = character — which hides object.Equals(object?). The member now
carries the new modifier, which is exactly what CS0108 asks for and is the only non-breaking
option: these are public members of a published package, and the name is dictated by the font, so
renaming would break consumers.

The three files are generated by IconFont2Code, a
web tool rather than a build-time generator, so there is no generator in the repository to correct.
Each file's header comment now records the manual edit so it is reapplied if the file is ever
regenerated.

Ploch.Common.Maui.Fonts: CS1591 disabled for the project

The project produces roughly twelve thousand missing-XML-comment warnings, one per generated glyph
constant. CS1591 is now in the project's NoWarn with a comment explaining why: a per-glyph
<summary> would repeat the code point the constant already holds, and the volume would swamp the
build log the moment the project joins the solution.

NoWarn is project-wide, so it suppresses CS1591 at type level as well as on the ~12,000 members.
The six public types in IconFonts/ therefore carry a hand-written <summary> each, added by this
change, so the API reference still says what each font class is for. Their glyph members stay
undocumented deliberately.

Ploch.Common, Ploch.Common.DependencyInjection: duplicate XML documentation tags (× 4)

Three members combined a hand-written tag with <inheritdoc />, so the same tag arrived twice and
DocFX discarded the second copy:

  • IOwnedPropertyInfo<TType, TProperty> had <inheritdoc /> alongside its own
    <typeparam name="TProperty">, which the base interface also documents. It now carries a real
    <summary> and <remarks> of its own instead of inheriting them — the interface had no summary
    before, so this is a documentation gain rather than a like-for-like swap. This also cleared the two
    duplicates reported against OwnedPropertyInfo<TType, TProperty>, which inherited from it.
  • DelegatingServicesBundle.Configure(IConfiguration) had a trailing <inheritdoc /> after a
    complete set of hand-written <summary>, <param>, <remarks>, and <exception> tags. The
    redundant <inheritdoc /> is removed; no documentation is lost.

Ploch.Common.AppServices.Web: built as a class library rather than a web application

The project used Microsoft.NET.Sdk.Web, which brings in the Razor SDK, and DocFX's older embedded
Roslyn could not load Microsoft.CodeAnalysis.Razor.Compiler — the FailedToLoadAnalyzer /
ReferencesNewerCompiler warning. The project contains two source files and no Razor, views, or
static web assets, so it now uses the plain Microsoft.NET.Sdk with
<FrameworkReference Include="Microsoft.AspNetCore.App" />, the documented pattern for ASP.NET Core
class libraries.

The two source files previously relied on the Web SDK's implicit usings and now import
Microsoft.AspNetCore.Http and Microsoft.Extensions.DependencyInjection explicitly.

Compatibility

No public API changes. Ploch.Common.AppServices.Web still declares the same
Microsoft.AspNetCore.App framework reference in its .nuspec, so the package's contract for
consumers is unchanged.

Refs: #290

Ploch.TestingSupport / Ploch.TestingSupport.XUnit3: test data files resolve against the test assembly

Fixed data attributes resolving a relative file path against the process working directory.
TextFileDataAttribute (and so TextFileLinesDataAttribute) and JsonFileDataAttribute in both
packages passed the supplied path through Path.GetFullPath, which anchors a relative path to
Environment.CurrentDirectory. Test data files handed to these attributes are almost always project
content copied next to the assembly by CopyToOutputDirectory, so the correct anchor is
AppContext.BaseDirectory — the directory the assembly was loaded from, which does not depend on how
the test host was launched.

Only a fully qualified path is now used as given; every other form is anchored to
AppContext.BaseDirectory before being normalised. "Fully qualified" is deliberately stricter than
"rooted": on Windows "\TestData\cases.txt", "/TestData/cases.txt" and the drive-relative
"C:cases.txt" are all rooted yet still resolve against the current drive or that drive's current
directory — ambient state the test host controls, which is exactly the failure mode this change exists to
remove. Such a path has its leading root stripped and the remainder anchored to the assembly directory. On
Unix-like platforms rooted and fully qualified mean the same thing, so a path starting with / is used
unchanged. Ploch.TestingSupport targets netstandard2.0, where Path.IsPathFullyQualified does not
exist, so the check is a small shim mirroring the framework implementation; the two packages now compile
one shared copy of the resolver, so they cannot drift apart on it.

Attribute argument Resolved (before) Resolved (after)
"TestData/cases.txt" <working directory>/TestData/cases.txt <assembly directory>/TestData/cases.txt
"/TestData/cases.txt" (Windows) <current drive>\TestData\cases.txt <assembly directory>\TestData\cases.txt
"C:cases.txt" (Windows) <C:'s current directory>\cases.txt <assembly directory>\cases.txt
"C:\data\cases.txt" C:\data\cases.txt C:\data\cases.txt
"/data/cases.txt" (Linux/macOS) /data/cases.txt /data/cases.txt

The fault was latent rather than constant because the xUnit v3 runners in common use normalise the
working directory to the assembly directory before running tests. A host that does not — an IDE
runner, a custom Microsoft.Testing.Platform host, or any use of the attribute outside a normalising
runner — saw ArgumentException: Could not find file at path: ... naming an unrelated directory.

Fixed Ploch.TestingSupport.XUnit3.TestData.TextFileDataAttribute reading a different path from
the one it validated.
The existence check ran against the resolved path while the read used the
raw filePath argument. The two happened to resolve identically before this change, so the defect
was invisible; once the anchor moved to the assembly directory they diverged and the read failed
after the check had passed. Ploch.TestingSupport.XUnit3.TestData.JsonFileDataAttribute had the same
divergence and is fixed with it.

Fixed Ploch.TestingSupport.XUnit3.TestData.TextFileLinesDataAttribute keeping whitespace-only lines when
removeEmptyEntries was set.
Its documentation and constructor parameter both stated that empty and
whitespace lines are excluded, but the filter tested only for an empty string, so a line of spaces or tabs was
still handed to the theory. It now uses string.IsNullOrWhiteSpace, matching its own documented contract and
the Ploch.TestingSupport implementation.

Ploch.TestingSupport.TestData.JsonFileDataAttribute now checks that the file exists before
reading it, throwing ArgumentException naming the resolved path, which is what the other three
attributes already did. It previously let File.ReadAllText throw FileNotFoundException.

Compatibility

This is consumer-visible. A test suite whose data file sits relative to the working directory
rather than the test assembly's output directory — and which relied on the old resolution — will now
fail to find it. Fix such a case by making the file project content with
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>, or by passing an absolute path,
which is honoured unchanged. A path that is rooted but not fully qualified — a Windows path beginning with a
separator, or a drive-relative one — is no longer resolved against ambient drive state; make it fully
qualified if that was intended.

Ploch.TestingSupport.XUnit3.TestData.TextFileLinesDataAttribute with removeEmptyEntries: true now
also drops whitespace-only lines. A suite that depended on those rows reaching the theory will see fewer
test cases; pass removeEmptyEntries: false to keep every line.

No public API signature changed.

Refs: #309, #299

PR-preview deployments for the DocFX documentation site

Issue: #201
Type: CI / documentation tooling
Breaking change: No

What changed

Every pull request now gets its DocFX site built automatically and a single sticky
📖 Documentation preview comment linking to the result. The comment is rewritten
in place on every push and replaced with a "preview removed" note once the pull
request closes or merges.

The preview is not restricted to pull requests that edit docs/ or
DocumentationSite/. Most of the site is API reference generated from XML
documentation comments in the C# sources, so a pull request that touches no Markdown
at all can still change dozens of pages.

The work lives in two places:

  • .github/workflows/publish-docs.yml gains two pull-request-only jobs —
    publish-preview, which pushes the built site under pr-preview/pr-<N>/ on a
    dedicated preview branch, and comment-preview, which posts the sticky comment.
  • .github/workflows/docs-preview-cleanup.yml (new) removes that directory on
    pull_request: closed, so previews cannot accumulate.

A new documentation page, docs/docs-preview.md, explains how reviewers use the
preview and how a maintainer enables the hosted lane.

One build, not two

The preview was first implemented as a standalone docs-preview.yml with its own
DocFX build. #330 then added a
pull_request trigger to publish-docs.yml, which made that a second build of the
same commit producing the same output — and the two copies had already drifted in a
way that mattered: the preview still passed --warningsAsErrors to
docfx metadata, the exact flag #330 had to replace with a narrower gate because
DocFX 2.78.5 always emits workspace warnings on the Linux runner
(#329).

So the preview jobs were folded into publish-docs.yml and docs-preview.yml was
deleted. There is now one build-docs job feeding both the production Pages
deployment and the preview, which halves the runner time on a documentation pull
request and makes it structurally impossible for a preview to be built under
different rules from production.

Isolation from production

The production site at https://github.ploch.dev/ploch-common/ is deployed by the
deploy-docs job through actions/upload-pages-artifact and actions/deploy-pages,
which require pages: write and id-token: write. Neither preview job is granted
either permission, and deploy-docs is itself gated on
github.event_name != 'pull_request' — so the Pages deployment is protected twice
over, by the token and by the trigger, rather than by convention. The preview jobs
also stay out of the "pages" concurrency group and touch no production artefact.

The branch write is a separate concern that the token does not constrain —
contents: write covers every branch — so both publish-docs.yml and
docs-preview-cleanup.yml validate the resolved DOCS_PREVIEW_BRANCH before
touching anything and hard-fail on master, main and gh-pages.

Hosting is opt-in

A repository serves exactly one GitHub Pages site, and this repository's is fed by
the production artifact, so a preview branch is not served automatically. Until a
host is configured the workflow links the downloadable site artifact instead of
posting a URL that would not resolve. Setting the repository variable
DOCS_PREVIEW_BASE_URL (and optionally DOCS_PREVIEW_BRANCH) switches the comment
to hosted preview URLs, with no other change required.

Forks and Dependabot

Pull requests raised from forks, and those authored by Dependabot, receive a
read-only GITHUB_TOKEN. The build job needs only contents: read and therefore
runs for them, giving the same docs build signal; the publishing job detects the
condition and skips with a workflow notice rather than failing. No
pull_request_target is used.

Comment on every outcome

The sticky comment is posted by a separate if: always() job, so a pull request
whose docs build fails still gets a comment saying so and linking the run, instead
of silence. The body is selected from the observed result of the build and publish
jobs — including a publish that stood down because the pull request closed mid-run —
so a preview URL is never advertised unless it was actually written.

#204 — Collections and enumerables article for the documentation site

Type: documentation
Breaking changes: none

Summary

Added an authored article covering the Ploch.Common.Collections namespace to the Articles section of the
documentation site, organised around the task a developer is trying to accomplish rather than as a flat
API listing.

Details

New page DocumentationSite/articles/collections-and-enumerables.md, wired into
DocumentationSite/articles/toc.yml.

It covers:

  • membership and emptiness tests — ValueIn, None, IsEmpty, IsNullOrEmpty — and when each beats the
    equivalent LINQ expression;
  • conditional query composition with If, and the eager-iteration semantics of ForEach;
  • string building with Join and JoinWithFinalSeparator, including the empty and single-element cases;
  • bulk mutation through AddMany on both collections and dictionaries, the DuplicateHandling policies,
    and the params overload's reversed argument order;
  • Add / AddIfNotNull chaining over ICollection<KeyValuePair<TKey, TValue?>>;
  • sampling with Shuffle and TakeRandom, their System.Random (non-cryptographic) basis, and the fact
    that sampling is without replacement over positions — a source containing equal values can return the
    same value more than once;
  • the smaller helpers GetWithEmptyProperty, AreIntegersSequentialInOrder, Second, ExceptItems and
    ArrayExtensions.Exists, including ExceptItems inheriting set semantics and dropping duplicates;
  • a per-method summary of how often each helper enumerates its source;
  • a per-method summary of argument guarding, recording exactly which helpers validate their own arguments
    and which delegate to LINQ — and therefore surface LINQ's parameter names, or, for a null
    GetWithEmptyProperty selector, a deferred NullReferenceException instead of an ArgumentNullException.

A caveat was added that AddMany is not an atomic merge and so is unsafe against a concurrently written
ConcurrentDictionary, despite being generic over IDictionary<TKey, TValue>.

Every code sample was compiled against the real Ploch.Common API, and the documented sampling and
argument-guarding behaviour was verified by executing it.

Review corrections

Three points raised in review were verified by execution and corrected:

  • TakeRandom sampling weights. The advice to call source.Distinct().TakeRandom(count) for distinct
    values was followed by a sentence stating that a value repeated five times is five times likelier to be
    drawn — which describes the behaviour without Distinct(), and so contradicted the advice it was
    attached to. The passage now separates the two: occurrence weighting is a property of the raw source,
    and Distinct() removes it, giving every surviving value equal probability. Measured over 200,000
    trials: without Distinct() a five-times-repeated value is drawn 50.2% of the time against 9.9% for a
    singleton (5.05x); with Distinct() both are drawn 16.7% of the time (1.00x).
  • AreIntegersSequentialInOrder numeric wraparound. The successor test is array[i] + 1 in unchecked
    arithmetic, so "exactly one greater" holds only modulo the integer range. Confirmed by execution that
    both [int.MaxValue, int.MinValue] and [long.MaxValue, long.MinValue] return true. The contract now
    documents the wraparound instead of overstating the guarantee.
  • collections-samples.md accuracy. Rather than documenting a disagreement between the two pages, the
    sibling reference was corrected: AreSequentialInOrder<T> → AreIntegersSequentialInOrder and
    NullOrEmpty → IsNullOrEmpty, with signatures taken from the source. All twelve samples in that file
    were compiled and executed against the real API. The now-obsolete "where the two disagree" disclaimer was
    removed from this article. Remaining coverage gaps in that reference are tracked in #332.

No changes to shipped library code — packages are unaffected.

#204 — Expressions and owned property info article for the documentation site

Type: documentation
Breaking changes: none

Summary

Added an authored article covering the Ploch.Common.Linq namespace to the Articles section of the
documentation site, aimed at developers deciding when a lambda selector beats a string literal rather than
simply listing the API.

Details

New page DocumentationSite/articles/expressions-and-owned-properties.md, wired into
DocumentationSite/articles/toc.yml.

It covers:

  • when not to use ExpressionExtensions.GetMemberName — nameof wins wherever the member is known at the
    call site; the expression overloads earn their keep inside generic methods and fluent builders;
  • all three GetMemberName overloads, and why the two-type-parameter form is the one for library code: it is
    the only overload that unwraps the UnaryExpression produced when a value-type member is boxed into an
    Expression<Func<TEntity, object>> selector;
  • GetProperty and IOwnedPropertyInfo/OwnedPropertyInfo — a PropertyInfo bound to its owning instance,
    the typed versus non-generic interfaces, and Owner typed as TType;
  • production-shaped scenarios: a rename-safe sortable-field whitelist, a change-tracking snapshot, and
    validation messages whose property name cannot drift from the property checked;
  • non-obvious behaviour: selectors must read a property directly off the lambda parameter — a nested
    selector (c => c.Address.City) and a selector closing over another instance (c => other.Id) both
    construct cleanly and then throw TargetException or silently read the wrong owner; indexer selectors
    resolve to Item and discard the index, so reads and writes need the index-taking overloads; and
    yield-based change enumeration reads values at enumeration time, not at call time;
  • the failure modes and their exact exception messages, plus the cost/caching trade-off.

Every code sample was compiled against the real Ploch.Common API, and the documented exception messages and
return values were verified by executing them.

No changes to shipped library code — packages are unaffected.

#204 — Process and environment utilities article for the documentation site

Type: documentation
Breaking changes: none

Summary

Added an authored article covering the four process- and environment-facing helpers in Ploch.Common —
EnvironmentVariables, EnvironmentUtilities, OperatingSystemExtensions and
Ploch.Common.Diagnostics.ProcessExtensions — to the Articles section of the documentation site. The page is
organised around the questions an application asks about the machine it is running on, and is explicit about
where behaviour is OS-conditional.

Details

New page DocumentationSite/articles/process-and-environment.md, wired into
DocumentationSite/articles/toc.yml.

It covers:

  • EnvironmentVariables as a configuration surface, and the two places its permissiveness bites:
    GetBool accepts only true/false (so 1, yes and on all yield null, which a ?? false silently
    turns into off), and GetEnumValue accepts numeric strings and comma-separated name lists, so 99
    returns an undefined (TEnum)99 rather than null. Both are shown with a full value/result table and a
    guarded wrapper. Also notes that ignoreCase defaults to true here, the opposite of the underlying
    SafeParseToEnum string extension, and that a null variable name surfaces as the BCL's
    ArgumentNullException with parameter name variable.
  • EnvironmentUtilities.GetCurrentAppPath — why it beats Environment.CurrentDirectory for locating
    deployed sidecar files, its entry-assembly/AppDomain.BaseDirectory fallback chain, its behaviour under
    single-file publish (Assembly.Location is empty, the NullIfEmpty fallback covers it), the resulting
    IL3000 trim warning in consuming builds, and its lack of a trailing directory separator.
  • EnvironmentUtilities.GetEnvironmentCommandLine — flagged prominently, because it splits
    Environment.CommandLine on spaces without honouring quoting. Documented with real output showing a quoted
    path shredded into three fragments, and the worse case where a space in the application's own path leaks
    path fragments in as phantom arguments. Environment.GetCommandLineArgs() is recommended instead.
  • OperatingSystemExtensions.IsWindows — only PlatformID.Win32NT counts; the real reason to prefer it
    over the net5.0+ static is that it takes an OperatingSystem instance, which makes platform branching
    unit-testable on any agent; plus the netstandard2.0 angle, PlatformID.Unix covering Linux/macOS/FreeBSD
    alike, and the fact that it is not a CA1416 platform guard.
  • ProcessExtensions — Windows/Linux only (PlatformNotSupportedException elsewhere, CA1416 on
    net5.0+); a pinned-worker scenario; the pointer-width bound on processor numbers and why
    Environment.ProcessorCount is deliberately not used as an index bound; the platform split when a
    non-existent processor is requested (Windows rejects the whole mask with Win32Exception and leaves the
    previous affinity intact, Linux intersects silently, which is why SetEnabledProcessors reads the mask back
    and throws InvalidOperationException); that GetEnabledProcessors returns an eager List<int> snapshot
    rather than a live view; a full argument-guard table including SetEnabledProcessors(null) throwing
    ArgumentException rather than ArgumentNullException and an unstarted Process failing inside the BCL;
    and the caution against pinning the current process, with a capture-and-restore pattern.

Every code sample was compiled against the real Ploch.Common API in a scratch project, and every
behavioural claim — exception types, parameter names, exception message text, snapshot semantics, the
command-line splitting output, and the single-file publish path resolution — was verified by executing it and
quoting the real output.

No changes to shipped library code — packages are unaffected.

Review follow-up (PR #334)

Corrections made after review, each re-verified by execution:

  • Empty vs absent environment variables — the page originally claimed that GetString, GetBool and
    GetEnumValue behave identically for a blank value on every platform, and attributed empty-variable
    handling to a Windows/Linux split. Both are wrong. GetString forwards Environment.GetEnvironmentVariable
    and can return "", while the two parsing methods normalise blank input to null; and the
    delete-on-empty behaviour of Environment.SetEnvironmentVariable(name, "") is a documented
    .NET 9 breaking change,
    not a platform difference. Measured on Windows 11 under .NET 8.0.30 (deletes) and .NET 9.0.19 (keeps),
    and on Ubuntu 24.04 under a self-contained net8.0 build (deletes) and .NET 9.0.14 (keeps).
  • Linux processor affinity is per-thread — Process.ProcessorAffinity on Linux goes through
    sched_setaffinity(2) for the process id, which the kernel treats as the main thread. Threads already
    running keep their old mask, and GetEnabledProcessors reports the main thread's mask. A new section
    documents the Windows/Linux split with measured results and the practical consequence for pinning.
  • GetFlag sample — lower-cases the raw value so YES/ON are recognised, matching the surrounding text.
  • RequireDefinedEnum sample — reads the raw string first, so "set to nonsense" throws instead of
    silently falling back to the default, which is the principle the section argues for.

#204 — Randomizers article for the documentation site

Type: documentation
Breaking changes: none

Summary

Added an authored article covering the Ploch.Common.Randomizers namespace to the Articles section of the
documentation site, written around the traps a developer actually hits rather than as a restatement of the
type list.

Details

New page DocumentationSite/articles/randomizers.md, wired into DocumentationSite/articles/toc.yml.

It covers:

  • the namespace ships no DI registrations — injecting IRandomizer<T> compiles and then throws
    InvalidOperationException: No service for type 'Ploch.Common.Randomizers.IRandomizer\1[System.Int32]' has
    been registered.The staticRandomizer.GetRandomizer()factory is the entry point; registering the concrete implementations is the consumer's job, and a registration againstIRangedRandomizerdoes not satisfy a request forIRandomizer`;
  • the closed five-type factory switch and its exact NotSupportedException messages, including the
    unguarded null path that reports an empty type name;
  • the upper bound is exclusive everywhere — int, the string character bounds and the date day count
    all delegate to Random.Next, contradicting the inherited BaseRandomizer<TValue> documentation, which
    describes both bounds as inclusive;
  • BooleanRandomizer treating its range as an equality check rather than an ordering, so (true, false) is
    still a coin toss;
  • StringRandomizer's three overloads: the ranged one uses only the first character of each argument, the
    length-taking one lives on the concrete class, and its default '0'–'Z' bounds include the ASCII
    punctuation between the digits and the letters;
  • the date randomizers' day resolution — the time of day is copied from minValue, a sub-day range
    collapses silently to minValue, the top day is unreachable, and a reversed sub-day range returns the
    minimum without throwing while a reversed multi-day range throws;
  • no seeding and therefore no reproducibility, with a BaseRandomizer<T>-derived seeded implementation
    and a Guid randomizer as the extension-point examples;
  • threading (each randomizer owns a private unsynchronised Random, so AddTransient/AddScoped over
    AddSingleton), and a target-framework difference: on net48 two randomizers created within one clock
    tick produce identical sequences, which makes calling the factory inside a loop a real bug on .NET
    Framework consumers of the netstandard2.0 asset;
  • a worked staging-catalogue seeder that takes the interfaces rather than calling the factory.

Every code sample was compiled against the real Ploch.Common API (0 warnings, 0 errors) and executed; every
quoted exception message, distribution count and output value in the article is real program output. The
invariance of IRangedRandomizer<TValue> and the identical-sequence behaviour on net48 were both confirmed
by separate compile/run probes.

No changes to shipped library code — packages are unaffected.

#204 — Reflection namespace article for the documentation site

Type: documentation
Breaking changes: none

Summary

Added an authored article covering the whole of Ploch.Common.Reflection to the Articles section of
the documentation site. It is organised around the four jobs the namespace actually does — asking
questions about a Type, discovering implementations across assemblies, reading and writing members
by name, and walking or comparing object graphs — rather than around the type list.

Details

New page DocumentationSite/articles/reflection.md, wired into
DocumentationSite/articles/toc.yml.

It covers:

  • TypeExtensions — IsImplementing including its open-generic support, and the two behaviours
    that differ from IsAssignableFrom: a type never reports as implementing itself, and
    concreteOnly: true (the default everywhere else in the namespace) excludes structs and static
    classes as well as abstract classes; IsConcreteImplementation; IsSimpleType and how much wider
    than "primitive" it is — every value type qualifies, so DateTime, Guid, any user struct and any
    ValueTuple are treated as atomic values downstream; IsNullable, IsEnumerable; and
    GetReadableTypeName, kept consistent with the merged expressions article, plus its three
    boundaries (CLR names not C# keywords, no namespace or declaring type, and multi-dimensional arrays
    losing their rank — int[,] renders as Int32[]).
  • Assembly scanning — a comparison table of ImplementationTypes, AssemblyTypes and
    TypeLoader across scope, filters, execution model and, most importantly, ReflectionTypeLoadException
    behaviour. Only AssemblyTypes recovers partial results; the other two propagate. Demonstrated with
    a real assembly deployed without its dependency, showing the exact outcome of each of the four calls.
  • TypeLoader configuration — multiple base types are OR not AND; abstract/interface/struct types
    are excluded by default; the globs match Type.FullName and the assembly's simple name and are
    StringComparison.Ordinal, so a case-mismatched pattern silently returns nothing; results are a set;
    no filters means every type including compiler-generated ones; and LoadTypes(params Type[]) rejects
    an empty array.
  • Member access by name — PropertyHelpers with its named PropertyAccessException family and
    their exact messages; the two leaks out of that family (InvalidCastException from the typed
    overload, IndexOutOfRangeException when an index is passed for a non-indexed property); the
    SetPropertyValue/GetPropertyValue asymmetry, where writes resolve against typeof(T) and so
    fail through a base-typed or object-typed variable while reads succeed; GetPropertyValues
    throwing on write-only properties; the static-member helpers, including that
    TryGetStaticPropertyValue is public-only and that GetStaticFieldValues<TType> cannot target a
    static class at all (CS0718); and ObjectReflectionExtensions/MemberInfoExtensions.
  • Object graphs, with the caveats stated rather than buried — ExecuteOnProperties visits
    non-enumerable property values twice, walks strings character by character, and does not terminate
    on cyclic graphs because its visited set is populated but never consulted;
    ByValueObjectComparator compares collections by their Count and Capacity rather than their
    contents (so two lists with different items compare equal), which also makes
    ByValueObjectComparer<T> violate the IEqualityComparer<T> contract for collection-bearing types
    since ObjectHashCodeBuilder does hash elements, and it overflows the stack on cyclic graphs;
    ObjectHashCodeBuilder is the sturdy one — cycle-safe and resilient to throwing getters, but not
    stable across processes.
  • A cost-and-caching section and a quick-reference table.

Every code sample was compiled against the real Ploch.Common API, and every behavioural claim —
return values, exception types and messages, visit order, glob case-sensitivity, deferred execution,
ReflectionTypeLoadException recovery, hash stability — was verified by executing it and quoting the
real output.

No changes to shipped library code — packages are unaffected.

Ploch.TestingSupport.XUnit3 family: the packages are now produced and can be published

Fixed three shipping libraries evaluating to IsPackable=false, so no .nupkg was ever built for
them.
Ploch.TestingSupport.XUnit3, Ploch.TestingSupport.XUnit3.AutoMoq and
Ploch.TestingSupport.XUnit3.Dependencies are documented for consumption from other repositories
(dotnet add package …), but a Release build of the solution produced no package for any of them,
so none could reach NuGet.org or GitHub Packages. A Release build now produces all three.

Root cause

Nothing in this repository set IsPackable=false for these projects. Directory.Build.props
classifies a project as a test project by name ($(MSBuildProjectName.EndsWith('Tests'))), and none
of the three matches, so all three took the non-test branch — which set GeneratePackageOnBuild=true
and GenerateDocumentationFile=true but left IsPackable empty. That handed the decision to the
SDK, where NuGet.Build.Tasks.Pack.targets applies its own default:

<IsPackable Condition="'$(IsPackable)'=='' AND ('$(IsTestProject)'=='true' OR '$(IsTestingPlatformApplication)'=='true')">false</IsPackable>

Any library that references xUnit v3 inherits IsTestingPlatformApplication=true from the
Microsoft.Testing.Platform build props — and these libraries must reference xUnit v3, because
providing xUnit v3 support is what they are for. The SDK therefore silently classified three shipping
packages as test applications. The repository's own name heuristic was never the trigger; the missing
IsPackable was.

Directory.Build.props now states IsPackable=true on the non-test branch for projects under
src/
, so the intent is explicit and the SDK default no longer applies to a shipping library. The
src/ scope matters: $(IsTestProject) is only a project-name heuristic, so a test project not
named *Tests (the projects under tests/TestAssemblies/ are the existing examples) would otherwise
have been made unconditionally packable and could be published by accident. Outside src/ the
property is still left empty, so the SDK default above continues to switch packing off for anything
that is a test project or a testing-platform application.

Test projects are unaffected: they take the $(IsTestProject) branch, which already sets
IsPackable=false. A project that opts out in its own .csproj also still wins, because
Directory.Build.props is imported first.

Ploch.TestingSupport.XUnit3.Dependencies additionally excludes the testhost.dll / testhost.exe
content items that Microsoft.NET.Test.Sdk injects, which would otherwise be packed — raising NU5100
and shipping a duplicate test host that consumers already receive through the
Microsoft.NET.Test.Sdk dependency the meta-package declares. The exclusion is hooked on the public
$(BeforePack) extension point (NuGet expands PackDependsOn to
$(BeforePack);_IntermediatePack;GenerateNuspec;$(AfterPack)), matches only content injected by
another build file, and is backed by a VerifyTestHostIsNotPacked guard that fails the pack outright
if a test host file ever reaches the package again.

Compatibility

Additive. No public API, package identifier or behavioural contract changed, and no package that was
previously produced is affected. Three packages that could not previously be published now can be;
the release workflow's package filter (find . -name '*.nupkg' -not -path './tests/*') already
includes them, so no workflow change is required.

Refs: #280

Branch rename master → main, phase 3: drop the dual-accept fallbacks (#282)

Completes the three-phase rename started in #284 (phase 1). The GitHub rename itself was
performed on 2026-09-02; this change removes the master fallbacks that phase 1 added so the
repository refers to a single default branch.

Build and release behaviour

  • build-dotnet.yml — push and pull_request branch filters narrowed from
    [ "master", "main" ] to [ "main" ]. The two GitHub Packages publish guards narrowed from
    (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') to
    github.ref == 'refs/heads/main', and the two GH_PACKAGES_TOKEN error messages updated.
  • publish-docs.yml — push filter reduced to main; header comment updated.
  • qodana_code_quality.yml — push filter reduced to main (plus releases/*, unchanged).
  • release.yml — job guard narrowed to github.ref == 'refs/heads/main'. The version-bump
    push already used HEAD:${GITHUB_REF_NAME} and needed no change.
  • version.json — publicReleaseRefSpec drops ^refs/heads/master$, leaving
    ^refs/heads/main$ and the v*.*.* tag pattern. Builds from main continue to be public
    releases; nothing about main matching changed.

Deliberately not changed

  • The reserved-branch safety guards in publish-docs.yml and docs-preview-cleanup.yml
    (case "$PREVIEW_BRANCH" in master|main|gh-pages)) keep master in the blocklist. That list
    prevents the preview jobs from force-writing a protected branch name; it is not a trigger.
    Removing master would weaken the guard should the nam