MigrationScan.Tool
0.1.0
See the version list below for details.
dotnet tool install --global MigrationScan.Tool --version 0.1.0
dotnet new tool-manifest
dotnet tool install --local MigrationScan.Tool --version 0.1.0
#tool dotnet:?package=MigrationScan.Tool&version=0.1.0
nuke :add-package MigrationScan.Tool --version 0.1.0
MigrationScan
A free, deterministic, offline .NET Framework migration assessment tool.
It answers one question: how much work is it to move this solution off .NET Framework, and what specifically blocks it? It runs offline, produces the same output every time, requires no account, and never transmits your source code.
Download the executable for your platform from the releases page, put it in the folder you want to assess, and run it:
migrationscan path/to/YourSolution.sln
It writes one file — migrationscan-report.json — and prints a summary. No .NET install, no
admin rights, no flags to learn. With no arguments at all it scans the current directory, so
dropping the executable into a repository root and double-clicking it works too.
Already have the .NET 10 SDK? dotnet tool install -g MigrationScan.Tool gets you the same tool.
Status: pre-release / under active development. The foundation is in place (Phase 0). Rules, reports, and the CLI surface are being built out phase by phase — see Roadmap. The install command above will work once the first release is published to NuGet.
📄 See a sample Markdown report → — the shareable artifact an engineering manager forwards to a CTO: executive summary, blockers, findings by project, an effort breakdown, and remediation guidance.
Why this exists
Microsoft deprecated the .NET Upgrade Assistant in late 2025 and replaced it with the GitHub Copilot app modernization agent. The replacement needs a paid subscription and sends source code to a cloud service. Teams in finance, healthcare, defense, and government cannot do that — and they are the teams sitting on the largest .NET Framework estates. MigrationScan gives those teams an assessment they can run themselves, offline, with nothing leaving the machine.
The promise
- Offline by default. No network calls in the default path. Network access is only available behind an explicit
--onlineflag, and only for NuGet package compatibility lookups. - No telemetry. No phoning home, no usage collection, no login.
- Your code stays put. Source is never transmitted anywhere.
- Deterministic. Same input, same output, every run.
- No AI in the analysis path. Findings come from static analysis, not an LLM.
Non-goals
MigrationScan deliberately does not:
- Modify your source code
- Perform the upgrade
- Use AI or an LLM anywhere in the analysis path
- Phone home, collect telemetry, or require a login
- Produce a binding cost estimate — effort figures are heuristic planning aids, not a quote
- Require Visual Studio or MSBuild to be installed
- Replace human judgment on architectural decisions
How it works
MigrationScan parses your .sln, .csproj, and .vbproj files as XML and reads your .cs and .vb source with Roslyn — no MSBuild registration and no Visual Studio required, so it runs the same on Windows, Linux, and macOS. Every finding carries a confidence tier so the report is honest about what static analysis can and cannot prove:
| Tier | Name | Source |
|---|---|---|
| 1 | Certain | Project files, packages.config, app.config, web.config, .sln (XML, no ambiguity) |
| 2 | Probable | Roslyn syntax trees without a resolved compilation (good recall, some false positives) |
| 3 | Verified | Read from compiled assemblies via Cecil — see binary analysis |
Reference inventory
Findings tell you what's broken. The reference inventory tells you what you depend on — every NuGet package, GAC and vendored assembly, COM/ActiveX component, project reference, and ASMX/WCF service proxy each project declares, with versions and where each one resolves from.
It's inventory, not findings: no severity, no effort, never a build failure. The point is that the expensive unknowns in a migration are usually somebody else's code — a control from a vendor that folded, a type library with no 64-bit build — and rules can only flag the ones with a known pattern. The inventory gives you the rest of the list to research.
Third-party references (10 distinct) — inventory, not counted above:
• nuget Newtonsoft.Json 13.0.3
• gac Telerik.Web.UI 2015.3.930.45
• dll Contoso.Payments 2.1.0.0
• com AxInterop.MSCommLib 1.0.0.0
• com MSXML2 6.0
• svc PricingService
(Also read, not listed: 1 framework, 1 solution-internal.)
The Markdown report renders it as a table with per-component project counts; the JSON exposes it
as a references array for scripting. See the references doc for what each
kind covers, the classification judgment calls, and what deliberately isn't collected.
Rules
MigrationScan ships a catalog of stable, never-reused rule IDs grouped by category (project/build, dependencies, blocking frameworks, runtime failures, configuration, serialization/security, data access, globalization). Each rule links to a remediation page under /docs/rules.
Implemented rules
| ID | Rule | Severity | Tier |
|---|---|---|---|
| MIG1001 | Non-SDK-style project file | Medium | 1 — Certain |
| MIG1002 | packages.config instead of PackageReference |
Medium | 1 — Certain |
| MIG1003 | Target framework below 4.6.2 | Medium | 1 — Certain |
| MIG1005 | GAC reference (no HintPath) | Medium | 1 — Certain |
| MIG1006 | COM reference or interop assembly (Windows lock-in) | Medium | 1 — Certain |
| MIG1007 | Legacy project type (SSRS, SSIS, setup, Silverlight, Web Site) | High | 1 — Certain |
| MIG1010 | Vendored DLL with no source and no NuGet equivalent | High | 1 — Certain |
| MIG2001 | Package has no version supporting the target framework | High | 1 — Certain |
| MIG2002 | Package marked deprecated on nuget.org (--online) |
Medium | 1 — Certain |
| MIG3001 | ASP.NET WebForms | Blocker | 1 — Certain |
| MIG3002 | System.Web dependency outside WebForms |
High | 1 — Certain |
| MIG3003 | ASMX web service | High | 1 — Certain |
| MIG3004 | WCF service host (server side) | High | 2 — Probable |
| MIG3005 | .NET Remoting | Blocker | 2 — Probable |
| MIG3009 | MSMQ (System.Messaging) |
High | 2 — Probable |
| MIG3010 | ASP.NET MVC 5 (System.Web.Mvc) |
High | 2 — Probable |
| MIG3015 | WCF client (System.ServiceModel) |
Medium | 2 — Probable |
| MIG4001 | System.Drawing.Common on non-Windows |
High | 2 — Probable |
| MIG4002 | Windows Registry access | High | 2 — Probable |
| MIG4003 | System.Management / WMI |
High | 2 — Probable |
| MIG4004 | System.DirectoryServices / Active Directory |
High | 2 — Probable |
| MIG4005 | EventLog |
Medium | 2 — Probable |
| MIG4008 | Thread.Abort |
Medium | 2 — Probable |
| MIG4013 | P/Invoke to a Windows system DLL (Windows lock-in) | Medium | 2 — Probable |
| MIG5001 | ConfigurationManager.AppSettings usage |
Low | 2 — Probable |
| MIG6001 | BinaryFormatter (removed in .NET 9) |
Blocker | 2 — Probable |
| MIG6004 | Code Access Security attributes | Medium | 2 — Probable |
| MIG6005 | Obsolete cryptography types | Medium | 2 — Probable |
| MIG7001 | System.Data.SqlClient |
Medium | 2 — Probable |
| MIG7003 | System.Data.OleDb on non-Windows |
Medium | 2 — Probable |
| MIG7006 | LINQ to SQL (System.Data.Linq) |
High | 2 — Probable |
| MIG8002 | Encoding.Default behavior change |
Medium | 2 — Probable |
| MIG8003 | Code-page encoding without provider registration | Medium | 2 — Probable |
More rules land phase by phase; see the full catalog in the spec.
Usage
migrationscan [path] [options]
[path] .sln, .csproj, .vbproj, .dll/.exe, or directory to scan.
Defaults to the current directory.
--target <tfm> .NET version to assess against (default: net10.0). Both
portability stances are always reported — see below.
--format <fmt> console | markdown | json | sarif (repeatable). Omit for the
default: a console summary plus migrationscan-report.json.
--output <path> Output file or directory
--fail-on <severity> blocker | high | medium | low
--online Allow NuGet.org lookups for package compatibility
--baseline <path> Suppress findings present in a baseline file
--include-paths Keep real file paths in the JSON (off by default)
That is the whole surface — --help is the authority. --fail-on is evaluated against the
stance --target names, so adding the second stance to the report never changes an exit code.
console always writes to stdout. For json/markdown, --output may be a file
(written as-is for a single format) or a directory (receives report.json /
report.md). When several file formats share one --output file path, each is written with
its own extension so they don't overwrite each other.
Cross-platform vs. staying on Windows
Modern .NET can still target Windows (net10.0-windows), where COM interop, P/Invoke to
Win32, the Registry, WMI, and similar continue to work. Those APIs are Windows lock-in —
they are only migration cost if you also need to leave Windows.
You do not have to choose, and you do not have to scan twice. Every report covers both stances:
"targets": [
{ "target": "net10.0", "stance": "crossPlatform", "summary": { /* 7.8–23 days */ } },
{ "target": "net10.0-windows", "stance": "windows", "summary": { /* 5–15 days */ } }
]
The difference between those two numbers is the price of portability, and it comes from a single scan: the target changes what a finding costs, never what was found, so the second stance is an exact re-evaluation rather than a second analysis.
The console and Markdown reports show the stance named by --target (cross-platform by
default, the loud one), where Windows lock-in findings are downgraded: still listed under a
"satisfied by target" section and flagged satisfiedByTarget in JSON / suppressed in SARIF, but
excluded from the severity counts, the effort estimate, and --fail-on. Gone-everywhere
findings — WebForms, BinaryFormatter, Remoting, MVC 5, and the rest — stay at full severity
regardless of stance.
--target selects the .NET version (net8.0 vs net10.0); the platform axis is always
reported both ways. If you want the console summary from the Windows stance instead:
migrationscan MyApp.sln --target net10.0-windows
The JSON is identical either way — both stances are in it regardless.
Exit codes
| Code | Meaning |
|---|---|
| 0 | No findings above threshold |
| 1 | Findings above --fail-on threshold |
| 2 | Analysis error |
| 64 | Bad usage |
Online package checks (--online)
By default MigrationScan makes no network calls and the output is fully deterministic.
Passing --online opts in to nuget.org lookups for package status — currently flagging
packages the maintainers have marked deprecated (MIG2002):
migrationscan . --online
Because these findings reflect live nuget.org state, they are not part of the deterministic default path. If a lookup fails (offline, rate-limited), the scan degrades gracefully — it prints a warning and continues without package status rather than failing.
Scanning compiled binaries
When you don't have the source — a third-party component, or an early look at a client's build output — point MigrationScan at a compiled assembly:
migrationscan path/to/YourApp.dll
It reads the assembly with Mono.Cecil and flags references to assemblies that aren't available
on modern .NET (System.Web, System.Drawing, System.Management, System.Messaging, …).
These are Tier 3 — Verified findings: read from the compiled metadata rather than inferred
from syntax. Source-based scanning (a .sln/.csproj) remains richer; binary analysis is the
fallback for when source isn't on hand.
Continuous integration
MigrationScan is built for CI: machine-readable output, meaningful exit codes, and no interactive prompts.
GitHub code scanning
Emit SARIF and upload it — findings show up inline on the Security → Code scanning tab and as annotations on pull requests:
name: Migration scan
on: [push, pull_request]
permissions:
contents: read
security-events: write # required to upload SARIF
jobs:
migrationscan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: dotnet tool install -g MigrationScan.Tool
- run: migrationscan . --format sarif --output migrationscan.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: migrationscan.sarif
SARIF file paths are relative to the scan root (the directory or solution you point at), so run the scan from the repository root for the annotations to line up with your files.
Failing the build on regressions
Use --fail-on to return exit code 1 when a finding is at least as severe as the threshold:
migrationscan . --fail-on high # fail on any high or blocker finding
Baselining an existing estate
Adopt the tool on a large legacy codebase without failing on day one: capture a baseline, then fail only on new findings.
migrationscan . --format json --output migrationscan-baseline.json # once, committed to the repo
migrationscan . --baseline migrationscan-baseline.json --fail-on high # in CI: only new findings count
A baseline is just a JSON report captured earlier; findings whose rule, file, and message match one in the baseline are suppressed. (Line numbers are ignored, so baselined findings survive unrelated edits that shift lines.)
Building on the output
The JSON is a stable, versioned, deterministic feed meant to be consumed by other tools —
dashboards, portfolio rollups, or your own scoping/estimating layer. Alongside the findings it
carries an effort rollup (summary.effort and a per-projects breakdown, in engineer-day
ranges) and a notAssessed list of non-C#/VB projects (SQL, deployment, …) that need planning
of their own — so coverage gaps are explicit, not silent. It also carries the full references
inventory, flat and per-project, for feeding a dependency-research workflow. See the
output schema for the full shape and consumer notes. Effort figures are
heuristic planning aids, not a quote — apply your own rates and judgment downstream.
What's in the report (for your security review)
The JSON report contains no file paths. Each one is replaced by a stable opaque id, so the file can be shared without anyone reading several thousand lines of JSON to approve it. Every run says so, and every Markdown report ends with a "What this report contains" section.
It includes: project names, line numbers, rule identifiers and their fixed remediation text, and the names and versions of dependencies your projects declare (NuGet packages, referenced assemblies, COM components, web-service endpoints, Windows system libraries called via P/Invoke). Those are identities, not locations, and they are kept deliberately — a component cannot be assessed without knowing which one it is.
It does not include: source file paths, any source code or file contents, connection strings, credentials, configuration values, web-service hosts and URLs, customer or business data, machine or user names, or anything from outside the folder you scanned.
Redaction applies to the JSON — the file you share. The console, the Markdown report and the
SARIF output keep full paths on purpose: they stay on your machine, SARIF exists to annotate a
specific line in a specific file, and withholding paths from your own developers would help
nobody. --include-paths keeps them in the JSON too.
Two details worth knowing: a fileId is stable, so two findings in the same file still visibly
share a file — real signal for estimating, at no disclosure. And a redacted report still works as
a --baseline, because each finding records its own fingerprint rather than having one derived
from the path.
Each report also records what produced it — the tool version, and the commit the scanned tree was checked out at when it is a git working tree:
"scan": { "toolVersion": "0.1.0", "commit": "0fc6524d7b26ccd2f1eca0d18d8b3792dc6dc675" }
There is deliberately no timestamp: the report is byte-identical for the same input by design, which is what makes it diffable and baselineable. The commit is the better answer to "is this scan stale?" anyway, since it names the exact revision assessed.
Scanning a whole estate
Point MigrationScan at a directory and it assesses everything under it in one pass — every solution, and every project, in one report:
migrationscan C:\code\LegacyEstate
Projects are the unit of truth and solutions are the grouping, not the other way round. A project is assessed because it exists on disk, so a project no solution references is still scanned — those are exactly the ones that surface halfway through a migration and blow the plan. They are called out in the warnings so you can confirm whether they are in scope. A project shared by several solutions is scanned once, not once per solution.
Build output, restored packages/, node_modules, and dot-directories are skipped, so a vendored
source tree is never mistaken for your own code.
Limitations
Static analysis without resolved references cannot see everything, and MigrationScan is honest about that rather than pretending to certainty:
- Tier 2 findings can be false positives. A reference to a type named
Registrymight be your own class, notMicrosoft.Win32.Registry. These are reported as probable, never certain. - Source scanning has no resolved compilation. Tier 2 findings come from syntax alone. For extra confidence you can also scan compiled binaries (Tier 3, via
migrationscan YourApp.dll), which reads referenced assemblies from the assembly metadata. - Effort figures are heuristic. They are planning aids derived from static analysis, not a quote.
- The reference inventory is what projects declare. Transitive package dependencies,
<Import>ed build targets, and binding redirects are not collected — see what isn't collected. Resolving the full package graph would need a restore, and therefore the network. - Architectural decisions are yours. MigrationScan flags what blocks a migration; it does not decide how to redesign around it.
Building from source
Requires the .NET 10 SDK.
git clone <repo-url>
cd MigrationScan
dotnet build
dotnet test
Releases ship from a v* tag — see publishing. Changes are recorded in
the changelog.
Roadmap
Development proceeds in ordered phases (see the spec for detail):
- Phase 0 — Foundation: repo, license, CI on Linux/Windows/macOS, empty solution
- Phase 1 — Walking skeleton: parse
.sln/.csproj, first rule (MIG1001), console + JSON output - Phase 2 — Rule engine (project-file + Roslyn syntax rules) and the first rule batch
- Phase 3 — Roslyn syntax rules (Tier 2): 12 runtime/blocking-framework detectors
- Phase 4 — Effort model and Markdown report (golden-file tested)
- Phase 5 — CI integration: SARIF,
--fail-onexit codes,--baseline - Phase 6 — Post-v1:
--onlineNuGet deprecation lookups, VB.NET support (projects + source), Mono.Cecil binary analysis, and an expanded rule catalog (28 rules). Further catalog rules land as needed.
Open questions
A few decisions from the spec are still open and will be resolved before v1:
- VB.NET support —
.vbprojprojects are discovered and their.vbsource is analyzed by the same rules as C#: the syntax queries are language-neutral, so VB gets the runtime-failure (Tier 2) rules too, honouring VB's case-insensitive matching. - Default target framework — pinned to
net10.0(LTS) for now; may float to whatever is current LTS. - Schema distribution — ship a
--json-schemacommand vs. publish the schema as a static file.
License
Apache-2.0. The patent grant matters for enterprise legal review.
| Product | Versions 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. |
This package has no dependencies.