Visitar URL original
Upgrade references through one Core module that both front ends call by slang25 · Pull Request #852 · dotnet-outdated/dotnet-outdated · GitHub
Skip to content

Upgrade references through one Core module that both front ends call - #852

Merged
slang25 merged 4 commits into
masterfrom
reference-upgrader
Nov 13, 2026
Merged

slang25 merged 4 commits into
masterfrom
reference-upgrader

Conversation

@slang25

@slang25 slang25 commented Nov 12, 2026

Copy link
Copy Markdown
Member

The CLI and MCP each orchestrated an upgrade themselves, and they had drifted apart. Upgrade also re-located each reference's version once per target framework, using two full MSBuild evaluations each time. This PR moves upgrading behind one Core interface, IReferenceUpgrader, which takes a reference and every project that declares it.

What changes

Commit 1: one evaluation per project. IProjectEvaluator.GetPackageVersions replaces GetVersionVariables and GetGlobalPackageReferenceFile. It returns the item that sets each package's version: its file, its expression, its evaluated range and the version variable behind it. A literal location now carries its declaration, so the --no-restore central-package update writes the file the evaluator found. It no longer searches each Directory.Packages.props above the project for the package name as text.

Commit 2: IReferenceUpgrader replaces IDotNetPackageService. The module now owns three rules the CLI used to apply around each call:

  • Range. A declared upper bound that the new version still satisfies is kept. It is read from what is written at the version location, not from analysis.
  • Remove before add. A package is removed before being added back in a project that isn't SDK-style. This is now checked through IFileSystem.
  • Kind inference when the kind is omitted.

Each project's version location is found once per upgrade. A location shared by several projects is written once, and each project is restored once.

The result is one outcome per project: upgraded, already current, or failed. Each outcome carries its own warnings, and one project failing doesn't stop the others. UpgradeVersionRange, IsProjectSdkStyle, RemovePackage and IWarningReporter are removed.

I planned three commits, but swapping the interface and switching the front ends had to land together to keep the build green.

Behaviour changes

  • MCP update_package applies the same rules as the CLI.
    • It keeps a declared upper bound: [6.0.13, 7.0.0) → [6.0.36, 7.0.0). It used to write a bare 6.0.36.
    • It removes a package before adding it back in projects that aren't SDK-style.
    • It returns warnings in the tool result.
    • It says when the package is already at the requested version.
  • A transitive package pinned by a central PackageVersion range keeps that range's upper bound. It used to be overwritten, because the policy ignored declared ranges for transitive packages.
  • A version location that already holds the version is reported as already current and not restored. One written by an earlier upgrade in the same run still restores each project that hasn't been restored since.
  • The CLI makes one upgrade call per package across all its projects and target frameworks. It prints one line per project instead of one per (project, target framework).
  • An unreadable project file no longer triggers remove-before-add. The upgrade reports its own error instead. A file that isn't valid XML is still treated as not SDK-style.
  • Core's public API. IDotNetPackageService (shipped) and IWarningReporter (unshipped) are removed. IReferenceUpgrader, ReferenceUpgrade, UpgradeProject, ProjectUpgradeResult and UpgradeOutcome are added.

Tests

  • DotNetPackageServiceTests is now ReferenceUpgraderTests. New tests cover:
    • a shared location written once across projects in one call;
    • an already-current location;
    • one project's failure not stopping the others.
  • The UpgradeVersionRange, VersionLocator and SDK-style cases now go through IReferenceUpgrader against MockFileSystem.
  • UpgradePackagesTests covers only prompting and console output, using a fake upgrader.
  • McpServerTests checks that update_package keeps a declared upper bound, using a real upgrader.

dotnet test passes on net8.0, net9.0 and net11.0 (1,137 / 1,116 / 1,116 passed, 1 pre-existing skip each).

🤖 Generated with Claude Code

slang25 and others added 4 commits November 12, 2026 11:02
Replace IProjectEvaluator's GetVersionVariables and GetGlobalPackageReferenceFile, which each ran a full static
evaluation, with GetPackageVersions: the item that sets each package's version, the file it is written in, its
evaluated range and the version variable behind it. A literal location now carries its declaration, so the
--no-restore central package update writes the file the evaluator found instead of searching each
Directory.Packages.props above the project for the package name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IReferenceUpgrader replaces IDotNetPackageService. It takes a reference's name, optional kind, target
version and every project that declares it, and owns what the CLI used to decide around each call:

- The range to write: a declared upper bound that the new version still satisfies is kept, read from
  what is written at the version location rather than from analysis, so MCP's update_package keeps
  it too (it used to write a bare version).
- Removing a package before adding it back in a project that isn't SDK-style, now checked through
  IFileSystem and applied to MCP as well.
- Inferring the kind when it's omitted.

Each project's version location is found once per upgrade. A location shared by several projects
is written once and restores each project once; one that already holds the version is reported as
already current and not restored. Each project gets its own outcome and warnings, and one project's
failure doesn't stop the others. Warnings come back in the result instead of through
IWarningReporter, which is removed.

UpgradeVersionRange, IsProjectSdkStyle and RemovePackage go, and their tests are rewritten to go
through the new interface, as are the version locator's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four regressions from moving upgrade behind IReferenceUpgrader:

- A project listed once per target framework ran `dotnet add package -f` for the first one only, and
  -f changes one target framework's reference. A reference declared per target framework, or a
  transitive package being promoted, was left at the old version for the others while the project
  was reported as upgraded. It now runs for each target framework, stopping at the first failure;
  a location written directly is still written once.
- A file-based app's literal `#:package Id@[1.0, 2.0)` directive lost its upper bound, because its
  location carried no declared range. The range is now read from the directive, including the
  quoted form `dotnet add package` writes when it holds a space.
- A project that isn't SDK-style lost a declared upper bound, because the package was removed before
  its range was read. The range is now read first.
- A version variable updated after `dotnet add package` wasn't recorded as written, so a project
  sharing it through a direct write was reported as already current and not restored.

The version location is found again for each further target framework that goes through
`dotnet add package`, since the previous run may have changed it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Two regressions from moving upgrade behind IReferenceUpgrader:

- A package declared per target framework (item groups conditioned on $(TargetFramework)) was given
  the range of the last one declared, whichever target framework was being upgraded, because the
  static evaluation can't tell which item applies. An upper bound was dropped, or another target
  framework's was written. The items are now evaluated again with $(TargetFramework) set to the
  target framework being upgraded, when the project doesn't set it itself; a package that target
  framework has no item for is still found where another declares it.
- A version location written directly, changed back, and written to the same version again wasn't
  restored, because the project had already been restored at that version. An upgrader outlives one
  upgrade in the MCP server. Each location now records the value last written and the projects
  restored since, and writing it forgets those restores. A location put back by hand to a version
  this upgrader wrote earlier is reported as already current.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@slang25
slang25 merged commit 72408dd into 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.

1 participant