Skip to content

Latest commit

 

History

277 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

equiv

Behavioural equivalence checker for migrated code.
It proves that before and after a migration behave the same, or shows you the input where they don't.

CI CodeQL Mutation testing SonarQube Cloud

Quality gate status Coverage Maintainability rating Reliability rating Security rating Bugs Vulnerabilities Code smells Lines of code

.NET 10 C# 14 Z3 SARIF 2.1.0 Coverage gate Warnings as errors License: BSL 1.1

Last commit Commit activity Merged PRs Open issues Top language Repo size Stars

Illustrative terminal session: equiv compare reports a divergence with the counterexample name = null, then exits 0 on a sample where only local names changed

Illustrative session built from the samples/ READMEs, not a recording.

equiv proves (or refutes, with a counterexample) that a program behaves the same before and after a migration. It does not diff syntax. It lowers both versions to a language-neutral intermediate representation (IR), encodes matched procedure pairs as SMT problems, and asks Z3 whether any input can make them disagree.

MVP scope: .NET Framework 4.8 → .NET 10 (C#). Later: Java 11 → 25, then cross-language rewrites. The language frontends are the only language-specific parts.

How it works

flowchart LR
    L["legacy<br/>.NET Framework 4.8"] --> F["Equiv.Frontend.CSharp<br/>Roslyn CFG"]
    M["modern<br/>.NET 10"] --> F
    F --> Match["match procedures<br/>(identity, rename map, HTTP route)"]
    Match --> IR["Equiv.Core<br/>SSA IR"]
    IR --> Z3["Equiv.Verify.Z3<br/>product program → SMT"]
    Z3 --> S["SARIF 2.1.0<br/>Equivalent · Divergent + counterexample<br/>Unknown · Added · Removed"]
Loading

Status

Milestones M0 (skeleton and gates), M1 (core IR, samples, SARIF, CLI shell) and M2 (the C# frontend) are merged, and so is M3-001, the Z3 product-program encoder for loop-free IR. equiv compare loads 4.8 and .NET 10 solutions, matches procedures (including by HTTP route), lowers them to IR and verifies the matched pairs. Loops and packaging are still to come.

M3 is in progress. It adds the loop ladder, the soundness and precision work from the pre-M3 review (ADRs 0018 to 0020), and the measurement work from the Unknown-rate and feasibility reviews (ADRs 0024 to 0029). Success is judged on a public corpus against thresholds fixed in advance (ADR 0028), and the first census on it decides whether the precision milestone (M4) goes ahead. Progress and ordering: docs/ROADMAP.md.

What exists today:

  • Equiv.Core — SSA IR (records, validator, text dump/parse round trip, interpreter), CsCheck generators in tests/Equiv.TestSupport; verdict model (Equivalent, Divergent + counterexample, Unknown + reason, Added, Removed); procedure identity normalisation with rename maps; equiv.config.json loader; stable identity matcher; SARIF 2.1.0 writer with fingerprint-based baselineState; the runtime-changes table (EQ006).
  • Equiv.Frontend.CSharp — MSBuildWorkspace loader (and, off Windows, a bare loader for non-SDK projects, M3-029), symbol enumeration, endpoint discovery (Web API 2 / MVC 5 / ASP.NET Core attribute routes), and lowering from Roslyn's CFG to IR. Coverage per OperationKind is in docs/tickets/IOPERATION-COVERAGE.md.
  • Equiv.Cli — equiv compare argument parsing, frontend routing by language, exit codes, --dry-run, --baseline, --fail-on.
  • Equiv.Verify.Z3 — product-program encoder, Z3 driver and counterexample replay for loop-free IR (M3-001); the loop ladder is M3-002.
  • samples/ — paired 4.8/10 solutions, each README stating the expected verdicts.
  • tools/corpus/ — the public migration corpus equiv is assessed on (ADR 0028): a pinned copy of Amazon's Poly-MigrationBench .NET list (100 repos) and public before/after pairs such as Git Extensions' 4.8-to-.NET 5 migration. Fetched into the git-ignored .corpus/, run through the equiv-corpus-run skill, and summarised under docs/runs/. No third-party code is committed.
  • Gates — 100% line and branch coverage on every src/ project, warnings as errors, ArchUnitNET dependency rules, CodeQL, gitleaks, Dependabot, locked restores, a dependency licence gate, and Stryker mutation testing. SonarQube Cloud runs on every PR but is not yet a required check.

Licence

equiv is source-available, not open source. It is licensed under the Business Source License 1.1; each version converts to Apache-2.0 on the Change Date (2030-09-20) or four years after that version is first published, whichever is sooner.

In plain terms, production use is free while both of the following hold:

  • no more than 3 people use equiv or act on its output, and
  • no codebase you analyse exceeds 50,000 lines (each side of a comparison is measured separately, so 49k against 49k is fine and 49k against 52k is not).

Using equiv in CI counts as production use. Non-production evaluation is free and uncapped.

How equiv counts lines for the 50,000 limit. Every equiv compare prints the analysed line count of each codebase, and writes it to the SARIF run's properties.analysedLinesOfCode as legacy and modern. They are two numbers, never a total, because the licence measures each codebase on its own. The same rule applies to both sides:

  • Files: every C# file the loaded projects compile, which is your source files plus what the build generates (such as obj/**/AssemblyInfo.cs and source-generator output). A file that more than one project compiles counts once.
  • Lines: a line counts when it holds C# code. Blank lines, comment-only lines, preprocessor directive lines (#if, #region) and code that an inactive #if excludes do not count.

equiv compare --dry-run reports the two counts without verifying anything, so you can check where you stand first. equiv only reports the numbers. It enforces nothing and sends nothing anywhere. Offering equiv as a hosted or embedded service, using it to run migrations or reviews for third parties, or reselling it, is never permitted under this licence at any size.

LICENSE is the controlling text and this summary is not a substitute for it. For a commercial licence, open an issue. Reasoning and the dependency licence policy are in ADR 0017; attribution for bundled third-party code is in THIRD-PARTY-NOTICES.md.

Usage (current surface)

equiv compare --legacy <path> --modern <path>
              [--out equiv.sarif] [--baseline <previous.sarif>]
              [--config equiv.config.json] [--fail-on divergent|unknown] [--dry-run]
              [--lower-only] [--chc-int-mode true|false] [--invariant-model <id>]

--invariant-model <id> lets the loop ladder ask the Claude model <id> for a loop invariant when Z3 Spacer times out (rung 5); it sends the loops' IR text, needs ANTHROPIC_API_KEY, and Z3 checks every answer, so a wrong one can never prove a pair. It is off by default.

--dry-run routes and loads both sides, prints the analysed line counts, and stops without writing SARIF. --lower-only loads, matches and lowers, then writes a SARIF log with the lowering census (run.properties.loweringCensus) and the Added and Removed results. It never runs the solver and exits 0 unless loading fails. It cannot be combined with --baseline or --fail-on.

Exit code Meaning
0 No new Divergent results (and no new Unknown when --fail-on unknown)
1 At least one Divergent result that is new relative to --baseline
2 --fail-on unknown and at least one new Unknown result
3 Usage error: bad arguments, missing or unparseable file, no frontend for the paths
4 A C# project was skipped because it failed to load (the SARIF lists it and is still written), or a side had no C# project that loaded (no SARIF). Outranks 1 and 2
5 At least one pair's lowering or verification crashed (the SARIF lists it as a tool-execution notification and is still written), or any other unhandled internal error. Outranks 1, 2 and 4

Output is always SARIF 2.1.0 (rules EQ001 to EQ006); the exact meaning of each verdict and of baselineState is in docs/VERIFICATION-MODEL.md.

A real Divergent result, from equiv compare --legacy samples/added-branch/legacy/*.sln --modern samples/added-branch/modern/*.slnx (ticket M3-003; see samples/added-branch/README.md), one result from equiv.sarif's runs[0].results:

{
  "ruleId": "EQ002",
  "level": "error",
  "message": {
    "text": "Equiv.Samples.AddedBranch.Doubler::Double(int) diverges: inputs(bv32 0) old(returned bv32 0 outs() trace()) new(returned bv32 4294967295 outs() trace())"
  },
  "properties": {
    "model": "inputs(bv32 0) old(returned bv32 0 outs() trace()) new(returned bv32 4294967295 outs() trace())",
    "ladderTrace": [
      { "rung": "bounded", "outcome": "refuted", "detail": "a divergence within 3 iterations" }
    ]
  }
}

The counterexample is x = 0 (bv32 0): legacy returns 0, modern returns -1 (bv32 4294967295 two's-complement). The exit code is 1.

Use from a coding agent

equiv mcp runs the same pipeline as equiv compare as a Model Context Protocol server over stdio (ADR 0033), so a coding agent can ask "is my port equivalent?" while it works. It has two read-only tools that write no file:

  • compare: legacy and modern (solution paths, required), and optionally config, baseline, bound and timeoutMs. The result is a one-line summary (Equivalent n, Divergent n, Unknown n, skipped projects n, exit code k), then the SARIF log equiv compare would write, as JSON text.
  • lower_only: legacy, modern and optionally config; the same as equiv compare --lower-only.

An input error equiv compare exits 3 or 4 on (a missing file, no frontend for the paths, no C# project that loads) comes back as a tool error with the same message.

equiv mcp --execute also registers probe (ADR 0035, ADR 0036; ticket M5-002), which runs code from both solutions on this machine and so needs Windows, same as compare --execute: an agent that gets Unknown back from compare can name a matched pair by its normalised identity and supply its own arguments, and get back both runtimes' outcomes to test its own hunch. probe never writes SARIF and never changes a compare result; a mismatch it reports is a hypothesis, not a proof. Without --execute, or off Windows, probe is not registered at all, so an agent cannot turn execution on by itself.

  • probe: legacy, modern, identity and arguments (the method's own arguments, in order, as JSON values), and optionally culture. The result is { legacy: {kind, canonical}, modern: {kind, canonical}, equal }. An argument that cannot be built, or an identity that matches no pair, comes back as a tool error naming it.

Every MCP host takes a stdio server as a command and its arguments; for the binary:

{ "mcpServers": { "equiv": { "command": "equiv", "args": ["mcp"] } } }

and for the container, with the repository mounted so the paths you pass resolve inside it:

docker run -i --rm -v <repo>:/src equiv mcp

Only protocol messages go to stdout; the run's own progress and messages go to stderr.

Building and running the gates

./build.ps1               # build, format check, tests, 100% coverage gate, architecture tests
./build.ps1 -Integration  # also runs tests/Equiv.Tests.Integration (needs VS Build Tools)

Microsoft.Z3 restores only from the git-ignored local feed .z3-feed/ (ADR 0030), which build.ps1 fills first. Before any direct dotnet restore, dotnet build or dotnet test on a fresh clone (an IDE build included), run ./tools/z3-feed/fetch.ps1 once.

Every gate in docs/QUALITY-GATES.md runs locally through this script and in GitHub Actions on every PR. Coverage flags for coverlet.MTP go after -- on dotnet test; the script already does this.

Read in this order

  1. CLAUDE.md — rules for anyone (human or agent) touching this repo.
  2. docs/ARCHITECTURE.md — components, boundaries, data flow.
  3. docs/VERIFICATION-MODEL.md — what "equivalent" means here, exactly.
  4. docs/QUALITY-GATES.md — the gates every change must pass.
  5. docs/ROADMAP.md — milestones; docs/tickets/ — the work items.
  6. docs/adr/ — why each technology was chosen (and what was rejected).

Layout

src/        production code, one project per component (see ARCHITECTURE.md)
tests/      one test project per src project, Equiv.TestSupport (generators),
            Equiv.Tests.Architecture, Equiv.Tests.Integration
samples/    tiny paired legacy/modern solutions used as fixtures and demos
tools/      check-coverage (100% gate), licence-check, sonar-triage, corpus (ADR 0028)
docs/       everything above; docs/runs/ holds corpus-run summaries
.corpus/    git-ignored: third-party checkouts and raw output of corpus runs
.github/    ci.yml, codeql.yml, mutation.yml, sonar.yml, dependabot.yml
.claude/    skills that encode the workflow for coding agents

Prerequisites

Windows dev box

  • .NET 10 SDK (the exact patch is pinned in global.json).

  • Visual Studio 2026 Build Tools with workload ".NET desktop build tools" and the component ".NET Framework 4.8 targeting pack". This is what lets Roslyn's out-of-process build host evaluate legacy (non-SDK) .csproj files. No Win32 API is used anywhere. Build Tools install under C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools (the x86 prefix, even on 64-bit Windows); net48 reference assemblies land under C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8. Unattended install with the exact component ids:

    winget install --source winget Microsoft.VisualStudio.BuildTools --override "--wait --quiet --norestart --add Microsoft.VisualStudio.Workload.ManagedDesktopBuildTools --add Microsoft.Net.Component.4.8.TargetingPack --add Microsoft.Net.Component.4.8.SDK"

    Without the targeting pack, legacy projects fail to load with CS0518 (predefined type not defined), not with a workspace error. ./build.ps1 -Integration loads every sample and is the quickest check that the box is set up.

  • Git, Docker Desktop (for the container packaging milestone), GitHub CLI.

Linux

Off Windows, equiv loads legacy (non-SDK) .csproj files with its own bare loader and SDK-style ones through MSBuildWorkspace on the .NET SDK (ADR 0031, ticket M3-029). A Linux host needs:

  • the .NET 10 SDK, not only the runtime: SDK-style projects are evaluated by its MSBuild.
  • network access to the package sources the solution's nuget.config names (nuget.org when it names none), or a pre-filled cache. equiv restores packages.config packages into the folder their HintPaths expect, and fetches the .NET Framework reference assemblies (Microsoft.NETFramework.ReferenceAssemblies.<tfm>, about 110 MB per framework version) on first use into $EQUIV_REFERENCE_ASSEMBLIES, by default ~/.local/share/equiv/reference-assemblies. A version already in that folder (layout .NETFramework/v4.8/..., as tools/corpus/corpus.ps1 -Prepare writes it) is never fetched again.
  • a dotnet restore of any legacy project that uses <PackageReference>: the bare loader reads the project.assets.json it writes, exactly as Visual Studio's ResolveNuGetPackageAssets does.

No Mono, nuget.exe or MSBuild.exe is needed. A legacy project that uses MSBuild the bare loader cannot evaluate exactly (<Choose>, a property function in a property it reads, a target that adds Compile or Reference items, a COM reference, ...) is skipped with a notification naming the construct (exit 4), never loaded approximately (ADR 0029).

CI runs the full pipeline on windows-latest, everything except the integration tests on ubuntu-latest, and a parity job that fails when equiv compare gives different SARIF results on the two for any sample.

Linux hosts need glibc 2.38 or newer (Ubuntu 24.04+): the Microsoft.Z3 5.1.0 native (from the official Z3Prover/z3 GitHub release, ADR 0030) is built against it. Debian 12, Ubuntu 22.04 and Alpine are unsupported for the linux-x64 build.

Running without cloning

Every green commit on main (CI, CodeQL and SonarQube Cloud) publishes a patch release (.github/workflows/rolling-release.yml, then release.yml; versioning policy in .claude/skills/equiv-release), with three ways to run equiv without a checkout. Every artifact carries LICENSE and THIRD-PARTY-NOTICES.md (ADR 0017); the licence is BUSL-1.1, source-available and not open source, with a Change Date specific to that release.

Single-file binary. equiv-<version>-win-x64.zip / equiv-<version>-linux-x64.tar.gz on the GitHub release, built with dotnet publish -r <rid> (PublishSingleFile, SelfContained, IncludeNativeLibrariesForSelfExtract; see src/Equiv.Cli/Equiv.Cli.csproj). Extract and run equiv compare ... directly; no .NET SDK install needed. On Linux, the same Prerequisites above still apply (SDK for SDK-style projects, network access for packages.config and reference assemblies) except the SDK itself is bundled with the binary.

Container. docker run --rm --user "$(id -u):$(id -g)" -v <samples>:/samples ghcr.io/zafnok/equiv:<version> compare --legacy /samples/legacy/*.sln --modern /samples/modern/*.slnx --out /samples/equiv.sarif (mount whatever directory holds the solutions read-write; MSBuildWorkspace writes obj/ there). The image runs as a non-root user (uid/gid 1654, the base image's own app user), not root, so --user "$(id -u):$(id -g)" is what makes the container's writes into your mounted directory land with your own ownership instead of failing with "Permission denied" — the same pattern any rootless container needs for bind-mount access. The image is the .NET SDK itself, not a runtime-only base (Dockerfile; ADR 0031), because the SDK-style loader path shells into the SDK's own MSBuild at runtime. It holds no net4x reference assemblies or NuGet packages; those are fetched into $EQUIV_REFERENCE_ASSEMBLIES (/data/reference-assemblies) on first use (M3-029) — mount a volume there (-v equiv-ref-assemblies:/data/reference-assemblies) to avoid re-fetching on every run. Image size: 1.62 GB (measured locally; M3-028 measured the plain Debian sdk:10.0 base at 917 MB, so the noble base plus the app layer roughly doubles it).

GitHub Action. action.yml at the repo root runs the container (runs.using: docker) with inputs legacy, modern, config, baseline, fail-on and output sarif. Follow it with github/codeql-action/upload-sarif to get results into Code Scanning:

- uses: zafnok/code-equivalency@v0.1.0
  id: equiv
  with:
    legacy: legacy
    modern: modern
- uses: github/codeql-action/upload-sarif@v4
  with:
    sarif_file: ${{ steps.equiv.outputs.sarif }}
    category: equiv

SonarQube can also consume the same SARIF file via sonar.sarifReportPaths (not integrated here; see Out of scope in ticket M3-004).

Star history

Star history chart

About

Prove equivalency between migrations

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages