Skip to content

Verified CPM migration and dependency analysis - #3

Merged
TarasKovalenko merged 3 commits into
mainfrom
feat/v2-migration-toolkit
Aug 24, 2026
Merged

Verified CPM migration and dependency analysis#3
TarasKovalenko merged 3 commits into
mainfrom
feat/v2-migration-toolkit

Conversation

@TarasKovalenko

Copy link
Copy Markdown
Owner

Writing Directory.Packages.props was the whole tool. This turns that into the first step of a workflow that can prove the migration was safe and keep the dependencies healthy afterwards.

Why

Two things bothered me about the old behaviour.

The first is that a migration was unverifiable. We rewrote every project file and told the user it worked. Nobody could tell whether NuGet still resolved the same packages afterwards, and that is the only question that actually matters — a migration that changes what gets restored is not a refactor, it is a behaviour change wearing a refactor's clothes.

The second is that XDocument.ToString() reformatted every file it touched. A one-line version removal produced a diff covering the whole project file, comments moved, and CRLF repos got LF back. That is enough friction to make people not run the tool.

What changed

The migration is provable. verify restores and snapshots the full resolved package graph, migrates, restores and snapshots again, then diffs the two. If any package resolves differently it reports the drift and rolls back. Pure additions pass by default, because that is what enabling transitive pinning legitimately does; --strict fails on those too. Exit code 9 means drift.

I tested it against a two-project solution where the library was on Newtonsoft.Json 13.0.1 and the app on 13.0.3. Unifying on 13.0.3 genuinely changes what the library resolves, and verify caught it and rolled back — which is exactly the case that would otherwise ship silently.

Edits are format-preserving. Every file is decoded with its encoding, BOM and line-ending style recorded, and re-encoded through the same record. XML is parsed with LoadOptions.PreserveWhitespace and edited surgically. A CRLF UTF-8-with-BOM project comes back byte-identical apart from the versions that were removed. --encoding and --linewrap are there when you want normalisation instead.

Planning never writes. PlanAsync returns the original and new content of every file. Dry-run prints it, --diff diffs it, apply writes it. There is no second code path that could disagree with the preview, which is what makes --dry-run trustworthy rather than approximate.

Property hoisting requires unanimity. A property only moves into Directory.Build.props when every project declaring it uses the same value, and identity properties like AssemblyName are never candidates. Conditioned PropertyGroups are left alone entirely. Anything short of unanimity changes the evaluated build for at least one project.

Analysis, then maintenance. 16 rules covering vulnerabilities, deprecation, licence risk, version drift, floating versions, transitive conflicts, duplicate and orphaned entries. Severity overrides, package ignores and baselines so CI can gate on new findings only. Auto-fix for the mechanical ones. update runs the tests and rolls back on failure, or bisects for the largest subset that still passes and tells you which packages it held back.

Command surface

migrate build all revert analyze update verify tree status doctor init explain batch backups {list,restore,prune} completions

packages, convert and packages-enhanced alias migrate. Exit codes are 0–9 so scripts can branch on the outcome.

Safety

Every mutating command backs up what it is about to touch first, including recording files it creates so a restore removes them again. backups restore undoes anything the tool did.

Build and CI

Multi-targets net8.0, net9.0 and net10.0. global.json now rolls forward to the latest installed major rather than pinning 9.0.0, which does not resolve on a machine with only the .NET 10 SDK.

CI builds on Linux, Windows and macOS. A self-check job runs the tool against this repository and uploads SARIF, so a regression in the analyzers surfaces as an annotation on the PR that caused it. release.yml publishes to NuGet on a v* tag or on demand.

release.yml needs NUGET_API_KEY in repository secrets before the first tag.

Notes for review

  • The CLI moved from System.CommandLine (which was still on a 2022 beta) to Spectre.Console.Cli, which was already a dependency. -d stays --directory; dry-run is -n / --dry-run.
  • .centralconfig-backups is in the default exclusion list. It has to be — without it the backup tree gets scanned as part of the workspace, and --fix will happily rewrite the backups it is supposed to restore from.
  • batch prefers solutions and only falls back to project directories when no solution exists anywhere under the root, so a mixed monorepo will skip solution-less projects. Worth revisiting if anyone hits it.
  • PropertyDrift findings are deliberately not auto-fixable. Hoisting also has to delete the property from every project, and doing half of that outside the build command would be worse than reporting it.

334 tests, no warnings.

Writing Directory.Packages.props was the whole tool. It is now the first
step of a workflow that can prove the migration was safe and keep the
dependencies healthy afterwards.

Core

- Encoding-aware IO. Every project file is decoded with its encoding, BOM
  and line-ending style recorded, and re-encoded through the same record on
  write, so a CRLF UTF-8-with-BOM file comes back byte-identical apart from
  what actually changed. --encoding and --linewrap override when you want
  normalisation instead.
- XML is edited surgically with LoadOptions.PreserveWhitespace rather than
  re-serialised, so comments, indentation and attribute order survive.
- Discovery understands .sln, .slnx and .slnf, and C#, F# and VB projects,
  with regex directory exclusion on top of the usual bin/obj skips.
- Migration planning happens entirely in memory and returns a plan holding
  the original and new content of every file. Dry-run prints it, --diff
  diffs it, apply writes it. There is no second code path that could
  disagree with the preview.
- Backups before every write, including a record of files the operation
  created so a restore removes them again. List, restore, retention, prune.
- Verification restores and snapshots the resolved package graph before and
  after the migration and diffs the two, rolling back when anything moved.
  This is the difference between "the files look right" and "the build
  produces the same binaries".
- Analysis engine with 16 rules, per-rule severity overrides, package
  ignores, baselines and a 0-100 health score normalised for workspace size.
  Auto-fixes are planned as one batch and backed up like any other write.
- Package updates run the test suite and roll back on failure. --bisect
  keeps the largest subset that still passes and names what it held back.
- Feed lookups go through every source in nuget.config, so private feeds
  work, and cover versions, deprecation, licence and advisories.

Property hoisting

A property only moves into Directory.Build.props when every project that
declares it uses the same value, and identity properties like AssemblyName
are never touched. Requiring unanimity is what makes the hoist
behaviour-preserving; conditioned PropertyGroups are left alone entirely.

CLI

Rebuilt on Spectre.Console.Cli. migrate, build, all, revert, analyze,
update, verify, tree, status, doctor, init, explain, batch, backups and
completions, with exit codes 0-9 so CI can branch on the outcome. packages,
convert and packages-enhanced alias migrate.

Reports render as terminal output, JSON, SARIF 2.1.0, Markdown or CSV.

Build

Multi-targets net8.0, net9.0 and net10.0. global.json now rolls forward to
the latest installed major instead of pinning 9.0.0, which no longer
resolves on a machine with only the .NET 10 SDK.

334 tests.
…hangelog

The README described three commands and an "enhanced" mode. It now covers
the full command surface, the configuration file, the exit codes and the
CI recipes people actually need.

- docs/migration-playbook.md walks the route from inline versions to a
  CI-gated workspace, with a safe stopping point at every step.
- docs/architecture.md explains the two invariants worth knowing (planning
  never writes, format is data) and how to add a rule or a command.
- schemas/centralconfig.schema.json backs the $schema reference that init
  writes, so editors complete and validate the config file.
- QUICK_START_ENHANCED.md and VERSION_MANAGEMENT.md described behaviour
  that no longer exists; their useful parts moved into the docs above.
- build.ps1 now also tests and packs, and build.sh gives the same thing to
  everyone not on Windows.
…workflow

- The build job runs on Linux, Windows and macOS with the 8, 9 and 10 SDKs
  installed, since the tool multi-targets all three.
- A self-check job runs the tool against its own repository and uploads
  SARIF, so a regression in the analyzers shows up as an annotation on the
  pull request that caused it.
- release.yml publishes to NuGet on a v* tag or on demand, and creates the
  GitHub release from the tag. --skip-duplicate keeps a re-run from failing
  when a version is already live.
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@TarasKovalenko
TarasKovalenko merged commit 55a473a into main Aug 24, 2026
6 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.

2 participants