Disclaimer: Some of the things described in this post can probably be accomplished with the .NET Compiler Platform (i.e. Roslyn). I haven’t had a chance to play around with that yet, so I use Mono.Cecil for similar tasks.

Preview

By the end of this blog post, we’ll be able to create cool dependency graphs based on our .NET classes like the following:

Background

Static code analysis is the process of investigating the structure of a program without actually executing it (as opposed to things that happen at runtime like reflection, logging, running unit tests, or debugging). This can be done via examining an Abstract Syntax Tree (AST) generated by parsing the C#/VB/F# source code. It can also be accomplished by analyzing the Common Intermediate Language (CIL) bytecode generated by compiling your .NET applications. We will be using the latter strategy.

Note: CIL bytecode is sometimes also called IL, MSIL (Microsoft Intermediate Language), or just “bytecode”.

CIL bytecode is what gets generated when you compile a .NET application. This bytecode then gets run using a CIL compatible runtime (.NET Framework, .NET Core, or Mono).

If you’ve never seen CIL Bytecode before, try installing an extension for Visual Studio called ILSpy (coincidentally, this tool was written using Mono.Cecil, the topic of this blog post). This tool will allow you to right click on a method or class in Visual Studio and select “Open code in ILSpy” from the context menu. A window will appear with the IL instructions that correspond to that method or class.

Mono.Cecil

Mono.Cecil is a library that analyzes a file containing IL instructions (a .dll or .exe assembly file), and creates a traversable data structure describing that assembly. Despite its name, it can be used for IL code compiled to run against the .NET Framework, not just Mono. It also allows for the rewriting of IL instructions. So, you can modify the assembly to customize its behavior even after it has been compiled. This is sometimes referred to as IL Weaving, and is how Aspect Oriented Programming (AOP) is accomplished. Cecil is a very popular library, and is used in all sorts of open source and commercial projects. See a list here.

Sample Project to Analyze

We will be using Visual Studio 2017 Community Edition. You could use Visual Studio Code with modifications to some of the steps.

If we’re going to be writing code that analyzes .NET code, we’re going to need some an example project to analyze. Instead of writing this example code, I decided to use an open source project called SmartStoreNET. It is an e-commerce shopping cart solution implemented using ASP.NET MVC.

To get our work environment set up, we need to complete the following steps:

Clone the SmartStoreNET repository: Link to the repository Navigate to SmartStoreNET\src\ and open SmartStoreNET.sln in Visual Studio. Restore the NuGet packages, and build the solution. We now have an entire directory of assemblies that we can play around with located at SmartStoreNET\src\Presentation\SmartStore.Web\bin .

Setting Up Our Analyzer Project

We will be using F# to do our analysis. I like to use F#, because it allows you to write in a functional style while providing nice integration with the .NET platform. This gives your F# code the ability to interop with libraries that were originally intended to be used with C#. An excellent source to learn more about F# is the F# for Fun and Profit website authored by Scott Wlaschin (If you search his name on YouTube, he also has some great presentations available for viewing).

Open Visual Studio, and select File > New > Project . Within the New Project window, navigate to the “Visual F#” section and select “Console Application”. Choose a name and location for your project. I chose to name mine “Mono.Cecil_FunTricks”. The paths in the rest of this post will assume this project name.

Note: If you don’t already have F# language tools installed for Visual Studio, you will have to do so. Within Visual Studio, go to Tools > Get Tools and Features . This will open the Visual Studio Installer window. Under the Individual Components tab, navigate to the Development Activities header and make sure “F# language support” is checked. Then click the “Modify” button located on the bottom right corner of the window. All Visual Studio windows need to be closed for the installer to work.

I will be using Paket to manage my dependencies. To read more about the benefits of Paket, check out the FAQ. To set up Paket:

Navigate in the file explorer to the root directory of our solution: Mono.Cecil_FunTricks\ . Create a directory named .paket . You may get an error saying “You must type a file name.” You get this error whenever you try creating a directory that starts with a period character. To circumvent this, create a directory named .paket. . Windows will automatically truncate the last period character leaving us with our desired directory name. (I got that tip here after having some trouble with it). Download the latest paket.bootstrapper.exe file into the .paket directory. Run .paket/paket.bootstrapper.exe . It will download the latest paket.exe into the .paket directory. Create a file named paket.dependencies in the root directory of our solution: Mono.Cecil_FunTricks\ . Add the following lines to this file: source https://nuget.org/api/v2 , nuget Mono.Cecil Navigate to the directory containing your F# project ( .fsproj ), and create a file named paket.references in it. Add the following line to this file: Mono.Cecil .

So, we should have the following:

paket.dependencies source https://nuget.org/api/v2nuget Mono.Cecil

paket.references Mono.Cecil

Try running the following command from the root of your solution to install your packages:

command line .paket/paket.exe install

We should now be ready to start modifying and building our F# source code!

A Quick Mono.Cecil Test

We will do a quick test, just to make sure that we can use Cecil, and our project is set up correctly. Copy and paste the following F# content into Mono.Cecil_FunTricks\Mono.Cecil_FunTricks\Program.fs .

Program.fs open Mono.Cecillet openFile ( filePath : string ) = AssemblyDefinition.ReadAssembly filePath [< EntryPoint >] let main argv = let assembly = openFile argv .[ 0 ] 0 (* return an integer exit code *)

Note: F# is a whitespace-significant language. That means tabs/spaces need to be correct for the code to compile. To see your whitespace in Visual Studio, press ctrl-r + ctrl-w .

Now in Visual Studio, right click on the project Mono.Cecil_FunTricks in the Solution Explorer, and select Properties from the context menu. Navigate to Debug > Start Options > Command line arguments: and enter the following path into the text area: {absolute path to the SmartStoreNET repository}\SmartStoreNET\src\Presentation\SmartStore.Web\bin\SmartStore.Web.dll . Note: Make sure you fill in the path to the SmartStoreNET repository!

Add a breakpoint within the main function, and debug. You should see a value if you hover over the assembly value. We are now successfully statically analyzing our sample assembly!

Some Plumbing Code

In Mono.Cecil you must explicitly load all of the assemblies that you want to query the types of. This has to be done manually, but it is usually pretty easy because all of the assemblies you want to inspect reside in the same bin directory (although sometimes you do have to hunt around for them, or load from the GAC). Add the following assembly loader module to your F# project:

AssemblyLoader.fs module AssemblyLoaderopen Mono.Cecilopen System.IOlet LoadAssembly ( assemblyPath : string ) = AssemblyDefinition.ReadAssembly assemblyPathlet LoadAllAssembliesByPrefix prefix binDirectoryPath = Directory.GetFiles ( binDirectoryPath , sprintf "%s*" prefix ) |> Array.filter ( fun f -> f . EndsWith ".dll" || f . EndsWith ".exe" ) |> Array.map LoadAssembly

Note: AssemblyLoader.fs must be higher in your Solution Explorer than Program.fs and any other files that reference it. In F#, files must be in the correct order, and modules/functions/values can only be referenced if they’ve already been defined. Move a file in Visual Studio, by highlighting it in the Solution Explorer, and typing alt-up or alt-down

Now change our Program.fs to call the new assembly loading code.

Program.fs open Mono.Cecil [< EntryPoint >] let main argv = let prefix = "SmartStore" (* We want to exclude SmartStore.Licensing, because it's a weird obfuscated assembly that we don't have the source for, so it isn't relevant to our analysis. *) let assemblies = ( AssemblyLoader.LoadAllAssembliesByPrefix prefix argv .[ 0 ]) |> Array.filter ( fun a -> a . MainModule.Name <> "SmartStore.Licensing.dll" ) (* Write to the console to test our assembly loading code! *) assemblies |> Array.map ( fun a -> a . MainModule.Name ) |> Array.iter Console.WriteLine 0 (* return an integer exit code *)

Change the debug command line argument to be the bin directory of the SmartStore.Web project (it was a specific assembly before): {absolute path to the SmartStoreNET repository}\SmartStoreNET\src\Presentation\SmartStore.Web\bin\ . You should get the following output when debugging your program:

Console Output SmartStore.Admin.dllSmartStore.Core.dllSmartStore.Data.dllSmartStore.Services.dllSmartStore.Web.dllSmartStore.Web.Framework.dll

We can now start iterating through these loaded assemblies, and finding our desired Types.

The rest of this post will take the form of different types of analysis you can do using Mono.Cecil.

Using Mono.Cecil as a Super-Charged Query Engine

During development, sometimes you have conditions in your head about some class you want to find in the codebase. Maybe you know something about its name, generic parameters, inheritance relationships, composition relationships, constructor signatures, etc. Sometimes the tools included with Visual Studio or ReSharper can’t do exactly what is necessary to hunt down this mystery class.

Mono.Cecil is ultimately flexible in finding any class/property/method/etc in your code based on any conditions you can dream up (given that they are representable in IL, or can be inferred from the IL). The primary cost, is that you must write these queries by hand. An out-of-the-box solution might have your query already baked in.

Find All Types Implementing Some Interface

We’ll start out with a simple example. Let’s find all classes and interfaces that implement the IDisposable interface in our assemblies. We’ll create another file to contain our custom queries. Add the following file to your solution:

AssemblyQueries.fs module AssemblyQueriesopen Mono.Cecillet FindAllTypesImplementingInterface interfaceFullName ( assembly : AssemblyDefinition ) = assembly . Modules |> Seq.collect ( fun m -> m . Types ) |> Seq.filter ( fun t -> t . Interfaces |> Seq.exists ( fun i -> i . FullName = interfaceFullName ))

We can now add the call to the disposable type querying code to the main program:

Program.fs open Mono.Cecil [< EntryPoint >] let main argv = let prefix = "SmartStore" (* We want to exclude SmartStore.Licensing, because it's a weird obfuscated assembly that we don't have the source for, so it isn't relevant to our analysis. *) let assemblies = ( AssemblyLoader.LoadAllAssembliesByPrefix prefix argv .[ 0 ]) |> Array.filter ( fun a -> a . MainModule.Name <> "SmartStore.Licensing.dll" ) let disposableTypes = assemblies |> Seq.collect ( AssemblyQueries.FindAllTypesImplementingInterface typeof < System.IDisposable >. FullName ) (* Write to the console to test our interface finding code! *) disposableTypes |> Seq.map ( fun t -> t . FullName ) |> Seq.iter Console.WriteLine 0 (* return an integer exit code *)

Debug your solution, and you should see all of the following disposable types:

Console Output SmartStore.DisposableObjectSmartStore.Utilities.ActionDisposableSmartStore.Utilities.Threading.ReadLockDisposableSmartStore.Utilities.Threading.UpgradeableReadLockDisposableSmartStore.Utilities.Threading.WriteLockDisposableSmartStore.Core.Logging.IChronometerSmartStore.Core.Logging.NullChronometerSmartStore.Core.IO.ILockFileSmartStore.Core.IO.LockFileSmartStore.Core.Data.ITransactionSmartStore.Core.Data.DbContextScopeSmartStore.Services.DataExchange.Excel.ExcelDataReaderSmartStore.Services.DataExchange.Csv.CsvDataReaderSmartStore.Services.DataExchange.Export.ExportXmlHelperSmartStore.Services.DataExchange.Export.IExportDataSegmenterProviderSmartStore.Services.DataExchange.Export.ExportDataSegmenter`1SmartStore.Web.Models.Catalog.ProductSummaryModelSmartStore.Web.Framework.WebApi.AutofacWebApiDependencyResolverSmartStore.Web.Framework.WebApi.AutofacWebApiDependencyScope

Note: When a type name in .NET has a backtick and a number (e.g. ExportDataSegmenter`1 in the above list), that means it represents a generic type. The number following the backtick is the number of generic type arguments supported by that type.

A Little Refactoring

F# being a functional language allows us to be a little bit more generic in our implementation. Take a look at the following function. Notice how it’s very similar to our interface finding code, but could handle any given query based on a passed in predicate. This will help with code reuse.

F# let FilterTypes predicate ( assembly : AssemblyDefinition ) = assembly . Modules |> Seq.collect ( fun m -> m . Types ) |> Seq.filter predicate

Now we can rewrite our previous module using this new helper function. Now, FindAllTypesImplementingInterface has an inner function findMatchingInterfaces . This inner function acts as the predicate for our helper function. I think it’s much cleaner this way:

AssemblyQueries.fs module AssemblyQueriesopen Mono.Cecillet FilterTypes predicate ( assembly : AssemblyDefinition ) = assembly . Modules |> Seq.collect ( fun m -> m . Types ) |> Seq.filter predicatelet FindAllTypesImplementingInterface interfaceFullName ( assembly : AssemblyDefinition ) = let findMatchingInterfaces ( t : TypeDefinition ) = t . Interfaces |> Seq.exists ( fun i -> i . FullName = interfaceFullName ) assembly |> FilterTypes findMatchingInterfaces

All Domain Objects Have an Entity Framework Mapping

Let’s try another querying example. In SmartStore.NET, EntityFramework acts as the ORM. It maps data residing in SQL tables to special classes called “Domain Classes”. These are supposed to represent the application’s Domain Model. In order to create this mapping, SmartStore.NET has special mapping classes that use Entity Framework’s Fluent API. Let us suppose that every domain class must have a matching mapping class (this isn’t actually true in the case of SmartStore.NET, but let’s just assume it for the sake of the example). If the mapping class is missing, the system will not work correctly.

Mono.Cecil can help us find out if the domain/mapping classes get out of sync. This type of query shows how flexible Mono.Cecil can be. It consists of the following steps:

Identify the domain classes. In SmartStore.NET, domain classes are all located in the SmartStore.Core.Domain namespace. For each domain class, check to make sure there is a corresponding Entity Framework Fluent API mapping. A class is considered a mapping class if it is a concrete subclass of the EntityTypeConfiguration<TEntityType> class. If no mapping exists, annotate that fact in some way.

You could imagine a query like this being written as a gate check in a deployment pipeline. If a domain class exists without an entity mapping (or vice versa), we consider that a high risk scenario for a bug, so we fail the deployment.

Let’s start with Step 1 from above. Add the following functions to your AssemblyQueries module:

AssemblyQueries.fs let FindAllTypesInNamespace namespace' ( assembly : AssemblyDefinition ) = assembly |> FilterTypes ( fun t -> t . Namespace.StartsWith namespace' ) let FindAllConcreteClassesInNamespace namespace' ( assembly : AssemblyDefinition ) = assembly |> FindAllTypesInNamespace namespace' |> Seq.filter ( fun t -> t . IsClass && not t . IsAbstract )

Note: in F#, you are allowed to use a single apostrophe ' in a value/function name. In this case, I named a parameter namespace' to avoid a conflict with “namespace” which is a keyword in F#

Now let’s try step 2. Add the following function to your AssemblyQueries module:

AssemblyQueries.fs let QueryMissingEFMappings ( assemblies : AssemblyDefinition[] ) = let efMapperClasses = assemblies |> Array.toSeq |> Seq.collect ( FilterTypes ( fun t -> match t . BaseType with | null -> false | baseType -> baseType . FullName.StartsWith "System.Data.Entity.ModelConfiguration.EntityTypeConfiguration`1" )) let domainClassesMapped = efMapperClasses |> Seq.choose ( fun t -> match t . BaseType with | null -> None | baseType -> match baseType with | :? GenericInstanceType as genericInstance -> let genericArgs = Seq.toList genericInstance . GenericArguments match genericArgs with | [ t ] -> Some t | _ -> None | _ -> None ) let domainClasses = assemblies |> Array.toSeq |> Seq.collect ( fun a -> a |> FindAllConcreteClassesInNamespace "SmartStore.Core.Domain" ) let domainClassesMissingMappers = domainClasses |> Seq.filter ( fun t -> domainClassesMapped |> Seq.exists ( fun m -> m . FullName = t . FullName ) |> not ) domainClassesMissingMappers

The above function may seem complicated, but it isn’t really if you break it down. Intermediate results are collected, and then a simple “exists” check is made to see if something is in one collection, but not the other. Some of the “match” cruft is because we are operating with a library intended to be used with C# (where nulls and type casting are commonplace). If I wanted to make the implementation cleaner, I might create a mapping from Mono.Cecil’s API to friendlier F# types (or even just Active Patterns to make my matches nicer).

Now we can alter our Program.fs to call our new query.

Program.fs open Mono.Cecilopen System [< EntryPoint >] let main argv = let prefix = "SmartStore" (* We want to exclude SmartStore.Licensing, because it's a weird obfuscated assembly that we don't have the source for, so it isn't relevant to our analysis. *) let assemblies = ( AssemblyLoader.LoadAllAssembliesByPrefix prefix argv .[ 0 ]) |> Array.filter ( fun a -> a . MainModule.Name <> "SmartStore.Licensing.dll" ) let domainClassesMissingMappers = AssemblyQueries.QueryMissingEFMappings assemblies Console.WriteLine "Domain classes missing mappers:" domainClassesMissingMappers |> Seq.map ( fun t -> sprintf " \t %s" t . FullName ) |> Seq.iter Console.WriteLine 0 (* return an integer exit code *)

You can see that there are actually quite a lot of domain classes that don’t have a corresponding mapper classes. I created an imaginary rule that all domain classes must have mapper classes for purposes of working through an example. In SmartStore.NET, this actually isn’t the case. Some domain classes aren’t loaded through EF at all, and other domain classes are properties on other domain classes that get mapped by the container class’ EF mapper.

You could write similar queries to enforce business rules applicable to your code base.

Build a Class Dependency Graph

In C#, a class can contain a reference to another class via a field or property. This is called composition in object oriented programming. It is used when two classes share a “HAS A” relationship with each other (for example a Dog class HAS A Tail class). The class of the referenced property can then have its own fields/properties, creating a graph of vertices (.NET classes) and directed edges (an edge from V1 to V2 means that V1 contains a reference to V2). You can brush up on some basic graph theory here. I also like this MIT lecture taught by Tom Leighton (co-founder and current CEO of Akamai, one of the top CDNs in the world!).

Sometimes it can be helpful to visualize this dependency graph to see the relationships between different classes in your application (or even classes in libraries that you pull in). For example, you may be surprised that a top level class includes a reference indirectly through nested composition to some other class. Or you may be surprised to find cycles in this graph. Or it can be helpful to see how deeply nested things get. The more nesting there is, the harder it is to reason about your application. It can create subtle links between parts of your application you thought were totally separated.

I am going to use Cytoscape.js, along with Dagre as my graph visualization library. I need to traverse the properties of a Type using Mono.Cecil, and create a data structure that Cytoscape.js can consume. I should be able to use a simple recursive function for this, with the caveat that I must check for types that have already been visited to prevent infinite recursion (i.e. cycles).

Create a new file named DependencyGraph.fs . I began, by adding some types that will map one-to-one to the json objects expected by Cytoscape.js:

DependencyGraph.fs module DependencyGraph (* These objects mirror the Cytoscape.js api, and will be serialized to json *) type GraphObjectData = { id : string ; name : string ; source : string ; target : string ;} type GraphObject = { group : string ; data : GraphObjectData ;}

These types aren’t very idiomatic for F#, but they are good enough for this simple example. The goal is to fold over the Mono.Cecil Type and its properties and return a type of GraphObject list . That will be able to be serialized to json, and consumed by Cytoscape.js.

Because we know we’ll be recursing over the properties of a type, let’s create a helper function to grab the properties of a Mono.Cecil TypeDefinition . In our example, we’ll only be interested in iterating over types in our namespace: SmartStore .

DependencyGraph.fs let GetProps ( t : TypeDefinition ) = t . Properties |> Seq.toList |> List.choose ( fun p -> if ( p . FullName.StartsWith "SmartStore" ) then Some p else None )

Here is an initial implementation of fold for our application:

DependencyGraph.fs (* This function is a fold over Type Properties *) let rec TraverseProps fType acc ( t : TypeDefinition ) = let recurse = TraverseProps fType let props = GetProps t match props with | [] -> fType acc t | ps -> let ts = ps |> List.map ( fun p -> p . PropertyType.Resolve() ) let newAcc = fType acc t ts |> List.fold recurse newAcc

Folding over a data structure is a pretty typical operation in functional programming. I modeled my fold based on the following example. Now, let’s try to write a function that generates a graph. What should the initial arguments to TraverseProps be? acc should definitely be an empty list [] . t should be the TypeDefinition for the type we want to generate the dependency graph for. What should our function look like? It has a function signature of 'a -> TypeDefinition -> 'a , where 'a is the type of our accumulator. Well, we already know that we need the entire recursive fold function to return type GraphObject list , so we really have a function of type GraphObject list -> TypeDefinition -> GraphObject list . We know that the function will need to somehow create GraphObject values and append them to our accumulator linked list. Let’s try to see what something like that would look like:

DependencyGraph.fs let GenerateDependencyGraph ( rootNode : TypeDefinition ) = rootNode |> TraverseProps ( fun elements t -> let nodeData = { id = "Should we generate an id here?" ; name = t . Name ; source = "" ; target = "" ; } let node = { group = "nodes" ; data = nodeData ; } let edgeData = { id = "???" ; name = "???" ; source = "???" ; target = "???" ; } let edge = { group = "edges" ; data = edgeData ; } if ( t = rootNode ) then node :: elements else node :: edge :: elements ) []

We seem to be missing some information in our implementation of fold ( TraverseProps ). We aren’t able to create the edges between nodes, because each call to our passed in function only has information about the current node. It has no context of the parent node, or even it’s own property Mono.Cecil metadata information (that was lost, when we called PropertyType.Resolve() ). Because each iteration of our algorithm only depends on information from the parent in the recursion, we just need to pass that information in.

Here is an updated fold implementation:

DependencyGraph.fs (* This function is a fold over Type Properties *) let rec TraverseProps fType acc ( t : TypeDefinition , propName : string , id : string , parentId : string ) = let recurse = TraverseProps fType let props = GetProps t match props with | [] -> fType acc ( t , propName , id , parentId ) | ps -> let ts = ps |> List.map ( fun p -> p . PropertyType.Resolve() , p . Name , System.Guid.NewGuid() . ToString() , id ) let newAcc = fType acc ( t , propName , id , parentId ) ts |> List.fold recurse newAcc

Notice how we’re passing in the property name, id of the current node being processed, and the parent node id. This will give us all of the information we need to build up our nodes/edges. In this case we’re using tuples to pass in this data. We also could have created a special record type just for this purpose. It’s just a matter of style. Here is an updated version of our graph generating function, which uses this newly passed in info. Notice how we’re using tuple pattern matching in the parameters. That is a nifty F# trick!

DependencyGraph.fs let GenerateDependencyGraph ( rootNode : TypeDefinition ) = ( rootNode , "" , System.Guid.NewGuid() . ToString() , "" ) |> TraverseProps ( fun elements ( t , propName , id , parentId ) -> let nodeData = { id = id ; name = t . Name ; source = "" ; target = "" ; } let node = { group = "nodes" ; data = nodeData ; } let edgeData = { id = System.Guid.NewGuid() . ToString() ; name = propName ; source = parentId ; target = id ; } let edge = { group = "edges" ; data = edgeData ; } if ( t = rootNode ) then node :: elements else node :: edge :: elements ) []

In order to create our dependency graph, we need to find a specific type within our assemblies by name. Add the following function to our queries module to accomplish this:

AssemblyQueries.fs let FindTypeByFullName fullName ( assembly : AssemblyDefinition ) = assembly |> FilterTypes ( fun t -> t . FullName = fullName )

We can now update our Program.fs to use this newly added functionality, but first we’re going to add a new dependency: JSON.NET . Open your paket.dependencies file and add the line nuget Newtonsoft.Json . Open up your paket.references file and add the line Newtonsoft.Json . Run the command .paket/paket.exe install . The dependency has been added to your project, and you can now serialize/deserialize json.

Program.fs open DependencyGraphopen Newtonsoft.Json [< EntryPoint >] let main argv = let prefix = "SmartStore" (* We want to exclude SmartStore.Licensing, because it's a weird obfuscated assembly that we don't have the source for, so it isn't relevant to our analysis. *) let assemblies = ( AssemblyLoader.LoadAllAssembliesByPrefix prefix argv .[ 0 ]) |> Array.filter ( fun a -> a . MainModule.Name <> "SmartStore.Licensing.dll" ) let type ' = assemblies |> Seq.collect ( fun a -> AssemblyQueries.FindTypeByFullName "SmartStore.Core.Domain.Common.Address" a ) |> Seq.exactlyOne let graph = GenerateDependencyGraph type' let json = JsonConvert.SerializeObject ( graph ) 0 (* return an integer exit code *)

The contents of the json value above will be our graph! I’m going to gloss over the implementation from a front end perspective, but here are a few dependency graphs using our code. The type names are on the nodes, and the property names are on the vertices. Be careful while scrolling. If you try to scroll within the bounding box of the graph, you will be interacting with the graph and not scrolling the page as intended.

Here is one for the Product class. It is pretty large, so try zooming in:

Oh no! We forgot to add code to detect cycles! This blog post is long enough, so I think that can wait for another day. Another oversight, is that we aren’t digging into generic types (like collections) to traverse those nested property types. A lot could be done to make this code more complete, but I think you get the gist of it.

Why Would We Go Through All This Effort?

It is true, that writing your own custom static analysis code is a lot more effort than just using an off the shelf solution. But, it doesn’t need to be one or the other! When your use case is covered by an existing product, use it. However, when you run into a scenario so specific to your application that no third party tool could possibly address it, break out Mono.Cecil and get coding.