Skip to content

Fix Spectre markup crashes and anchor mapping paths to projectPath - #197

Merged
davidkallesen merged 6 commits into
mainfrom
fix/escape-package-version-in-skip-log
Sep 9, 2026
Merged

davidkallesen merged 6 commits into
mainfrom
fix/escape-package-version-in-skip-log

Conversation

@davidkallesen

Copy link
Copy Markdown
Collaborator

Summary

  • Fix crash when a project pins a NuGet version range
  • Sweep the same markup-escaping defect across the codebase
  • Fix --projectPath being ignored for mapping paths
  • Bump NuGet dependencies

Changes

🐛 Fixes

  • Escape package version in the "not comparable" skip log
  • Prevent range pins ([2.9.0,3.0.0)) aborting a run
  • Prevent exact pins ([2.1.4]) failing the same way
  • Escape exception text in 15 unescaped log messages
  • Escape CLI-supplied provider names echoed back on error
  • Escape organization and repository names in props logs
  • Anchor relative mapping paths to --projectPath
  • Stop UNC mapping paths being retargeted into the project
  • Resolve default discovered folder names against the project

💥 Breaking Changes

  • Mapping paths no longer resolve against the current directory

♻️ Refactoring

  • Replace the CLI-folder guard with IsPathFullyQualified
  • Use Path.DirectorySeparatorChar instead of hardcoded \

🔧 Configuration

  • Add Spectre.Console to Atc.CodingRules, pinned to 0.57.2
  • Add Spectre.Console to Atc.CodingRules.AnalyzerProviders

📦 Dependencies

  • Update NuGet package versions

Breaking Changes

  • Relative mapping paths in the options file now resolve against
    --projectPath rather than the current working directory.
  • Previously they resolved correctly only when the process ran from
    inside a folder named Atc.CodingRules.Updater.CLI.
  • Running cd <repo> && atc-coding-rules-updater . is unaffected,
    since there the current directory already is the project path.
  • Any workflow relying on the old cwd-relative behaviour, such as
    invoking with -p <repo> from a different directory, will now
    write to the target repo instead of the current one.

David Kallesen added 6 commits September 9, 2026 11:04
A NuGet version range ("[2.9.0,3.0.0)") or an exact pin ("[2.1.4]") starts with
'[', which the console sink reads as an opening markup tag. The version was
interpolated unescaped, so the log call itself threw and aborted the run for
that area with "An error occurred while writing to logger(s). (Encountered
malformed markup tag at position 81.)".

Only the version needed escaping; a package id cannot contain '['.

The existing DirectoryBuildPropsHelperPackageVersionTests already covers this
method, but every version it uses is bracket-free and it never renders the
message, so the path was green throughout. The markup-safety assertion lives in
its own class rather than as extra cases there: that class asserts the message
contains the raw version, which escaping necessarily breaks, so folding the two
together would mean loosening a check that is currently exact.
Same defect class as the package-version skip log: log messages are rendered as
Spectre markup, so a '[' in interpolated text is read as an opening tag and the
log call throws. Sweeps the 14 sites that interpolated exception text unescaped;
the four that already used Markup.Escape are unchanged and were the precedent.

These are not theoretical. AtcApiNugetClientHelper's System.Text.Json failures
carry paths like "$.items[0]", and GetMessage() concatenates inner exceptions,
so that site has a live path to "Could not find color or style '0'". File paths
and MSBuild diagnostics reach the ProjectHelper sites the same way.

Atc.CodingRules gains a direct Spectre.Console reference for this. It emits no
markup of its own, but it logs into the same markup-rendered sink as everything
else, so it was already bound by that contract implicitly - the dependency only
makes it explicit. Pinned to 0.57.2, the version already resolving there
transitively via Atc.Console.Spectre, so nothing new reaches the output graph.

No tests: these are catch-block diagnostics with no seam to trigger, and a test
would end up asserting Markup.Escape's own behaviour. The regression test for
the reproducible crash rides with the previous commit.
…directory

Relative mapping paths were only resolved against the project when the process
current directory happened to sit inside a folder named
"Atc.CodingRules.Updater.CLI":

    var di = new DirectoryInfo(orgPath);
    if (di.FullName.Contains("Atc.CodingRules.Updater.CLI", ...))

DirectoryInfo resolves a relative path against the current directory, so that
guard reads as leftover from running the tool out of its own build output.
Anywhere else it is false, the path is returned unresolved, and ProjectHelper
later turns it into a DirectoryInfo - against the current directory again.

The effect is that "atc-coding-rules-updater run -p <repo>" from any directory
other than the repo itself writes .editorconfig and Directory.Build.props into
<cwd>/sample, <cwd>/src and <cwd>/test, and bumps NuGet versions there, while
reporting the target repo's areas. Reproduced from an empty directory: sample,
src and test were reported as "would create" while the target repo already had
them; after the fix all three report "nothing to update".

Three separate shapes were wrong, and the new tests cover each:

  sample, ./sample, src/nested   left unresolved -> resolved against cwd
  \server\share\src             leading separator stripped, then combined onto
                                 the project path, silently retargeting a UNC
                                 share into the project tree
  \sample                        already correct, and still is

The guard is IsPathFullyQualified rather than IsPathRooted on purpose: on
Windows IsPathRooted also accepts the drive-relative "\sample", which is one of
the forms that does need anchoring.

CreateDefaultOptions is the second half. It discovers bare folder names under
the project path and never called ResolvePaths, so the no-options-file run was
broken the same way regardless of the guard above.

The common invocation - cd into the repo, then run - is unaffected, because
there the current directory already is the project path. Verified: identical
output before and after.
Two log messages interpolate values that come straight from the command line, so
a '[' in either is read as an opening markup tag and throws out of the log call:

  --includeProviders / --excludeProviders   echoed back when a name is unknown
  --organizationName / --repositoryName     echoed back when written into the
                                            props file

Values a user types are the likeliest place for a stray bracket to arrive, and
the failure lands on the path that was already reporting a mistake.

Stopping here rather than escaping every interpolation. Package ids, MSBuild
property names and elapsed times cannot carry a bracket. File paths and feed
version strings can in principle, but escaping every path in the codebase is a
large diff against a near-zero probability. HttpClientHelper's "[link={url}]"
is left alone deliberately: that is intentional markup with an interpolated tag
attribute, where a naive escape would break the link rather than protect it.
CI runs ubuntu, macos and windows. The tests added with the path-anchoring fix
were written against Windows path semantics and failed all five non-UNC cases on
the Unix legs.

Two distinct causes:

The project path was built as Path.Combine("D:", "Code", "MyRepo"). On Unix that
is not rooted, so DirectoryInfo.FullName resolved it against the current
directory and every expectation drifted. It is now built from Path.GetTempPath(),
which is absolute everywhere. The absolute-path case had the same problem:
"D:\Elsewhere\test" is a relative path on Unix, so it was anchored rather than
left alone.

Two inputs are genuinely platform-divergent rather than badly written, and are
now separate tests that skip off Windows:

  /sample                on Windows drive-relative, so it means "project root";
                         on Unix an absolute path, correctly left alone
  \server\share\src     a UNC share only exists on Windows; the same string is
                         an ordinary relative path elsewhere

Resolution itself now accepts both separators on every platform and normalizes
to the local one. An options file is checked in and shared, so a path written on
Windows as "src\nested" has to keep working for someone running the tool on
Linux or macOS - previously only "/" was normalized, leaving "src\nested" as a
single literal filename there.
@davidkallesen
davidkallesen merged commit e33bbdb into main Sep 9, 2026
15 of 18 checks passed
@davidkallesen
davidkallesen deleted the fix/escape-package-version-in-skip-log branch September 9, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant