Visitar URL original
Upgrade packages pinned in Directory.Build.props + edit MSBuild files with an XML parser instead of regex by timabell · Pull Request #856 · dotnet-outdated/dotnet-outdated · GitHub
Skip to content

Upgrade packages pinned in Directory.Build.props + edit MSBuild files with an XML parser instead of regex - #856

Merged
slang25 merged 16 commits into
dotnet-outdated:masterfrom
timabell:upgrade-packages-in-imported-props
Nov 13, 2026
Merged

slang25 merged 16 commits into
dotnet-outdated:masterfrom
timabell:upgrade-packages-in-imported-props

Conversation

@timabell

@timabell timabell commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

dotnet outdated -u can't upgrade a package whose version is a literal PackageReference in Directory.Build.props: dotnet add package refuses with "Cannot edit items in imported files". This PR writes the new version into that file directly.

It also replaces the regular expressions dotnet-outdated used to find and edit versions, properties and SDKs in .csproj and .props files with an XML parser (XmlReader). Parsing / updating the xml files with regex is always going to have edge cases and bugs, so in order to fix this class of bugs for good I have completely replaced all of the regex handling of xml with a full xml parser. - See the legendary stackoverflow post: https://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags/1732454#1732454

The tests show valid MSBuild the regexes got wrong: <Version> child elements, entity references such as Include="A&#46;B", a </PropertyGroup> inside a comment, and commented-out tags inside a value.

Each fix comes after a commit of characterization tests that assert the old behaviour. The fix then flips those assertions, so every commit passes and the diffs show the before and after.

All tests pass on every commit on this PR branch to make it clearer that we haven't broken anything, and to avoid breaking git-bisect.

Fixes: #301
Refs: #561
Refs: #602
Refs: #316

Claude code has done significant heavy lifting on this, however I have been reading the diffs, ensuring test coverage proves it's behaving before/after and is sensible. It is quite a big diff so I can't promise there isn't the odd thing in here that could be improved, however I think the move from regex to xml and the provable bugfix for a particularly problematic use case of being unable to update versions pinned in the props file is probably worth it even if it requires follow-up work. I have had repeated issues with this showing up in real client projects so this would be great to have resolved.

I noticed some "nullable" warnings, but have split that out to #855 as it's unrelated to this.

If you wouldn't mind adding mise support that would make it easier for me to work on this repo as that's what I use to manage dotnet installs, I've propsed the config for mise in #854

Thanks so much for your efforts on dotnet-outdated, it's been so useful. Do let me know if you want anything changed about this. Feel free to message me directly (contact info in gh profile). Maybe I'll see you at a dotnet meetup, I'm not too far from you.

timabell and others added 12 commits November 13, 2026 00:01
A later commit swaps the regular expressions that find a package's
version for an XML parser. A project file can contain text that looks
like a package reference but isn't one, and an upgrade must leave it
untouched:

- A `PackageReference` someone has commented out, such as an old package
  kept for reference.
- Package markup inside a CDATA section, which XML treats as plain text
  rather than elements.
- Package markup written with `&lt;` and `&gt;` inside a property value,
  which is also just text.

The regexes already skip all three. These tests pass against them now,
and must still pass once the parser replaces them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue dotnet-outdated#561 reported that `dotnet outdated -u -t` on a solution using
central package management (CPM) added transitive packages to each
csproj *with* a `Version` attribute, which CPM rejects at restore
(NU1008). Nobody managed to reproduce it, so before changing anything
these tests pin down what actually happens today.

They run the real `dotnet` against the repo's local fake package feed,
upgrade a transitive dependency, and check the project still restores:

- Under CPM, the transitive package is added to the csproj without a
  version and its version goes into Directory.Packages.props. This
  already works, with and without `--no-restore`, so dotnet-outdated#561 appears fixed
  upstream; the test stops it coming back.
- When the parent package is referenced from Directory.Build.props, the
  transitive package is added to the project itself and the props file
  is left alone. This already works too.

Refs: dotnet-outdated#561

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The next commits stop running `dotnet add package` for packages whose
version is set in Directory.Build.props, and write the version there
directly instead. That must not catch packages `dotnet add package`
handles fine, so pin down today's behaviour first, with a shared
Directory.Build.props in place:

- A package the project declares itself, with its own `Include` and
  `Version`, is upgraded through `dotnet add package`, and the props
  file is left alone.
- A package declared nowhere (for example a transitive dependency being
  added) also goes through `dotnet add package`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ence` updates

The `GlobalPackageReference` update writes a literal version straight
into the file that declares it, keeping the file's encoding and
restoring each project once when several share the file. The next commit
needs exactly that for `PackageReference` versions in
Directory.Build.props, so split the writing out into
`UpdateLiteralVersionInFile`, which takes the element name, rather than
copying it.

No behaviour changes: `GlobalPackageReference` updates call the new
method with `"GlobalPackageReference"`, and the error message names the
element as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…graded

dotnet-outdated#301 asks for `dotnet outdated -u` to upgrade a package whose version is
set in Directory.Build.props. As of upstream master 4d722c8 it can't: it
runs `dotnet add package` for each package, which refuses with "Cannot
edit items in imported files", so the upgrade fails and both files are
left as they were.

Pin that failure down with an end-to-end characterization test against
the real `dotnet`, with and without `--no-restore`.

This new test project pins `DotNetOutdated.Fake.Logging` in its
Directory.Build.props, along with Logging's transitive dependency
`DotNetOutdated.Fake.DependencyInjection` at a version above the one
Logging asks for.

The fix in the next commit flips the test's assertions, so its diff
shows the behaviour before and after. Asserting the bug, rather than
adding a test that fails until the fix lands, avoids a commit with a
failing test, so every commit passes and `git bisect` keeps working. The
test checks the exit code and that the files are unchanged, not the
error text, which is localised and differs between SDKs.

Refs: dotnet-outdated#301

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ops (Issue dotnet-outdated#301)

A Directory.Build.props can set package versions once for every .csproj
in the folders below it, for example to pin a vulnerable transitive
dependency. dotnet-outdated already upgrades a version set in one
through a `$(Property)`, but not a literal version on a
`PackageReference`, which is what dotnet-outdated#301 asks for: "if a
Directory.Build.props file existed and provided a package version".

For those, `dotnet outdated -u` runs `dotnet add package`, which refuses
with "Cannot edit items in imported files", and the upgrade fails. The
same happens when a csproj overrides an imported version with its own
`<PackageReference Update="..." Version="...">`. Since c3fbf21 (dotnet-outdated#790)
that error is shown rather than swallowed. It comes from NuGet's
`dotnet add package` command:
https://github.com/NuGet/NuGet.Client/blob/7e935f9c72b287adc13b9f42fd7b3bdcaa1500e7/src/NuGet.Core/NuGet.CommandLine.XPlat/Strings.Designer.cs#L495-L502
https://github.com/NuGet/NuGet.Client/blob/7e935f9c72b287adc13b9f42fd7b3bdcaa1500e7/src/NuGet.Core/NuGet.CommandLine.XPlat/Strings.resx#L334-L340

In theory `dotnet add` should be able to make these edits itself; these
NuGet issues cover the area:
NuGet/Home#14317
NuGet/Home#13218
NuGet/Home#12156

dotnet-outdated#301 notes that NuKeeper handled this. NuKeeper is now archived in
favour of Dependabot and dotnet-outdated, so dotnet-outdated needs to:
NuKeeperDotNet/NuKeeper#1155

Where `dotnet add` would refuse, write the new version directly into the
file that declares it, then restore:

- The MSBuild evaluator already works out which file sets each package's
  version. It now also reports when that is a literal `PackageReference`
  version that `dotnet add` can't edit: one in an imported file, or an
  `Update` item.
- `VersionLocator` turns that into a new location type,
  `PackageReferenceVersionInFile`.
- `DotNetPackageService` writes the version there with the code split
  out of `GlobalPackageReference` updates two commits back: the file's
  encoding is kept, and a props file shared by several projects restores
  each project once.

Deciding this up front, rather than running `dotnet add` and reacting to
its failure, avoids parsing dotnet's error text, which is localised and
changes between SDKs.

The end-to-end test from the previous commit, which showed the upgrade
failing, now expects it to succeed and the project to restore.

This commit decides *which file* gets the new version. How the version
text is found inside a file is the next commit's concern.

Fixes: dotnet-outdated#301

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The next commit replaces the regular expressions that find a package's
version with an XML parser. These are the valid MSBuild files the
regexes get wrong:

- A version written as a `<Version>` child element, which MSBuild
  accepts like any other item metadata, isn't found. A
  Directory.Build.props entry written that way can't be upgraded, and
  nor can a central package with `--no-restore`; upstream's
  `CpmProjectWithNoRestore_UnmatchedEntry_ReportsFailureAndLeavesFileUnchanged`
  test already shows the latter.
- A package id containing an XML entity reference (`Include="A&dotnet-outdated#46;B"`)
  doesn't match, because the regex compares the raw text.

Pin those failures down with characterization tests. The next commit
flips their assertions, so its diff shows the behaviour before and
after. Asserting the bugs, rather than adding tests that fail until the
fix lands, avoids a commit with failing tests, so every commit passes
and `git bisect` keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some upgrades write the new version into a file directly rather than
through `dotnet add package`: central package versions in
Directory.Packages.props, `GlobalPackageReference` items, and, since the
dotnet-outdated#301 fix, versions in Directory.Build.props. dotnet-outdated finds the
version to change in those files with regular expressions.

Regex can't reliably parse XML. dotnet-outdated#602 was one case: entries spread over
several lines weren't updated. dotnet-outdated#783 fixed that by making the regex
cleverer, but valid MSBuild still defeats it:

- A version written as a `<Version>` child element, which MSBuild
  accepts like any other item metadata, is never found. Upgrading a
  central package with `--no-restore` fails, and a Directory.Build.props
  entry can't be upgraded at all.
- A package id containing an XML entity reference (`Include="A&dotnet-outdated#46;B"`)
  doesn't match, because the regex compares the raw text.

Each such case would need another regex special case, and there will
always be another:
https://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags/1732454#1732454

Instead, find the elements with an XML parser (`XmlReader`), which can
correctly parse the `.props` xml files, and use its line and column
information to replace only the version text, so the rest of the file
stays byte-for-byte identical. (Loading and saving with `XDocument`
would reformat the whole file.) A file that isn't well-formed XML is
left unchanged and reported as not updated.

No existing library fits for editing .csproj and .props files:

- Microsoft.Build, the MSBuild API, is the "correct" editor and resolves
  import precedence, but needs Microsoft.Build.Locator plus a bound SDK
  toolset: a heavy, fragile dependency for a global tool and a departure
  from this project, which otherwise shells out to `dotnet` and reads
  NuGet libraries.
  https://www.nuget.org/packages/Microsoft.Build
  https://www.nuget.org/packages/Microsoft.Build.Locator
- FubuCsProjFile: dead since 2015, .NET Framework only.
  https://www.nuget.org/packages/FubuCsProjFile
- PackageReferenceEditor: unmaintained since 2021, and pulls in the
  ReactiveUI UI framework.
  https://www.nuget.org/packages/PackageReferenceEditor
  https://www.nuget.org/packages/ReactiveUI
- MvsSln: healthy, but depends on Microsoft.Build and is
  solution-oriented.
  https://www.nuget.org/packages/MvsSln
- SunamoCsproj: missing license text, unclear if it does what we need
  https://www.nuget.org/packages/SunamoCsproj

This covers every place that changes a version this way: central package
updates with `--no-restore`, putting back the Directory.Packages.props
layout after `dotnet add package`, `GlobalPackageReference` updates, and
the Directory.Build.props updates from the dotnet-outdated#301 fix. Restoring
`$(Variable)` references (`ReplacePackageVersion`) and editing
properties still use regex; the next two commits move them onto the XML
reader too.

The characterization tests from the previous commit now expect these
versions to be found and updated.

One upstream test asserted the old limitation: it used a `<Version>`
child element as its example of an entry that can't be updated. It now
uses an entry with no version at all.

Refs: dotnet-outdated#561
Refs: dotnet-outdated#301
Refs: dotnet-outdated#602
Refs: dotnet-outdated#316

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… regexes

Two kinds of edit still find elements with regular expressions:

- Restoring a `$(Variable)` reference after an upgrade
  (`ReplacePackageVersion`).
- Editing properties (`ReplacePropertyValueAt`, `InsertProperty`,
  `InsertPropertyBefore`).

These are the valid MSBuild files they get wrong:

- A `</PropertyGroup>` inside a comment ends the group early. The
  definition or anchor after it isn't found, and `InsertProperty` puts
  the new property inside the comment.
- A package id containing an XML entity reference (`Include="A&dotnet-outdated#46;B"`)
  doesn't match, because the regex compares the raw text.
- A commented-out closing tag inside a property's value, as in
  `<AVersion>1.0.0<!-- </AVersion> --></AVersion>`, makes the edit
  change the wrong text, so the upgrade is refused with a warning.
  Upstream's
  `KnownDefinition_EditWouldChangeOtherDefinitions_DoesNothing` test
  already shows this.

Pin those failures down with characterization tests. The next commit
flips their assertions, so its diff shows the behaviour before and
after. Asserting the bugs, rather than adding tests that fail until the
fix lands, avoids a commit with failing tests, so every commit passes
and `git bisect` keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit's tests show valid MSBuild that the remaining
regexes get wrong: restoring a `$(Variable)` reference
(`ReplacePackageVersion`) and editing properties
(`ReplacePropertyValueAt`, `InsertProperty`, `InsertPropertyBefore`).

Move them onto the XML reader that `ReplaceVersion` already uses:

- `PackageXml.Elements` reads the file once and reports each element's
  name, parent and attributes, and where its start tag, content, end tag
  and text nodes sit in the original text. `FindVersionSpans` now uses
  it too.
- Each edit finds its element in that list and replaces or inserts text
  at those offsets, so the rest of the file stays byte-for-byte
  identical, as before.
- A property definition is an element directly inside a
  `<PropertyGroup>`, the same rule `MSBuildEvaluator` uses when it
  records which definition to edit, so both count definitions the same
  way.
- With a comment or other markup inside a value, only the value's text
  node is replaced, keeping the comment. With more than one text node it
  isn't clear which is the value, so nothing is changed.

The characterization tests from the previous commit now expect the edits
to be made. Upstream's
`KnownDefinition_EditWouldChangeOtherDefinitions_DoesNothing` becomes
`KnownDefinition_CommentedOutTagsInTheValue_UpdatesOnlyTheValue`: the
edit no longer touches the comment, so the upgrade goes ahead.
`MSBuildVariableWriter` still checks each property edit against the
parsed XML before writing.

`ProjectSdkReference`, which updates `Sdk` attributes, still uses regex;
the next two commits move it onto the XML reader too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ProjectSdkReferenceHelper` finds a project's SDKs by reading the file
as XML (`GetDeclarations`), but updates their versions by finding the
tags with regular expressions (`TryUpdate`). The two disagree on valid
MSBuild:

- An entity reference in the SDK's name or version, such as
  `Name="X&dotnet-outdated#46;Sdk"` or `Version="1&dotnet-outdated#46;0&dotnet-outdated#46;0"`, is decoded by
  discovery, so the SDK is found and reported when a newer version
  exists. The update compares the raw text, finds nothing to change and
  returns false, and `DotNetPackageService.UpdateProjectSdk` then fails
  with "Failed to update the version of SDK".
- Only an `<Sdk>` element directly under `<Project>` declares an SDK,
  and discovery ignores one anywhere else, such as an `Sdk` item in an
  `ItemGroup`. The update rewrites that item's `Version` anyway.

These files are unlikely in practice. The tests are here to show that
the regexes have edge cases they don't handle, and that an XML parser
makes dotnet-outdated more robust against XML structured in ways it
doesn't expect. XML is not a regular language, so regular expressions
can't parse it.

Pin those failures down with characterization tests. The next commit
flips their assertions, so its diff shows the behaviour before and
after. Asserting the bugs, rather than adding tests that fail until the
fix lands, avoids a commit with failing tests, so every commit passes
and `git bisect` keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit's tests show `ProjectSdkReferenceHelper.TryUpdate`,
which finds SDK declarations with regular expressions, disagreeing with
`GetDeclarations`, which reads the file as XML.

Move `TryUpdate` onto `PackageXml.Elements`, finding declarations by the
same rules as `GetDeclarations`: the root `<Project>`'s `Sdk` attribute,
`<Sdk>` elements directly under it, and `<Import Sdk="...">` elements,
none of them inside `<ProjectExtensions>`. Names and versions are
compared after entity references are decoded, and only the version text
is replaced, so the rest of the file stays byte-for-byte identical. When
an entry in the root's `Sdk` attribute changes, the attribute is
rewritten from its decoded value, escaped again.

The characterization tests from the previous commit now expect an SDK
written with entity references to be updated, and an `Sdk` item in an
`ItemGroup` to be left alone.

With this, no XML in the files dotnet-outdated edits is parsed with
regex. The regexes left in `src` parse MSBuild expressions such as
`$(Name)`, `#:` directive lines in file-based apps, and generated shell
completion scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
slang25 and others added 4 commits November 13, 2026 12:02
…ps upgrade

Moves the PackageReference-in-an-imported-file location onto master's single
GetPackageVersions lookup (PackageVersionDeclaration gains IsUpdate) and its
write into ReferenceUpgrader, sharing the GlobalPackageReference literal writer.
Two test fixtures adjusted for master: an SDK-style project file, and an
unmatched central entry that the evaluator still sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…file

A Directory.Build.props can pin a package once per target framework. Writing
every PackageReference for the package there overwrote the other target
frameworks' pins. The evaluator now reports which of the file's items for the
package is in effect, only that item is written, and each target framework of
the upgrade writes its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root Sdk attribute was rewritten from its decoded value, which turned line
breaks into spaces and decoded entity references. Entries are still matched on
the decoded value, as MSBuild reads it, but each decoded character is mapped
back to the raw text so only the matching version's text is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@slang25
slang25 merged commit 084f9b1 into dotnet-outdated:master Nov 13, 2026
12 checks passed
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.

Directory.Build.props not supported

2 participants