Every CMS 12 to CMS 13 upgrade I have looked at starts the same way. Someone opens the solution and starts hunting. A text search for the APIs they half-remember being removed, a scroll through appsettings.json looking for sections that no longer bind to anything, a squint at the .csproj to see which target framework it is still on, and a mental inventory of which third-party packages have a CMS 13 build yet. Then the compiler finds the rest, usually about three weeks in.
It works. It is also manual, inconsistent between whoever happens to be doing it, and it does not scale past a handful of projects. The things that slip through are rarely the obvious ones. They are the API that was merely deprecated last major and is an error in this one, the add-on package that quietly caps its dependency below CMS 13, the configuration section that still binds without complaint but no longer does anything, and the project nobody noticed is still on an older target framework.
I wanted something that would do that first pass for me, the same way every time, without needing an internet connection or a credit card. That is cms13-scan (package name OptimizelyCms13ReadinessScanner), a .NET global tool I have just published to NuGet. It reads your solution and reports what will block or complicate a CMS 13 upgrade across four places people tend to check separately: C# source, configuration, project files, and the NuGet packages you depend on.
What it does
Point it at a solution, a project, or a directory of an Optimizely CMS 12 codebase, and it reports the breaking changes, deprecated APIs, and configuration drift that will block or complicate an upgrade to CMS 13, before you start the upgrade.
cms13-scan ./MySite.sln --exclude-tests
It prints a console summary with counts of Blockers, Warnings, and Info findings, an overall readiness score, and file and line references for each finding, with a suggested fix.
Why it does not use AI
This is the bit I actually want to make the case for, because it was a deliberate choice, not a missing feature.
It would have been easy to wire this up as a thin wrapper around an LLM: dump the codebase into a prompt, ask it to find CMS 13 breaking changes, get something back. I did not want that, for a few reasons that matter more the bigger the codebase gets.
- Deterministic output - the same codebase scanned twice gives you the same findings, the same line numbers, the same score. That matters when you are comparing a baseline before and after a fix, or gating a build in CI. Non-deterministic output in a gate is worse than no gate.
- No tokens, no API key, no bill - there is nothing to meter. You are not paying per file, per scan, or per line of code. Run it a hundred times a day against a hundred solutions and the cost is the same: zero.
- Your code never leaves your machine - a lot of Optimizely solutions sit behind strict client NDAs. Sending source code to a third-party model to ask "is this obsolete" is a non-starter for plenty of teams, and rightly so. This tool parses C#, XML, and JSON locally, and only reaches the network at all if you explicitly opt into
--check-packagesor--check-api, and even then it is just hitting your configured NuGet feeds, not a model. - Every finding traces back to a rule you can read -
OPT13-002is always "synchronous Find query execution", triggered by a specific member access pattern defined inrules.json. There is no "the model thought this looked risky." If a finding is wrong, you can go look at the rule, see exactly why it fired, and either fix your code or suppress that specific rule with a reason. - It is fast - a syntax-only pass over a large solution takes seconds, because it is not making a round trip per file to an inference endpoint.
Note: none of this means AI tools are bad for migration work. They are genuinely useful for things like "rewrite this specific obsolete call to the new API" once you know where to look. This tool is for the "where do I even need to look" step, which is a static analysis problem, not a reasoning problem, and static analysis problems are better solved with a fast, deterministic, rule-based scanner.
How it works
By default the scanner parses C#, XML, and JSON files without executing anything, and it is aware of Directory.Build.props, so it picks up properties and package references defined further up the folder tree instead of reporting false "missing" findings.
There are three opt-in levels of depth:
- Syntax matching (default) - fast pattern matching against known obsolete members and namespaces. Anything that can only be confirmed by resolving the actual symbol is reported as an unverified Warning rather than a Blocker, to avoid false positives.
- --semantic - compiles your projects via MSBuild to resolve real symbols and types, which lets findings like
OPT13-007(obsolete API usage) get confirmed and escalated to Blocker, or cleared entirely. This needs the solution to already be restored. - --check-packages / --check-api - reads your
nuget.config, checks direct package references against your target CMS major version, and downloads the actual CMS 13 assemblies to check for APIs that are missing or marked[Obsolete(error: true)].
What it checks
Thirteen rules ship out of the box, covering the usual suspects for a CMS 12 to CMS 13 move:
- OPT13-001 / 011 / 012 - direct or transitive dependency on Search & Navigation (Find) packages and namespaces, which are not supported on CMS 13.
- OPT13-002 - synchronous Find query execution, like
.GetResult()orSearchClient.Instance. - OPT13-003 -
ContentArea.FilteredItems, which is[Obsolete(error: true)]in CMS 13. - OPT13-004 - deprecated
FilterAccess.QueryDistinctAccess*calls. - OPT13-005 - legacy
EPiServer:Findconfig sections and hardcoded Graph credentials sitting inappsettings*.jsonorweb.config. - OPT13-006 / 009 - missing
TreatWarningsAsErrors, and target frameworks that are not on .NET 10 or later. - OPT13-007 / 013 - any
[Obsolete]Optimizely API usage, and types or members that are gone outright in CMS 13 assemblies (needs--semantic). - OPT13-008 -
ISiteDefinitionRepository/SiteDefinitionusage, flagged for an application-model review rather than as a hard blocker. - OPT13-010 - third-party package dependencies that are incompatible with, or undetermined for, CMS 13.
Each rule carries a severity (Blocker, Warning, or Info), and the readiness score is calculated off the breadth of distinct rules triggered rather than the raw count of occurrences, so one file with fifty of the same obsolete call does not tank the score harder than fifty files each with one different problem:
Score = 100 - (distinct Blocker rules x 20) - (distinct Warning rules x 8) - (distinct Info rules x 2)
If a built-in rule does not fit your codebase, or you want to add your own, --rules <file> lets you merge or override rules using the same kinds the built-ins use (ProjectPackageReference, MemberAccessPattern, ProjectFileMustContain, IdentifierUsage, ProjectTargetFramework). --list-rules prints the effective set so you can see exactly what is active.
Install it
dotnet tool install --global OptimizelyCms13ReadinessScanner
Or scoped to a repo with a tool manifest:
dotnet new tool-manifest
dotnet tool install OptimizelyCms13ReadinessScanner
# run via: dotnet cms13-scan
It targets .NET 8 but ships with RollForward=Major, so it runs fine on a machine with only .NET 10 installed. The solution you are scanning can be on any framework, since the scanner inspects it out of process.
Using it
Quick first look, syntax only, no restore needed:
cms13-scan ./MySite.sln --exclude-tests
Full accurate run, after dotnet restore:
cms13-scan ./MySite.sln --semantic --check-packages --check-api --exclude-tests --no-fail --output-markdown cms13-report.md
CI build gating against a baseline, so only new findings fail the build:
cms13-scan ./MySite.sln --baseline baseline.json --output-sarif cms13.sarif
The SARIF output plugs straight into GitHub code scanning or Azure DevOps PR annotations, so findings show up inline on a pull request instead of buried in a build log. Exit codes are plain: 0 clean, 1 new blockers found, 2 path does not exist.
Findings can also be suppressed inline when you have a reason to:
// cms13-scan:disable OPT13-003
// cms13-scan:disable-next-line OPT13-002
Suppressed findings are dropped from the score and the exit code, but stay listed in the report for visibility, so a suppression is not the same as sweeping it under the rug.
What this is not
I want to be direct about this rather than let the tool oversell itself: cms13-scan is single-purpose. It finds and reports breaking changes. It does not rewrite your code, it does not perform the upgrade, it does not touch your Opti ID, Graph, or hosting configuration, and it is not aware of your specific solution's architecture or customizations beyond what a static scan can see.
Optimizely has its own homebrew migration and analysis tooling for CMS upgrades, backed by people who do this for a living and have seen far more upgrade paths than a side-project scanner ever will. If you are planning an actual CMS 12 to CMS 13 migration, especially anything non-trivial, get in touch with Optimizely directly so you have access to the right tools, guidance, and expertise for the full job. Use cms13-scan as a quick, free, local first pass to scope the size of the problem before that conversation, not as a replacement for it.
Compatibility
- Requires the .NET 8 SDK or newer to install and run.
- Scans CMS 12 codebases targeting CMS 13 by default (
--cms-target-majordefaults to13, but can be changed). - Semantic and API checks need the target solution already restored.
Links
- GitHub repository: OptimizelyCms13ReadinessScanner
- NuGet package: OptimizelyCms13ReadinessScanner
It is MIT licensed, so pull requests, new rules, and bug reports are all welcome on GitHub. If it saves your team even a day of manual grepping before an upgrade kickoff, it has done its job.


