CPMigrate 3.51.0

dotnet tool install --global CPMigrate --version 3.51.0
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local CPMigrate --version 3.51.0
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=CPMigrate&version=3.51.0
                    
nuke :add-package CPMigrate --version 3.51.0
                    

<div align="center">

<img src="https://raw.githubusercontent.com/georgepwall1991/CPMigrate/main/docs/images/logo.png" alt="CPMigrate logo" width="132" />

CPMigrate

Stop hand-editing .csproj files. Stop "update everything and pray."

One binary migrates you to Central Package Management, scores the rot, and ships updates that refuse to break your build.

<img src="https://raw.githubusercontent.com/georgepwall1991/CPMigrate/main/docs/images/banner.png" alt="CPMigrate — migrate, analyze, and update NuGet packages" width="100%" />

NuGet .NET Downloads License

Install · 30-second path · See it work · CLI reference · Docs site ↗

</div>

You inherit a 40-project solution. Newtonsoft.Json is at six different versions. Something transitive has a CVE nobody's looked at since 2023. The intern's migration PR touched every .csproj by hand and missed three. CPMigrate is the binary that makes that a one-command problem — and then keeps it solved.


The problem 🎯

NuGet dependency drift is a slow leak. Version sprawl, duplicate references, transitive conflicts, and unpatched CVEs pile up silently — until the build breaks at the worst possible moment or an audit finds a vulnerable package you didn't know you shipped. Hand-migrating to Central Package Management is tedious and lossy; updating packages blind is Russian roulette with your test suite.

CPMigrate replaces both with three things that actually hold up:

  • 🔍 A dry-run-first migration that shows you the exact Directory.Packages.props diff before it touches a byte.
  • 📊 A dependency health scoreboard — 11 analyzers, a 0–100 score, severity-gated CI exits.
  • 🛡️ Updates that roll themselves back the instant dotnet test goes red — and with --bisect, keep the largest subset that stays green instead of nuking all 38 because one broke.

What it catches 🕵️

Rule What it finds Severity
🟥 SecurityVulnerability Known CVEs in direct and transitive deps (--audit) Critical
🟧 InlineVersionUnderCpm CPM drift — inline overrides, missing pins, orphaned entries, CPM switched off High
🟧 LicenseRisk Copyleft (GPL/AGPL) & proprietary licenses (--licenses) High
🟨 VersionInconsistency Same package, different versions across projects Moderate
🟨 TransitiveConflict Divergent transitive graphs (auto-pinnable) Moderate
🟦 DuplicatePackageCasing Newtonsoft.Json vs newtonsoft.json Low
🟦 RedundantReference The same PackageReference twice in one project Low
🟦 OutdatedPackage / DeprecatedPackage Behind the feed / abandoned packages Low
FrameworkAlignment Projects drifting across TargetFramework values Info

Every finding carries a stable rule ID — paste it straight into cpmigrate --explain <RuleId> for the why and the fix. Full reference: docs/rules.md.


30-second path ⚡

# 0 · is my machine even ready?
cpmigrate --doctor

# 1 · score the rot (CI-safe exit codes: 0 clean · 5 findings · 8 incomplete)
cpmigrate --analyze --audit --outdated --deprecated --output Json --quiet > analysis.json

# 2 · preview the migration as a unified diff — nothing written
cpmigrate -s ./MySolution.sln --dry-run --diff

# 3 · migrate to Central Package Management
cpmigrate -s ./MySolution.sln

# 4 · update packages; tests fail → automatic rollback
cpmigrate --update-packages --bisect

No flags? Bare cpmigrate drops you into Mission Control, an interactive wizard. One project? cpmigrate --project ./src/Api/Api.csproj --dry-run. Monorepo? cpmigrate --batch ./repo --batch-parallel. Team defaults? cpmigrate --init scaffolds a .cpmigrate.json.


Install 📦

Requires .NET SDK 8.0 or later. The tool itself targets net10.0 with LatestMajor roll-forward.

dotnet tool install --global CPMigrate --version 3.51.0
dotnet tool update --global CPMigrate     # or:  cpmigrate --update
Channel Command
Homebrew brew tap georgepwall1991/cpmigrate && brew install cpmigrate
🪟 Winget winget install GeorgeWall.CPMigrate
📦 Windows portable CPMigrate-portable-win-x64.zip from Releases
‍💻 From source git clone https://github.com/georgepwall1991/CPMigrate.git && dotnet build

Indexing lag after a fresh release? dotnet nuget locals http-cache --clear


See it work 🎬

The scoreboard — a 0–100 health meter, not a wall of text

────────────────────────── ! ANALYSIS COMPLETE — 4 ISSUES ──────────────────────────

              ╭──────────────── ANALYZER SCOREBOARD ────────────────╮
              │ ANALYZER                       │ ISSUES │  STATUS   │
              ├────────────────────────────────┼────────┼───────────┤
              │ ! Version Inconsistencies      │      3 │  3 FOUND  │
              │ ✖ Security Vulnerabilities     │      1 │  1 FOUND  │
              │ ✔ Duplicate Packages (Casing)  │      0 │     PASS  │
              │ ✔ Transitive Conflicts         │      0 │     PASS  │
              │ ✔ Redundant References         │      0 │     PASS  │
              ╰────────────────────────────────╯

   Dependency Health  ██████████████████░░░  78/100  GOOD

Dependency analysis scoreboard — version inconsistencies and health share meters

The migration — see the diff before it lands

CPM migration before / after — Directory.Packages.props

The bisect — keep 36/38 instead of reverting all 38

Package update bisect — keep the largest green subset with rollback

Mission Control — the interactive wizard

CPMigrate interactive wizard — guided CPM migration, risk assessment, dependency analysis


Feature snapshot 🧩

Surface What you get
🏗️ CPM migration Generate Directory.Packages.props, strip inline versions, conflict strategies, --merge
🔬 Dependency analysis 11 analyzers + scoreboard + 0–100 health score; JSON / SARIF / Markdown / CSV
🩹 Auto-fix Version, casing, redundant refs, transitive pin
🔁 Safe updates Latest versions + dotnet test + automatic rollback
🔪 --bisect Largest green update subset; names the held-back packages
🧱 Directory.Build.props Unify repeated properties across projects
🏢 Batch / monorepo Sequential or parallel multi-solution runs
💾 Backup & rollback Timestamped on-disk backups for every destructive path
📄 .sln + .slnx Classic solutions and Visual Studio 17.10+ .slnx
🩺 --doctor Environment diagnostics: SDK, NuGet, workspace, config, git
--init Scaffold .cpmigrate.json with team defaults
📟 --status One-shot workspace health dashboard
🌳 --tree ASCII dependency tree, direct + transitive
🔀 --diff Unified diff preview on --dry-run

Why not just do it by hand?

Manual CPM Ad-hoc scripts dotnet package list CPMigrate
Generates Directory.Packages.props ⚠️
Conflict resolution strategy
Dependency health scoreboard
Auto-fixers
Test-verified updates + rollback
Bisect to keep green subset
Machine-readable CI output
Repeatable across a monorepo ⚠️

Who this is for

  • 🧑‍💼 Solution owners dragging a codebase onto Directory.Packages.props
  • 🛠️ App teams modernizing package management without a hand-edited migration PR
  • 🏙️ Monorepo / multi-solution teams standardizing one dependency policy
  • 🤖 CI/CD maintainers who need gates that can't be fooled by an incomplete scan

🖥️ The whole toolbox

<details open> <summary><b>🩺 Diagnostics & workspace</b> — know your state before you change it</summary>

cpmigrate --doctor                 # SDK, NuGet reachability, workspace, git — one table
cpmigrate --status                 # repo-context dashboard, no wizard
cpmigrate --tree --transitive      # ASCII dependency tree per project
cpmigrate --init                   # scaffold .cpmigrate.json (interactive or CI-safe)

</details>

<details open> <summary><b>🔬 Analysis & auto-fix</b> — find the rot, then fix it</summary>

cpmigrate --analyze --audit --outdated --deprecated --licenses
cpmigrate --analyze --fix                       # apply every auto-fixable finding
cpmigrate --analyze --fix-dry-run               # preview the fixes
cpmigrate --analyze --fail-on High              # gate CI without failing on old debt
cpmigrate --analyze --write-baseline            # accept today's debt; fail only on new

</details>

<details open> <summary><b>🔁 Updates & bisect</b> — move forward without fear</summary>

cpmigrate --update-packages --dry-run
cpmigrate --update-packages                     # update · test · rollback on red
cpmigrate --update-packages --bisect            # keep the largest green subset
cpmigrate --update-packages --only Serilog,Polly   # chase the held-back ones

</details>


📚 CLI reference

Every flag, in one place. Collapsible so the page stays scannable.

<details open> <summary><b>Diagnostics & workspace</b></summary>

Option Default Description
--doctor false Diagnose the environment: SDK, NuGet, workspace, config, git
--init false Scaffold a .cpmigrate.json (interactive, or CI-safe defaults)
--status false One-shot workspace health dashboard
--tree false ASCII dependency tree per project (add --transitive for the full graph)

</details>

<details open> <summary><b>Migration & core</b></summary>

Option Short Default Description
--solution -s cwd Path to a .sln / .slnx file or directory
--project -p A specific project file, or a directory holding one
--output-dir -o . Where Directory.Packages.props is written
--dry-run -d false Preview changes without modifying files
--diff false Render a unified diff during --dry-run
--merge false Merge into an existing props file instead of failing
--conflict-strategy Highest Highest · Lowest · Fail
--interactive-conflicts false Prompt for each version conflict
--keep-attrs -k false Leave inline Version attributes in place
--interactive -i false Launch the Mission Control wizard

</details>

<details open> <summary><b>Analysis & auto-fix</b></summary>

Option Short Default Description
--analyze -a false Run dependency health analysis
--transitive false Include transitive dependencies
--audit false Security vulnerability scanning
--outdated false Outdated package checks
--deprecated false Deprecated package checks
--licenses false Flag copyleft / proprietary / unknown licenses
--fix false Apply auto-fixes (with --analyze)
--fix-dry-run false Preview auto-fixes
--fail-on Info Lowest severity that fails: Info·Low·Moderate·High·Critical·Never
--max-parallelism procs (≤8) Projects scanned at once for --audit/--outdated/--deprecated
--baseline Accepted-findings file; reported but never fail the build
--write-baseline false Record current findings as the baseline, then exit

Gating on a codebase with existing debt. --fail-on High narrows the gate without narrowing the report — sub-threshold findings still show in terminal, JSON, and SARIF; only the exit code changes. It can never suppress exit 8 (incomplete scan). Record the current state once, then fail only on what's new:

cpmigrate --analyze --audit --write-baseline              # commit .cpmigrate-baseline.json
cpmigrate --analyze --audit --baseline .cpmigrate-baseline.json

Baselined findings stay visible everywhere (suppressed: true in JSON, kind: "external" in SARIF). A finding is keyed by rule + package + projects — a version drifting 13.0.1 → 13.0.2 stays suppressed; spreading to a new project does not.

</details>

<details open> <summary><b>Package updates</b></summary>

Option Default Description
--update-packages false Update all packages, test, rollback on failure
--include-prerelease false Include pre-release versions
--bisect false Keep the largest green subset instead of reverting all
--bisect-budget 16 Max restore+test cycles a bisection may spend
--bisect-test-filter dotnet test --filter expression per probe
--only Comma-separated package IDs to restrict the update to

How --bisect thinks. The whole set is verified first (one run if it's healthy). On failure it halves: a clean half is banked into the baseline every later probe builds on; a failing half splits again until one package is held back. Probing against the banked-good set — not each package alone — catches failures that need two packages together. Cost ≈ 2·log₂(n) cycles. Exit 0 when green with ≥1 applied (check summary.packagesHeldBack in JSON for a partial), 7 when nothing could be kept. --bisect can't combine with --dry-run.

</details>

<details> <summary><b>Modernization · batch · backup · output · rules</b></summary>

Modernization

Option Default Description
--unify-props false Promote common properties to Directory.Build.props
--force false Skip confirmation prompts

Batch processing

Option Default Description
--batch Recursively scan a directory for solutions
--batch-parallel false Process solutions in parallel
--batch-continue false Continue past a failing solution

Backup & rollback

Option Short Default Description
--rollback -r false Restore the most recent backup
--no-backup -n false Disable backup creation
--backup-dir . Backup directory location
--list-backups false List backups with timestamps & file counts
--prune-backups false Delete old backups per --retention
--prune-all false Delete all backups
--retention 5 Backups to keep when pruning
--add-gitignore false Add the backup dir to .gitignore
--gitignore-dir . Where to create .gitignore if missing

Output & logging

Option Short Default Description
--output Terminal Terminal · Json · Sarif · Markdown · Csv (last four need --analyze)
--output-file Write Json/Sarif/Markdown to a file
--quiet -q false Suppress non-essential output
--verbose -v false Diagnostic logging to cpmigrate.log

Rules, completions & self-update

Option Description
--explain <RuleId> What a rule means, why it matters, how to fix it (--explain all lists every rule)
--completions <Shell> Emit a completion script and exit: Bash · Zsh · Fish · PowerShell
--update Check for and install the latest CPMigrate

Completions are generated from the live option list — enums and paths complete too, so they can't drift. --explain IDs paste straight from build logs and SARIF (issueCode / ruleId); a near-miss suggests the real rule, an unknown ID exits non-zero so a CI typo is visible.

</details>


🚪 Exit codes

The contract a CI gate is written against — and the one thing a script can't discover by trying.

Code Name Meaning
0 Success Operation completed successfully
1 ValidationError Invalid command-line options
2 FileOperationError File I/O or permission failure
3 VersionConflict Unresolvable conflict (with --conflict-strategy Fail)
4 NoProjectsFound No .csproj / .fsproj / .vbproj files discovered
5 AnalysisIssuesFound Analysis detected issues (your CI gate)
6 UnexpectedError Unhandled exception
7 TestFailure Tests failed after update (rollback done); with --bisect, only when nothing could be kept
8 IncompleteAnalysis A scan didn't finish — treat as re-run, never as clean

⚠️ Exit 8 is the whole point of the gate. If a project fails to scan, the run reports nothing for the part it couldn't read. A green 0 on an incomplete scan would let a vulnerability slip through. Always branch on 8.


🤖 CI/CD integration

Strict JSON contract

--output Json --quiet guarantees JSON-only stdout against a published schema:

cpmigrate --analyze --audit --outdated --deprecated --output Json --quiet > analyze.json
cpmigrate -s ./MySolution.sln --dry-run --output Json --quiet > migrate.json

Key off outputSchemaVersion, not the tool version. Two gotchas: success: true ≠ "no findings" (read summary.issuesFound / issuesAtOrAboveThreshold), and absent fields are meaningful (no issuesBaselined = no baseline used, not zero suppressions). --output Csv gives one row per finding for spreadsheets.

SARIF → PR annotations

- name: Install CPMigrate
  run: dotnet tool install --global CPMigrate
- name: Analyze dependencies
  id: analyze
  run: |
    set +e
    cpmigrate --analyze --audit --outdated --deprecated \
      --output Sarif --output-file cpmigrate.sarif --quiet
    echo "exit_code=$?" >> "$GITHUB_OUTPUT"
- name: Upload SARIF
  if: always() && hashFiles('cpmigrate.sarif') != ''
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: cpmigrate.sarif
- name: Require a completed scan
  run: |
    code="${{ steps.analyze.outputs.exit_code }}"
    case "$code" in 0|5) ;; *) echo "::error::incomplete scan (exit $code)"; exit 1 ;; esac

Capture the exit code — don't continue-on-error, which would swallow 8 and go green on exactly the case the upload exists to catch.

Markdown → job summary / PR comment

cpmigrate --analyze --audit --outdated --output Markdown --quiet >> "$GITHUB_STEP_SUMMARY"

Verdict-first, severity breakdown, findings linked to their rules, baselined rows marked, incomplete scans flagged, long lists collapsed behind <details>. Post to a PR with gh pr comment "$N" --body-file report.md. Full guide: georgepwall1991.github.io/CPMigrate/guides/ci-cd.


❓ FAQ

<details> <summary><b>How do I migrate a solution to Central Package Management?</b></summary>

cpmigrate -s ./MySolution.sln --dry-run --diff to preview, then drop --dry-run --diff to apply. CPMigrate extracts every <PackageReference>, resolves conflicts by strategy, generates Directory.Packages.props, and strips inline Version attributes — with a timestamped backup you can --rollback to.

</details>

<details> <summary><b>What <i>is</i> <code>Directory.Packages.props</code>?</b></summary>

The file NuGet Central Package Management reads versions from, so every project shares one version per package instead of declaring its own. CPMigrate generates and maintains it. Microsoft's CPM docs cover the format.

</details>

<details> <summary><b>Can it roll back a bad update?</b></summary>

Two ways. --update-packages runs dotnet test and rolls back on failure. --update-packages --bisect keeps the largest green subset and names the held-back packages. Migrations get timestamped backups restorable with cpmigrate --rollback.

</details>

<details> <summary><b>Does it work in CI/CD?</b></summary>

It's built for it. --output Json --quiet for strict stdout, --output Sarif for PR annotations, --output Markdown for the step summary, --output Csv for spreadsheets. Exit codes are contract-level: 5 = findings, 8 = incomplete scan.

</details>

<details> <summary><b>Does it support <code>.slnx</code> and monorepos?</b></summary>

Yes — classic .sln and VS 17.10+ .slnx, and --batch recursively discovers every solution, optionally in parallel (--batch-parallel) and continue-on-failure (--batch-continue), with an isolated backup per solution.

</details>

<details> <summary><b>Can I gate on vulnerabilities without failing on existing debt?</b></summary>

Yes. --audit scans direct + transitive CVEs; --fail-on High narrows the gate while still reporting everything; --write-baseline records today's findings once so CI fails only on new debt. Baselined findings stay visible in every report.

</details>

<details> <summary><b>Which .NET versions are supported?</b></summary>

The tool targets .NET 10 with LatestMajor roll-forward and runs on any machine with .NET SDK 8.0+. Your projects can target anything — CPMigrate edits XML directly and only builds your solution when you ask it to verify updates.

</details>


🔧 Configuration

cpmigrate --init writes a .cpmigrate.json (CLI flags always win):

{
  "$schema": "https://raw.githubusercontent.com/georgepwall1991/CPMigrate/main/schemas/cpmigrate.schema.json",
  "conflictStrategy": "Highest",
  "backup": true,
  "addGitignore": true,
  "failOn": "High",
  "baseline": ".cpmigrate-baseline.json",
  "retention": { "enabled": true, "maxBackups": 5 }
}

Discovered by walking up from the solution/project path (or cwd). Contradictory settings warn; malformed JSON reports the exact line and column.


Compatibility ✅

  • .NET SDK: 8.0+ (tool targets net10.0, LatestMajor roll-forward)
  • Projects: .csproj / .fsproj / .vbproj
  • Solutions: .sln and .slnx
  • CPM: generates and consumes standard NuGet Central Package Management files

🧪 Examples & benchmarks

🗓️ Release cadence

Stable releases weekly (versioned + changelogged); RCs for fast feedback. Source of truth: CHANGELOG.md. Policy: docs/release-cadence.md.

📡 Telemetry (opt-in)

Disabled by default. Set CPMIGRATE_TELEMETRY_OPT_IN=true to emit command-level metrics only (operation, duration, exit-code category, high-level flags) — never paths, package names, file contents, or source. Stored locally at ~/.cpmigrate/telemetry/events.ndjson.

🤝 Contributing

Fork → git checkout -b feature/thing → write tests → dotnet test → open a PR. The drift tests hold the docs to the code, so update the README tables when you touch a flag.

📜 License

MIT — see LICENSE.


<div align="center">

Built by George Wall · cpmigrate · make the build boring again.

⭐ Star it · 🐛 Report a bug · 📖 Docs site

</div>

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
3.51.0 0 7/31/2026
3.50.0 0 7/31/2026
3.49.0 0 7/31/2026
3.28.2 9 7/30/2026
3.28.1 28 7/30/2026
3.28.0 32 7/30/2026
3.27.0 28 7/30/2026
3.26.0 33 7/30/2026
3.25.1 36 7/30/2026
3.25.0 42 7/30/2026
3.24.0 31 7/30/2026
3.23.0 33 7/30/2026
3.22.0 31 7/30/2026
3.21.0 37 7/30/2026
3.20.0 46 7/30/2026
3.19.0 39 7/30/2026
3.18.0 38 7/30/2026
3.17.1 30 7/30/2026
3.17.0 36 7/30/2026
3.16.0 33 7/30/2026
Loading failed

Docs and packaging: the README is rebuilt around a conversion funnel — Remotion-rendered hero video (poster-linked, because NuGet strips video tags), terminal recordings re-recorded at 3.28.x replacing GIFs that showed v3.5.0, a new FAQ, and 40% fewer lines with every documented contract preserved. The docs site gains FAQ and Video structured data and proper 1200x630 social cards. The nupkg no longer bundles assets/video — the **\* pack glob swept 3.4MB of media NuGet cannot render; it now ships the README and the three product-flow SVGs. Previously: URGENT FIX for a regression in 3.26.0, 3.27.0 and 3.28.0: those versions silently dropped projects from --analyze. The restore isolation they introduced was passed as environment variables, which apply to every project in the MSBuild graph rather than the one being queried, so any project with a ProjectReference told its references to write to the same intermediate directory. They collided, the query returned no frameworks, and the scan counted that as success with zero packages — so the project vanished with scanFailures: 0 and no warning. Measured on Serilog: three of six projects lost, non-deterministically. Upgrade from any of those three versions. Previously: closes a security-relevant race in --audit, --outdated and --deprecated that has been present since 3.15.0. Those queries shell out to `dotnet package list`, which restores — so two projects sharing a project.assets.json corrupt each other's results. Demonstrated with two projects in one directory, one pinned to a Newtonsoft.Json version with a known high-severity advisory and one to a clean version: queried concurrently, the clean project was reported carrying the vulnerable version. It runs the other way just as easily, which is a genuinely vulnerable project reported clean, with nothing in the output to say so. Each deep scan now reuses the isolated directory its project's resolved scan proved usable, and where that scan escaped, it runs under the exclusive restore lock instead. Previously in 3.27.0: Every dependency is now current — MSBuild 18.8.2, Buildalyzer 9, Spectre.Console 0.57.2, SonarAnalyzer 10.31, coverlet 10 — with nothing outdated left. This also corrects the record: 3.24.0 reported that Microsoft.Build 18.8.2 broke 20 tests. It does not, and it did not. That run took 14 minutes against a normal 2 and the machine ran out of disk shortly afterwards; the bisect that produced the claim never tested the MSBuild packages on their own. Verified now on both this code and the pre-3.26.0 code: clean either way. Previously in 3.26.0: Analysis is about four times faster on a large solution: 60 projects went from 200s to 50s, with byte-identical findings. Resolving packages shells out to `dotnet package list` once per project — a separate process that spends most of its time waiting — and those now run concurrently. The reason it could not before is that `dotnet package list` restores, and two projects sharing an assets file cannot be queried at once: the loser reports the other project's packages, so two projects with different versions report the same one and the inconsistency disappears with a clean exit code. Each invocation now gets its own MSBuild intermediate directory, which makes that collision impossible rather than something to detect — the detection is not possible here, because where the assets file goes depends on MSBuild evaluation that this phase must not perform. Reading project files stays serial, deliberately: MSBuild's object model is not thread-safe. Previously in 3.25.1: Refreshes the dependencies bundled into the tool — Serilog 4.4.0, NuGet.Versioning 7.6.0, Microsoft.Extensions.Logging 10.0.10 — which were updated on main after 3.25.0 was tagged. No behaviour change: this is a PackAsTool package, so those assemblies ship inside it rather than being restored by consumers, and 3.25.0 was still carrying the older ones. Previously in 3.25.0: Holds the documentation to the tool. A flag that exists and is undocumented is invisible; one that is documented and no longer exists is worse, because it gets written into a CI script and discovered from an exit code. Neither shows up in a build, a test run, or a review of the code that changed — the docs are a different file and nothing was checking them. DocumentationDriftTests now asserts, by reflection, that every option appears in the README reference and every documented option still exists, that every exit code is in the table under its own name, and that every analysis rule has a published page. One option had already slipped (--gitignore-dir), which is the argument for asserting it rather than remembering. Previously in 3.24.0: Adds ScanWorkTests: the properties any change to the analysis scan's scheduling has to hold, rather than a duration — findings independent of --max-parallelism, shared-directory and redirected-output layouts still reporting their findings, and the same solution producing the same report twice. Also fixes a --fix regression from 3.23.0 where a framework-conditional pin read as an ordinary one and every other project was unified to it. A concurrent package-resolution change measured at two to four times faster was written and deliberately not shipped: whether two projects share a project.assets.json depends on full MSBuild evaluation, which that phase cannot do, and eight review rounds each found another route to a shared file. A scan that silently reports fewer findings is worse than a slow one. Previously in 3.23.0: Fixes --fix reporting "No changes were needed" when it could not change anything. Both fixers that edit project XML swallowed every exception and returned null, which the caller could only read as "nothing to change" — so on a read-only, locked, or malformed project file the run printed that reassuring line directly above "1 issue(s) could not be fixed automatically", two statements contradicting each other, and threw away the actual cause. The exit code was already right, because the post-fix rescan gates on the surviving finding; everything a human reads was wrong. Failures now carry their reason and name the file, one unreadable file still does not stop the others, and "No changes were needed" is printed only when that is true. Previously in 3.22.0: Closes the last four exemptions in the end-to-end rule guard, so every analysis rule is now proven against real data rather than taken on trust. The vulnerability, outdated, deprecated, and transitive rules were exempted as needing a live NuGet feed — true of the query, but not of the parsing and reporting after it, which is exactly where the 3.20.0 defect lived. They are now driven from JSON captured from real `dotnet package list` runs, which immediately found that a --transitive scan invented a duplicate-reference finding for any package present both directly and transitively. Previously in 3.21.0: Fixes RedundantReference, which could not fire, and its fixer, which could not fix. The rule reads package references from `dotnet package list` — the *resolved* graph — and resolution collapses two PackageReference items with the same Include into one, so by the time a duplicate reached the analyzer there was only ever one of it. Rules about what a project file says now read the references as declared. The fixer separately resolved projects by file name while findings have identified them by path since 3.10.0, so it matched nothing and returned "no fix needed": the run printed "No changes were needed" over an unrepaired finding. Conditional declarations are now respected by every rule and fixer: declaring a package once per target framework behind a Condition is how multi-targeting is written, and treating it as a duplicate or an inconsistency had --fix delete or overwrite the pin another framework depended on. The rule also now fires under central package management, where references carry no version at all. All found by a new end-to-end guard that drives the real CLI against real files and asserts every rule can actually fire — because the analyzer unit tests all passed throughout, proving the logic while nothing checked that the pipeline in front of it delivers the shape the logic expects. Previously in 3.20.0: Fixes the RedundantDirectReference analyzer, which had never reported anything. It reads the resolved dependency graph out of project.assets.json, but composed its lookup key from the version in project.frameworks.<tf>.dependencies — which NuGet writes as a range, "[7.0.0, )" — while the targets section is keyed by the resolved version, "Serilog/7.0.0". The key matched nothing on any real project, so the traversal found no dependencies and the analyzer reported a clean result instead of a broken lookup. Its tests passed because their fixtures used a bare version that restore never writes. Lookup is now by package name against the resolved graph, since restore settles on one version per package per framework and no version needs reconstructing. Two further conditions the corrected traversal needed, both found in cross-review: a reference that pins *higher* than anything else requires is not redundant, because removing it downgrades the package — the contract always said "at the same or higher version" and a reachability-only check dropped that; and a reference redundant under only some target frameworks is not reported, since removing it breaks the framework where it was independently required. Previously in 3.19.0: Directory.Packages.props keeps its shape across runs. A merge used to write new pins as <PackageVersion Include="X"><Version>1.0.0</Version></PackageVersion> while every entry around them used Version="1.0.0" — one file, two styles, and a three-line diff for a one-line addition. It also inserted new entries between a comment and the pin that comment documents, silently reattaching a team's "pinned because 2.x drops netstandard2.0" note to a different package, which is worse than losing it because the file still reads as correct. New pins now match whichever style the file already uses, a file that was ordered stays ordered, one that was not is left as its author arranged it, and where a comment occupies the sorted position the new entry goes one slot later rather than taking the explanation with it. Ordering is also no longer culture-sensitive. Previously in 3.18.0: Fixes the interactive wizard silently answering its own questions. Every prompt used to work out what an answer meant by reading its label back — StartsWith("Yes"), an exact match against the display string, or slicing an emoji prefix off a directory name — and every one of them had a quiet wrong answer behind it: an answer that matched nothing fell through to a default, so a reworded option flipped to its opposite and an unrecognised mission started a migration nobody asked for. Answers now carry their values, and an answer that was not offered is an error. Also fixes a real gap this was hiding: a repository with Directory.Packages.props at the top and projects under src/ — the ordinary shape of a migrated solution, and exactly what --analyze is pointed at — could not be selected in the browser at all. Previously in 3.17.1: Internal: CommandRouter dispatch is now an ordered table rather than an if/else chain. Behaviour-preserving, but it makes precedence explicit — which mode wins when several flags are present was previously decided by the order statements happened to appear in, and is now a readable list with tests asserting it. Previously in 3.17.0: publishes a JSON Schema for the --output Json payload at schemas/cpmigrate-output.schema.json. The contract has been versioned since 1.0.0 but never described, so consumers had to infer the shape from examples — and infer it wrong at the edges, which is where the fields that matter live: success: true does not mean "no findings" when they were below the --fail-on threshold or accepted by a baseline, and an absent summary counter means the command did not produce it rather than producing zero. Guarded against drift by reflection rather than a new dependency, so a field added without updating the schema fails the build. Previously in 3.16.0: adds --explain <RuleId>: rule documentation from the same terminal that produced the finding. A rule ID in a build log is the moment someone needs to know what it means and the moment they are least likely to go looking for a docs site. The same IDs appear as issueCode in JSON and ruleId in SARIF, so a report can be pasted straight back. --explain all lists every rule; a near miss suggests the real one; an unrecognised ID exits non-zero so a CI typo is visible. Previously in 3.15.0: --audit, --outdated, and --deprecated now query projects concurrently. Each shells out to 'dotnet package list' per project and waits on the network, so the scan scaled linearly with solution size for no reason: 10 projects with --outdated went from 12s to 6s. Bounded by --max-parallelism (default: processor count, capped at 8). Reading package references stays serial on purpose — that pass goes through MSBuild's object model, whose static caches are not thread-safe, and running it concurrently made projects report each other's versions and silently erased version-inconsistency findings. Results merge in project order, so a report is identical run to run. Previously in 3.14.0: fixes a failed NuGet version lookup being reported as "up to date". The lookup returned null both for "this package is current" and "the request failed", so one 503 or timeout during --update-packages silently dropped that package and the run still finished with "Everything up to date!" — on a slow connection a large solution could skip most of its updates and say nothing. Transient failures are now retried with exponential backoff and jitter, honouring Retry-After up to a cap; 404 and malformed bodies are treated as definitive; and whatever still fails is named rather than being counted as current. Lookups are also cached per run, so a package referenced from thirty projects costs one request instead of thirty. Previously in 3.13.0: adds --completions for bash, zsh, fish, and PowerShell. With 45 options, remembering which take a value — and that it is Sarif rather than SARIF — is not a reasonable expectation. Generated from the option metadata rather than hand-written, because a stale completion list is worse than none: it suggests flags that no longer exist. Enum options complete their values, path options complete filenames, and zsh shows help text inline. Previously in 3.12.0: adds four rules that catch a solution drifting back off central package management. Migrating is a one-off event; staying migrated is not — someone adds a package with an inline Version and the solution is quietly half-centralized, with NuGet saying nothing because the inline version simply wins. InlineVersionUnderCpm (an inline pin overriding the central one), MissingPackageVersion (no version anywhere; restore fails), OrphanedPackageVersion (a central pin nothing references), and CpmNotEnabled (a props file with central management switched off, so every entry is inert). Gated on data rather than a flag: nothing is reported unless the solution has a Directory.Packages.props. Previously in 3.11.0: adds --output Markdown: a report for a CI job summary or PR comment, because neither existing format reaches a human when they need it — JSON is for parsers and SARIF only surfaces findings that map to a line in the diff, which a solution-wide dependency problem never does. Leads with the verdict (did anything reach the --fail-on threshold), then scan totals, a severity breakdown, and a findings table linking each rule to its docs. Incomplete scans get a prominent warning, baselined findings are marked, long lists collapse, and table cells are escaped. Previously in 3.10.0: findings identify projects by their path relative to the scan root (src/App/App.csproj) rather than by file name. A file name is not an identifier — two projects can share one — which meant a baseline entry accepting debt in one project could silently suppress a new finding in another, and SARIF had to guess which same-named file to annotate. Action required: regenerate baselines, the fingerprint scheme is now v2 and a v1 file is rejected with an explicit instruction. JSON schema 1.3.0: analysisIssues[].affectedProjects holds relative paths. Previously in 3.9.0: adds --baseline and --write-baseline: adopt a CI gate on a codebase that already has debt. Record the current findings once, commit the file, and every run after that fails only on findings the baseline does not contain. Baselined findings stay in every report — terminal, JSON (suppressed: true), and SARIF (as a suppressions entry) — so the debt stays visible without blocking. A finding is identified by its rule, package, and affected projects rather than by the versions in its description, so version drift does not unsuppress accepted debt while spreading to a new project does. Stale entries are reported so a baseline cannot grow forever. Previously in 3.8.0: --fail-on <severity>: gate CI on the findings that matter instead of on all of them. Every finding used to fail the build, which makes the gate unusable for a repository with existing debt. --fail-on High narrows the gate without narrowing the report — findings below the threshold still appear in terminal, JSON, and SARIF output, and only the exit code changes. Accepts Info (default), Low, Moderate, High, Critical, and Never for report-without-gating; settable team-wide as "failOn" in .cpmigrate.json. Cannot suppress exit 8 (IncompleteAnalysis). JSON schema 1.2.0 adds summary.failOnSeverity, issuesAtOrAboveThreshold, highestSeverity, scanFailures, and deepScanFailures (all additive). Also fixes the published config schema, which was missing Sarif from outputFormat. Previously in 3.7.0: --output Sarif: cpmigrate --analyze now emits a SARIF 2.1.0 log for GitHub code scanning, so dependency findings annotate the pull request diff. Results point at the exact PackageReference line, carry full rule metadata with links to the new docs/rules.md reference, and include stable fingerprints so findings are tracked across runs rather than reopened. Tool failures emit an unsuccessful SARIF invocation, so an upload step never breaks on a malformed file. --output-file now accepts Sarif as well as Json. No analyzer severity changes.