A cross-platform Roslyn-based tool that generates an object model diagram of a set of C# source files and/or .NET assemblies
If you like this library and use it a lot, consider sponsoring me. Anything helps and encourages me to keep going.
See here for details: https://github.com/sponsors/dotMorten
Run the following command from commandline (requires .NET Core 2.1 installed):
dotnet tool install --global dotMorten.OmdGenerator
generateomd --source=[source folder|file|project] --compareSource=[oldSource folder|file|project] --gitRepo=[repo path or url] --sourceRef=[commit|branch|tag] --compareRef=[commit|branch|tag] --preprocessors=[defines] --format=[html|md] --showPrivate -showInternal
Required parameters:
source Specifies folders, source files, or .csproj files to include for the object model.
Separate with ; for multiple items
or
assemblies Specifies a set of assemblies to include for the object model.
Separate with ; for multiple assemblies, or use wildcards
Optional parameters:
compareSource Specifies a folder of old source to compare and generate a diff model
This can be useful for finding API changes or compare branches
gitRepo Specifies a local git repository path or remote git URL to resolve source paths from when using `--sourceRef` or `--compareRef`
sourceRef Specifies the git commit, branch, or tag to use for the source side of a diff
compareRef Specifies the git commit, branch, or tag to compare the current source or /sourceRef against
compareAssemblies Specifies a set of old assemblies to compare and generate a adiff model.
Separate with; for multiple assemblies, or use wildcards
format Format to generate:
`html` a single html output (html is default)
`md` for markdown you can copy-paste to for instance GitHub.
Specify multiple with a semicolon seperator, and use an output filename without extension
preprocessors Define a set of preprocessors values. Use ; to separate multiple
exclude Defines one or more strings that can't be part of the path Ie '*/Samples/*;*/UnitTests/*'
(use forward slash for folder separators)
regexfilter Defines a regular expression for filtering on full file names in the source
showPrivate Show private members (default is false)
showInternal Show internal members (default is false)
output Filename to write the output to (extension is optional, but exclude the extension if you specify multiple formats)
tfm Target Framework to use against NuGet packages or multi-targeted source projects.
When omitted for project inputs, the generator evaluates all supported target frameworks,
merges the resulting APIs, and annotates APIs that only exist on some TFMs.
nugetDependencies Dependency package ID patterns to include for /nuget and /compareNuget.
Separate with ; for multiple patterns and prefix with ! to exclude a package or subtree.
As an alternative you can also reference a NuGet package to your class library, and set up a post-build script to generate an Object Model Diagram HTML file:
Install-Package dotMorten.OmdGenerator
Add the following to your project:
<Target Name="GenerateObjectModel" AfterTargets="Compile">
<Exec Command="dotnet "$(DotNetOMDGeneratorToolPath)" /source="@(Compile)" /preprocessors="$(DefineConstants)" /output="$(OutputPath)$(TargetName)"" WorkingDirectory="$(ProjectDir)" />
</Target>
An example of a generated output for all of .NET can be found here.
It can also be used to compare two folders (for instance two separate branches) and only show changes to the API. Here's an example of .NDesktop APIs .NET 8 vs .NET 9.
Generate OMD for .NET source code, and ignore ref and test folders:
generateomd --source=src/libraries --exclude="*/ref/*;*/tests/*;*/perftests/*"
Compare .NET CoreFX main with v2.0.0 repo branches directly from their Github zipped downloads:
generateomd --source=https://github.com/dotnet/corefx/archive/master.zip --compareSource=https://github.com/dotnet/corefx/archive/release/2.0.0.zip --exclude="*/ref/*;*/tests/*;*/perftests/*"
Generate OMD from the files actually included by a project instead of every .cs file in the folder:
generateomd --source=c:\github\MyLibrary\src\MyLibrary\MyLibrary.csproj
Select the target framework when the project is multi-targeted:
generateomd --source=c:\github\MyLibrary\src\MyLibrary\MyLibrary.csproj --tfm=net8.0
Merge APIs across multiple project files or across all TFMs of a multi-targeted project when --tfm is omitted:
generateomd --source=c:\github\MyLibrary\src\MyLibrary\MyLibrary.csproj;c:\github\MyLibrary\src\MyLibrary.Wpf\MyLibrary.Wpf.csproj
APIs that are only available on some target frameworks are annotated in the output:
public class Net8OnlyType [TFMs: net8.0]
Compare the current checkout against a tagged release from the same git repository:
generateomd --source=c:\github\dotnet\runtime\src\libraries\System.Text.Json\src --compareRef=v8.0.0
Compare a project file against a tag from the same git repository:
generateomd --source=c:\github\dotnet\runtime\src\libraries\System.Text.Json\src\System.Text.Json.csproj --compareRef=v8.0.0 --tfm=net8.0
Compare two commits from a remote git repository by resolving the selected repo-relative source path from both refs:
generateomd --source=src/libraries/System.Text.Json/src --gitRepo=https://github.com/dotnet/runtime.git --sourceRef=9f4f4cf --compareRef=v8.0.0
What's new in Xamarin.Forms? Compare assemblies from the nuget cache:
generateomd --assemblies=%USERPROFILE%\.nuget\packages\xamarin.forms\3.3.0.912540\lib\netstandard2.0\*.dll --compareAsssemblies=%USERPROFILE%\.nuget\packages\xamarin.forms\3.2.0.871581\lib\netstandard2.0\*.dll
Compare a meta package and include only matching dependency packages in the analysis:
generateomd --nuget=Microsoft.WindowsAppSDK:1.0.0 --compareNuget=Microsoft.WindowsAppSDK:0.8.0 --tfm=net8.0-windows10.0.19041.0 --nugetDependencies="Microsoft.WindowsAppSDK.*;!Microsoft.WindowsAppSDK.Tests.*"
You can use the git ref comparison support in a pull request workflow to generate the markdown diff between the PR head commit and the PR base commit, then post the result as a PR comment.
In the workflow below:
--sourceis the repo-relative path to the C# source you want to analyze.sourceRefis the PR head SHA.compareRefis the PR base SHA.- When the diff becomes empty again, the workflow updates its existing comment to say so.
- If the workflow has never commented on the PR before and there are no API changes, it does nothing.
name: PR API diff
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
api-diff:
runs-on: ubuntu-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install generator
run: dotnet tool install --global dotMorten.OmdGenerator
- name: Generate API diff
shell: bash
run: |
generateomd \
--source=src/MyLibrary/MyLibrary.csproj \
--gitRepo=${{ github.server_url }}/${{ github.repository }} \
--sourceRef=${{ github.event.pull_request.head.sha }} \
--compareRef=${{ github.event.pull_request.base.sha }} \
--format=md \
--output=api-diff
- name: Check whether API changes were found
id: api_diff
shell: bash
run: |
if grep -q '^namespace ' api-diff.md; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi
- name: Create or update PR comment
uses: actions/github-script@v7
env:
COMMENT_MARKER: <!-- dotnet-omd-api-diff -->
HAS_CHANGES: ${{ steps.api_diff.outputs.has_changes }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const marker = process.env.COMMENT_MARKER;
const hasChanges = process.env.HAS_CHANGES === 'true';
const diff = fs.readFileSync('api-diff.md', 'utf8').trim();
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100
});
const existing = comments.find(comment =>
comment.user.type === 'Bot' && comment.body.includes(marker));
if (!hasChanges && !existing) {
return;
}
const body = hasChanges
? [
marker,
'## API changes',
'',
diff
].join('\n')
: [
marker,
'## API changes',
'',
'No API changes are currently detected in this pull request.'
].join('\n');
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body
});
}If you prefer to remove the old bot comment instead of replacing it with a "no API changes" message, change the !hasChanges branch to call github.rest.issues.deleteComment(...) when existing is found.
If you want the comment to cover multiple source roots, separate them with semicolons in --source, for example --source=src/MyLibrary;src/MyOtherLibrary.
If your PRs come from forks and you want to comment on those PRs too, you may need a pull_request_target workflow instead of pull_request. Use that carefully, since it runs with broader repository permissions.
You can also use the same git ref support to append API changes to a GitHub Release. The workflow below runs when a release is published, finds the previous published release, compares the two tags, and inserts or updates a marked API diff section in the release body.
In the workflow below:
sourceis the repo-relative path to the C# source you want to analyze.sourceRefis the current release tag.compareRefis the previous published release tag.- If there is no previous release, the workflow exits without changing the release body.
- If the diff is empty, the workflow writes a short "no API changes" note into the marked release section.
name: Release API diff
on:
release:
types: [published]
permissions:
contents: write
jobs:
api-diff:
runs-on: ubuntu-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install generator
run: dotnet tool install --global dotMorten.OmdGenerator
- name: Find previous published release
id: previous_release
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
const currentReleaseId = context.payload.release.id;
const publishedReleases = releases.filter(release => !release.draft);
const currentIndex = publishedReleases.findIndex(release => release.id === currentReleaseId);
if (currentIndex === -1 || currentIndex === publishedReleases.length - 1) {
core.setOutput('tag', '');
return;
}
core.setOutput('tag', publishedReleases[currentIndex + 1].tag_name);
- name: Generate API diff
if: steps.previous_release.outputs.tag != ''
shell: bash
run: |
generateomd \
--source=src/MyLibrary.csproj \
--gitRepo=${{ github.server_url }}/${{ github.repository }} \
--sourceRef=${{ github.event.release.tag_name }} \
--compareRef=${{ steps.previous_release.outputs.tag }} \
--format=md \
--output=api-diff
- name: Check whether API changes were found
if: steps.previous_release.outputs.tag != ''
id: api_diff
shell: bash
run: |
if grep -q '^namespace ' api-diff.md; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi
- name: Update release notes
if: steps.previous_release.outputs.tag != ''
uses: actions/github-script@v7
env:
SECTION_MARKER: <!-- dotnet-omd-api-diff -->
HAS_CHANGES: ${{ steps.api_diff.outputs.has_changes }}
PREVIOUS_TAG: ${{ steps.previous_release.outputs.tag }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const marker = process.env.SECTION_MARKER;
const hasChanges = process.env.HAS_CHANGES === 'true';
const previousTag = process.env.PREVIOUS_TAG;
const diff = fs.readFileSync('api-diff.md', 'utf8').trim();
const release = context.payload.release;
const existingBody = release.body || '';
const section = hasChanges
? [
marker,
`## API changes since ${previousTag}`,
'',
diff
].join('\n')
: [
marker,
`## API changes since ${previousTag}`,
'',
'No API changes are detected since the previous release.'
].join('\n');
const markerIndex = existingBody.indexOf(marker);
const body = markerIndex >= 0
? existingBody.slice(0, markerIndex).trimEnd() + '\n\n' + section
: (existingBody.trim()
? existingBody.trimEnd() + '\n\n' + section
: section);
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: release.tag_name,
name: release.name,
body,
draft: release.draft,
prerelease: release.prerelease
});If you would rather omit the API section entirely when no changes are found, change the section assignment so the !hasChanges branch returns an empty string and skip the updateRelease call when there is no existing marker.
If you want to compare against the previous stable release only, filter out prereleases in the publishedReleases list before picking the previous tag.
