diff --git a/.centralconfig.json b/.centralconfig.json
new file mode 100644
index 0000000..779ceec
--- /dev/null
+++ b/.centralconfig.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://raw.githubusercontent.com/TarasKovalenko/CentralConfigGenerator/main/schemas/centralconfig.schema.json",
+ "conflictStrategy": "Highest",
+ "backup": true,
+ "addGitignore": true,
+ "failOn": "High",
+ "retention": { "enabled": true, "maxBackups": 5 },
+ "rules": {
+ "OutdatedPackage": "Low",
+ "PropertyDrift": "none"
+ },
+ "buildProperties": [
+ "ImplicitUsings",
+ "Nullable",
+ "LangVersion",
+ "InvariantGlobalization"
+ ]
+}
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 7fcd6dc..29b4771 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -1,28 +1,111 @@
-# This workflow will build a .NET project
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
-
name: .NET
on:
push:
- branches: [ "main" ]
+ branches: ["main"]
pull_request:
- branches: [ "main" ]
+ branches: ["main"]
+
+permissions:
+ contents: read
jobs:
build:
+ name: Build & test (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }}
+ restore-keys: ${{ runner.os }}-nuget-
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --verbosity normal
+
+ self-check:
+ name: Dependency health (dogfood)
runs-on: ubuntu-latest
+ needs: build
+ permissions:
+ contents: read
+ security-events: write
steps:
- - uses: actions/checkout@v4
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 9.0.x
- - name: Restore dependencies
- run: dotnet restore
- - name: Build
- run: dotnet build --no-restore
- - name: Test
- run: dotnet test --no-build --verbosity normal
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Build the tool
+ run: dotnet build CentralConfigGenerator/CentralConfigGenerator.csproj -f net10.0 -c Release
+
+ # The tool analyses its own repository, so a regression in the analyzers shows up here first.
+ - name: Analyse dependencies
+ run: |
+ dotnet run --project CentralConfigGenerator/CentralConfigGenerator.csproj -f net10.0 -c Release --no-build -- \
+ analyze --audit --deprecated --licenses \
+ --output Sarif --output-file centralconfig.sarif \
+ --fail-on Critical --quiet
+
+ - name: Publish findings
+ if: always()
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: centralconfig.sarif
+ category: centralconfig
+
+ - name: Job summary
+ if: always()
+ run: |
+ dotnet run --project CentralConfigGenerator/CentralConfigGenerator.csproj -f net10.0 -c Release --no-build -- \
+ analyze --audit --deprecated --output Markdown --fail-on Never --quiet >> "$GITHUB_STEP_SUMMARY"
+
+ pack:
+ name: Pack
+ runs-on: ubuntu-latest
+ needs: build
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Pack
+ run: dotnet pack CentralConfigGenerator/CentralConfigGenerator.csproj -c Release -o ./artifacts
+
+ - name: Upload package
+ uses: actions/upload-artifact@v4
+ with:
+ name: nupkg
+ path: ./artifacts/*.nupkg
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..778d535
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,87 @@
+name: release
+
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to publish, without the leading v (e.g. 1.0.0)'
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ env:
+ CI: true
+ DOTNET_NOLOGO: true
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # The tool multi-targets net8.0/net9.0/net10.0, so every SDK has to be present.
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Resolve version
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ version="${{ inputs.version }}"
+ else
+ version="${GITHUB_REF_NAME#v}"
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ echo "Publishing $version"
+
+ - name: Restore
+ run: dotnet restore CentralConfigGenerator.sln
+
+ - name: Build
+ run: dotnet build CentralConfigGenerator.sln -c Release --no-restore -p:Version=${{ steps.version.outputs.version }}
+
+ - name: Test
+ run: dotnet test CentralConfigGenerator.sln -c Release --no-build
+
+ - name: Pack
+ run: |
+ dotnet pack CentralConfigGenerator/CentralConfigGenerator.csproj \
+ -c Release --no-build \
+ -p:Version=${{ steps.version.outputs.version }} \
+ -o artifacts/packages
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: packages-${{ steps.version.outputs.version }}
+ path: artifacts/packages/*.*nupkg
+
+ # --skip-duplicate keeps a re-run from failing when a package version is already live.
+ - name: Push to NuGet
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ run: |
+ if [ -z "$NUGET_API_KEY" ]; then
+ echo "::error::NUGET_API_KEY is not set. Add it with: gh secret set NUGET_API_KEY"
+ exit 1
+ fi
+ dotnet nuget push "artifacts/packages/*.nupkg" \
+ --api-key "$NUGET_API_KEY" \
+ --source https://api.nuget.org/v3/index.json \
+ --skip-duplicate
+
+ - name: Create GitHub release
+ if: github.event_name == 'push'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release create "${GITHUB_REF_NAME}" artifacts/packages/*.nupkg --generate-notes
diff --git a/.gitignore b/.gitignore
index 48a1780..45b7508 100644
--- a/.gitignore
+++ b/.gitignore
@@ -399,4 +399,7 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
-.idea/
\ No newline at end of file
+.idea/
+
+# CentralConfigGenerator backups
+.centralconfig-backups/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..da29921
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,58 @@
+# Changelog
+
+## 2.0.0
+
+A rewrite around one idea: migrating to Central Package Management should be provable, reversible, and only the beginning of keeping dependencies healthy.
+
+### New commands
+
+- `verify` — migrates, then diffs the resolved package graph before and after to prove NuGet still resolves identical packages. Rolls back automatically on drift (exit code `9`).
+- `analyze` — 16 dependency-health rules with a 0–100 score, severity overrides, baselines, and `--fix` / `--fix-dry-run` auto-fixes.
+- `update` — updates central versions, runs the tests, and rolls back on failure. `--bisect` keeps the largest subset that still passes and names what it held back.
+- `revert` — undoes CPM, writing versions back into project files.
+- `doctor` — environment diagnostics: SDK, solution layout, feed reachability, config, backups, `.gitignore`.
+- `status` — workspace dashboard plus the single most useful next action.
+- `tree` — resolved dependency graph as an ASCII tree.
+- `batch` — runs the migration across every solution in a monorepo, optionally in parallel.
+- `init` — scaffolds `.centralconfig.json` (JSON schema published under `schemas/`).
+- `explain` — documents any analysis rule.
+- `backups list | restore | prune` — inspect and roll back any change the tool made.
+- `completions` — bash, zsh, fish and PowerShell completion scripts.
+
+### Migration improvements
+
+- **Format-preserving edits.** Encoding, BOM, line endings, comments, indentation and attribute order all survive; only what must change changes. `--encoding` and `--linewrap` override when you want normalisation instead.
+- **Dry-run with unified diffs** (`--dry-run --diff`), planned entirely in memory so the preview and the write share one code path.
+- **Backups before every write**, with restore, retention and pruning. Files the operation created are recorded too, so restoring removes them.
+- `--merge` into an existing `Directory.Packages.props` without disturbing its comments or layout.
+- Conflict strategies `Highest`, `Lowest`, `MostCommon`, `Fail`, plus `--interactive-conflicts`.
+- `--transitive-pinning`, `--ignore-prerelease`, `--version-comparison`, `--keep-attrs`.
+- F# and VB projects alongside C#; `.sln`, `.slnx` and `.slnf` scoping; `--exclude-dirs`.
+- Versions declared as child elements (`1.0.0`) are handled, as are `GlobalPackageReference` and `VersionOverride`.
+
+### Directory.Build.props
+
+- Property hoisting now requires **unanimity**: a property moves up only when every project declaring it uses the same value. Conditioned `PropertyGroup`s and identity properties (`AssemblyName`, `RootNamespace`, …) are never touched.
+- Configurable candidate list via `--property` or `buildProperties` in the config file.
+
+### Reporting and CI
+
+- Output as Terminal, JSON, SARIF 2.1.0, Markdown or CSV, to stdout or a file.
+- Stable exit codes 0–9 so CI can branch on the outcome.
+- `--fail-on`, `--rules Rule=Severity`, `--baseline` and `--write-baseline` for gradual adoption.
+
+### Platform
+
+- Multi-targets `net8.0`, `net9.0` and `net10.0`.
+- Private feeds honoured through `nuget.config`.
+- 320+ tests.
+
+### Breaking changes
+
+- `packages-enhanced` is now an alias for `migrate`; the enhanced analysis is the default path.
+- The CLI moved from `System.CommandLine` to `Spectre.Console.Cli`. Command names are unchanged and `packages` / `convert` alias `migrate`.
+- `-d` is `--directory`; use `-n` or `--dry-run` for dry runs.
+
+## 1.1.1 and earlier
+
+See the git history.
diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs
new file mode 100644
index 0000000..203b813
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs
@@ -0,0 +1,357 @@
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.NuGet;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Analysis;
+
+public class AnalysisEngineTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-analysis");
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportVersionInconsistency()
+ {
+ var report = await RunAsync(WorkspaceWithTwoProjects());
+
+ report.Findings.ShouldContain(f => f.RuleId == RuleIds.VersionInconsistency);
+ report.Findings
+ .First(f => f.RuleId == RuleIds.VersionInconsistency && f.PackageId == "Newtonsoft.Json")
+ .FixValue.ShouldBe("13.0.3");
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportCasingMismatches()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("Newtonsoft.Json", "13.0.3"))
+ .AddFile(Path.Combine(Root, "b", "B.csproj"), Reference("newtonsoft.json", "13.0.3"));
+
+ var report = await RunAsync(fileSystem);
+
+ report.Findings.ShouldContain(f => f.RuleId == RuleIds.DuplicatePackageCasing);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportFloatingVersions()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("xunit", "2.9.*"));
+
+ var report = await RunAsync(fileSystem);
+
+ report.Findings.ShouldContain(f => f.RuleId == RuleIds.FloatingVersion);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportDuplicateReferencesInOneProject()
+ {
+ var content = """
+
+
+
+
+
+
+ """;
+
+ var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), content);
+
+ var report = await RunAsync(fileSystem);
+
+ report.Findings.ShouldContain(f => f.RuleId == RuleIds.RedundantReference);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldNotFlagConditionedReferencesAsDuplicates()
+ {
+ var content = """
+
+
+
+
+
+
+
+
+ """;
+
+ var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), content);
+
+ var report = await RunAsync(fileSystem);
+
+ report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.RedundantReference);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportInlineVersionsUnderCentralManagement()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "2.0.0"))
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var report = await RunAsync(fileSystem);
+
+ var finding = report.Findings.First(f => f.RuleId == RuleIds.InlineVersionUnderCpm);
+ finding.Severity.ShouldBe(Severity.High);
+ finding.Fix.ShouldBe(FixKind.RemoveInlineVersion);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportOrphanedCentralEntries()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", null))
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var report = await RunAsync(fileSystem);
+
+ report.Findings.ShouldContain(f =>
+ f.RuleId == RuleIds.OrphanedPackageVersion && f.PackageId == "Newtonsoft.Json"
+ );
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportPackagesWithNoVersionAtAll()
+ {
+ var props = """
+
+
+ true
+
+
+ """;
+
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", null))
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), props);
+
+ var report = await RunAsync(fileSystem);
+
+ report.Findings.ShouldContain(f => f.RuleId == RuleIds.MissingCentralVersion);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldSkipNetworkRulesWhenTheyAreNotRequested()
+ {
+ var report = await RunAsync(WorkspaceWithTwoProjects());
+
+ report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.OutdatedPackage);
+ report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.SecurityVulnerability);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportVulnerabilitiesWhenAuditingIsOn()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Vulnerable.Pkg", "1.0.0"));
+
+ var feed = new FakeNuGetFeed().Add(
+ "Vulnerable.Pkg",
+ ["1.0.0", "1.0.1"],
+ vulnerabilities: [("1.0.0", Severity.Critical)]
+ );
+
+ var report = await RunAsync(fileSystem, feed, new AnalysisOptions { RootDirectory = Root, Audit = true });
+
+ var finding = report.Findings.First(f => f.RuleId == RuleIds.SecurityVulnerability);
+ finding.Severity.ShouldBe(Severity.Critical);
+ finding.FixValue.ShouldBe("1.0.1");
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportDeprecatedPackages()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Old.Pkg", "1.0.0"));
+
+ var feed = new FakeNuGetFeed().Add("Old.Pkg", ["1.0.0"], deprecated: true, alternate: "New.Pkg");
+
+ var report = await RunAsync(
+ fileSystem,
+ feed,
+ new AnalysisOptions { RootDirectory = Root, Deprecated = true }
+ );
+
+ report.Findings
+ .First(f => f.RuleId == RuleIds.DeprecatedPackage)
+ .Recommendation.ShouldNotBeNull()
+ .ShouldContain("New.Pkg");
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldGradeOutdatedPackagesByHowFarBehindTheyAre()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Old.Pkg", "1.0.0"));
+
+ var feed = new FakeNuGetFeed().Add("Old.Pkg", ["1.0.0", "3.0.0"]);
+
+ var report = await RunAsync(
+ fileSystem,
+ feed,
+ new AnalysisOptions { RootDirectory = Root, Outdated = true }
+ );
+
+ report.Findings.First(f => f.RuleId == RuleIds.OutdatedPackage).Severity
+ .ShouldBe(Severity.Moderate);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldFlagCopyleftLicences()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Gpl.Pkg", "1.0.0"));
+
+ var feed = new FakeNuGetFeed().Add("Gpl.Pkg", ["1.0.0"], license: "GPL-3.0-only");
+
+ var report = await RunAsync(
+ fileSystem,
+ feed,
+ new AnalysisOptions { RootDirectory = Root, Licenses = true }
+ );
+
+ report.Findings.First(f => f.RuleId == RuleIds.LicenseRisk).Severity.ShouldBe(Severity.High);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldHonourRuleSeverityOverrides()
+ {
+ var options = new AnalysisOptions
+ {
+ RootDirectory = Root,
+ RuleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [RuleIds.VersionInconsistency] = Severity.Critical,
+ },
+ };
+
+ var report = await RunAsync(WorkspaceWithTwoProjects(), options: options);
+
+ report.Findings
+ .First(f => f.RuleId == RuleIds.VersionInconsistency)
+ .Severity.ShouldBe(Severity.Critical);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldSkipDisabledRules()
+ {
+ var options = new AnalysisOptions
+ {
+ RootDirectory = Root,
+ RuleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [RuleIds.VersionInconsistency] = null,
+ },
+ };
+
+ var report = await RunAsync(WorkspaceWithTwoProjects(), options: options);
+
+ report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.VersionInconsistency);
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldIgnoreListedPackages()
+ {
+ var options = new AnalysisOptions { RootDirectory = Root, IgnorePackages = ["Newtonsoft.*"] };
+
+ var report = await RunAsync(WorkspaceWithTwoProjects(), options: options);
+
+ report.Findings.ShouldNotContain(f => f.PackageId == "Newtonsoft.Json");
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldScoreACleanWorkspaceAt100()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "3.1.1"));
+
+ var report = await RunAsync(fileSystem);
+
+ report.Findings.ShouldBeEmpty();
+ report.HealthScore.ShouldBe(100);
+ report.Grade.ShouldBe("A");
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReturnAnEmptyReportWhenThereAreNoProjects()
+ {
+ var report = await RunAsync(new InMemoryFileSystem().AddDirectory(Root));
+
+ report.ProjectCount.ShouldBe(0);
+ report.Findings.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task AnalyzeAsync_ShouldReportTransitiveDivergence()
+ {
+ var graph = new PackageGraph
+ {
+ IsAvailable = true,
+ Packages =
+ [
+ new ResolvedPackage
+ {
+ PackageId = "System.Text.Json",
+ ResolvedVersion = "8.0.0",
+ ProjectPath = "A.csproj",
+ TargetFramework = "net8.0",
+ IsTransitive = true,
+ },
+ new ResolvedPackage
+ {
+ PackageId = "System.Text.Json",
+ ResolvedVersion = "9.0.0",
+ ProjectPath = "B.csproj",
+ TargetFramework = "net8.0",
+ IsTransitive = true,
+ },
+ ],
+ };
+
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "3.1.1"));
+
+ var engine = CreateEngine(fileSystem, new FakeNuGetFeed(), graph);
+ var report = await engine.AnalyzeAsync(
+ new AnalysisOptions { RootDirectory = Root, Transitive = true }
+ );
+
+ report.Findings.ShouldContain(f => f.RuleId == RuleIds.TransitiveConflict);
+ }
+
+ private static InMemoryFileSystem WorkspaceWithTwoProjects() =>
+ new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("Newtonsoft.Json", "13.0.1"))
+ .AddFile(Path.Combine(Root, "b", "B.csproj"), Reference("Newtonsoft.Json", "13.0.3"));
+
+ private static string Reference(string packageId, string? version) =>
+ $"""
+
+
+
+
+
+ """;
+
+ private static AnalysisEngine CreateEngine(
+ InMemoryFileSystem fileSystem,
+ INuGetFeedService feed,
+ PackageGraph? graph = null
+ ) =>
+ new(
+ new ProjectDiscoveryService(fileSystem),
+ fileSystem,
+ feed,
+ new FakePackageGraphService(graph ?? PackageGraph.Empty),
+ new BaselineService(fileSystem)
+ );
+
+ private static Task RunAsync(
+ InMemoryFileSystem fileSystem,
+ INuGetFeedService? feed = null,
+ AnalysisOptions? options = null
+ ) =>
+ CreateEngine(fileSystem, feed ?? new FakeNuGetFeed())
+ .AnalyzeAsync(options ?? new AnalysisOptions { RootDirectory = Root });
+}
diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs
new file mode 100644
index 0000000..0efa26f
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs
@@ -0,0 +1,88 @@
+using CentralConfigGenerator.Core.Analysis;
+
+namespace CentralConfigGenerator.Core.Tests.Analysis;
+
+public class AnalysisReportTests
+{
+ [Fact]
+ public void HealthScore_ShouldBe100ForACleanWorkspace() =>
+ Report([]).HealthScore.ShouldBe(100);
+
+ [Fact]
+ public void HealthScore_ShouldDropSharplyForCriticalFindings()
+ {
+ var report = Report([Finding(Severity.Critical)]);
+
+ report.HealthScore.ShouldBeLessThan(80);
+ report.Grade.ShouldNotBe("A");
+ }
+
+ [Fact]
+ public void HealthScore_ShouldBarelyMoveForInfoFindings() =>
+ Report([Finding(Severity.Info)]).HealthScore.ShouldBeGreaterThan(95);
+
+ [Fact]
+ public void HealthScore_ShouldNeverGoBelowZero()
+ {
+ var findings = Enumerable.Repeat(Finding(Severity.Critical), 100).ToList();
+
+ Report(findings).HealthScore.ShouldBe(0);
+ }
+
+ [Fact]
+ public void HealthScore_ShouldScaleWithWorkspaceSize()
+ {
+ var findings = Enumerable.Repeat(Finding(Severity.Moderate), 5).ToList();
+
+ var small = Report(findings, packageCount: 5);
+ var large = Report(findings, packageCount: 500);
+
+ large.HealthScore.ShouldBeGreaterThan(small.HealthScore);
+ }
+
+ [Fact]
+ public void HighestSeverity_ShouldReturnTheWorstFinding() =>
+ Report([Finding(Severity.Low), Finding(Severity.High)]).HighestSeverity
+ .ShouldBe(Severity.High);
+
+ [Fact]
+ public void HighestSeverity_ShouldBeNeverWhenThereAreNoFindings() =>
+ Report([]).HighestSeverity.ShouldBe(Severity.Never);
+
+ [Theory]
+ [InlineData(100, "A")]
+ [InlineData(85, "B")]
+ [InlineData(75, "C")]
+ [InlineData(65, "D")]
+ [InlineData(10, "F")]
+ public void Grade_ShouldFollowTheScore(int targetScore, string expected)
+ {
+ // Work backwards: pick a finding count that lands near the target score.
+ var report = Report([], packageCount: 9);
+ var findings = new List();
+
+ while (Report(findings, packageCount: 9).HealthScore > targetScore)
+ {
+ findings.Add(Finding(Severity.Low));
+ }
+
+ report = Report(findings, packageCount: 9);
+ report.Grade.ShouldBe(expected);
+ }
+
+ private static Finding Finding(Severity severity) =>
+ new()
+ {
+ RuleId = "X",
+ Severity = severity,
+ Message = "m",
+ };
+
+ private static AnalysisReport Report(IReadOnlyList findings, int packageCount = 9) =>
+ new()
+ {
+ Findings = findings,
+ ProjectCount = 3,
+ PackageCount = packageCount,
+ };
+}
diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs
new file mode 100644
index 0000000..ec3ff78
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs
@@ -0,0 +1,177 @@
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.NuGet;
+using CentralConfigGenerator.Core.Services;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Analysis;
+
+public class AutoFixServiceTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-autofix");
+
+ [Fact]
+ public async Task PlanAsync_ShouldRemoveInlineVersionsUnderCentralManagement()
+ {
+ var project = Path.Combine(Root, "A.csproj");
+
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(project, Sample.AppProject)
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var (service, context) = await CreateAsync(fileSystem);
+
+ var result = await service.PlanAsync(
+ context,
+ context.References.Select(r => new Finding
+ {
+ RuleId = RuleIds.InlineVersionUnderCpm,
+ Severity = Severity.High,
+ Message = "inline",
+ PackageId = r.PackageId,
+ ProjectPath = r.ProjectPath,
+ Fix = FixKind.RemoveInlineVersion,
+ }).ToList()
+ );
+
+ result.Changes.ShouldHaveSingleItem();
+ result.Changes[0].NewContent.ShouldNotContain("Version=");
+ result.Fixed.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldSetCentralVersions()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var (service, context) = await CreateAsync(fileSystem);
+
+ var result = await service.PlanAsync(
+ context,
+ [
+ new Finding
+ {
+ RuleId = RuleIds.OutdatedPackage,
+ Severity = Severity.Low,
+ Message = "outdated",
+ PackageId = "Serilog",
+ Fix = FixKind.SetCentralVersion,
+ FixValue = "4.0.0",
+ },
+ ]
+ );
+
+ result.Changes.ShouldHaveSingleItem();
+ result.Changes[0].NewContent.ShouldContain("Version=\"4.0.0\"");
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldStackSeveralFixesOnOneFile()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var (service, context) = await CreateAsync(fileSystem);
+
+ var result = await service.PlanAsync(
+ context,
+ [
+ Fix("Serilog", "4.0.0"),
+ Fix("Newtonsoft.Json", "14.0.0"),
+ ]
+ );
+
+ result.Changes.ShouldHaveSingleItem();
+ result.Changes[0].NewContent.ShouldContain("4.0.0");
+ result.Changes[0].NewContent.ShouldContain("14.0.0");
+
+ static Finding Fix(string packageId, string version) =>
+ new()
+ {
+ RuleId = RuleIds.OutdatedPackage,
+ Severity = Severity.Low,
+ Message = "outdated",
+ PackageId = packageId,
+ Fix = FixKind.SetCentralVersion,
+ FixValue = version,
+ };
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldSkipFindingsThatAreNotAutoFixable()
+ {
+ var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject);
+
+ var (service, context) = await CreateAsync(fileSystem);
+
+ var result = await service.PlanAsync(
+ context,
+ [
+ new Finding
+ {
+ RuleId = RuleIds.FloatingVersion,
+ Severity = Severity.Moderate,
+ Message = "floating",
+ },
+ ]
+ );
+
+ result.Skipped.ShouldHaveSingleItem();
+ result.HasChanges.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ApplyAsync_ShouldWriteChangesAndBackThemUp()
+ {
+ var project = Path.Combine(Root, "A.csproj");
+
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(project, Sample.AppProject)
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var (service, context) = await CreateAsync(fileSystem);
+
+ var result = await service.PlanAsync(
+ context,
+ [
+ new Finding
+ {
+ RuleId = RuleIds.InlineVersionUnderCpm,
+ Severity = Severity.High,
+ Message = "inline",
+ PackageId = "Serilog",
+ ProjectPath = project,
+ Fix = FixKind.RemoveInlineVersion,
+ },
+ ]
+ );
+
+ var backup = await service.ApplyAsync(result, Root, Root, createBackup: true);
+
+ fileSystem.ReadText(project).ShouldContain("");
+ backup.ShouldNotBeNull();
+ }
+
+ private static async Task<(AutoFixService Service, AnalysisContext Context)> CreateAsync(
+ InMemoryFileSystem fileSystem
+ )
+ {
+ var discovery = new ProjectDiscoveryService(fileSystem);
+
+ var engine = new AnalysisEngine(
+ discovery,
+ fileSystem,
+ new FakeNuGetFeed(),
+ new FakePackageGraphService(PackageGraph.Empty),
+ new BaselineService(fileSystem)
+ );
+
+ var context = await engine.BuildContextAsync(new AnalysisOptions { RootDirectory = Root });
+ var service = new AutoFixService(discovery, fileSystem, new BackupService(fileSystem));
+
+ return (service, context);
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs
new file mode 100644
index 0000000..3ec2111
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs
@@ -0,0 +1,68 @@
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Analysis;
+
+public class BaselineServiceTests
+{
+ private static readonly string Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ "ccg-baseline.json"
+ );
+
+ private static readonly Finding Sample = new()
+ {
+ RuleId = RuleIds.FloatingVersion,
+ Severity = Severity.Moderate,
+ Message = "floating",
+ PackageId = "xunit",
+ Version = "2.9.*",
+ };
+
+ [Fact]
+ public async Task WriteAsync_ThenLoadAsync_ShouldRoundTrip()
+ {
+ var fileSystem = new InMemoryFileSystem();
+ var service = new BaselineService(fileSystem);
+
+ await service.WriteAsync(Path, [Sample]);
+ var baseline = await service.LoadAsync(Path);
+
+ baseline.ShouldNotBeNull();
+ baseline.Fingerprints.ShouldContain(Sample.Fingerprint);
+ }
+
+ [Fact]
+ public async Task LoadAsync_ShouldReturnNullWhenTheFileIsMissing() =>
+ (await new BaselineService(new InMemoryFileSystem()).LoadAsync(Path)).ShouldBeNull();
+
+ [Fact]
+ public async Task LoadAsync_ShouldReturnNullForMalformedJson()
+ {
+ var fileSystem = new InMemoryFileSystem().AddFile(Path, "{ not json");
+
+ (await new BaselineService(fileSystem).LoadAsync(Path)).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Apply_ShouldSuppressRecordedFindings()
+ {
+ var service = new BaselineService(new InMemoryFileSystem());
+ var baseline = new Baseline { Fingerprints = [Sample.Fingerprint] };
+
+ service.Apply([Sample], baseline).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Apply_ShouldKeepFindingsThatAreNotInTheBaseline()
+ {
+ var service = new BaselineService(new InMemoryFileSystem());
+ var baseline = new Baseline { Fingerprints = ["Something|else|-|-"] };
+
+ service.Apply([Sample], baseline).ShouldHaveSingleItem();
+ }
+
+ [Fact]
+ public void Apply_ShouldReturnEverythingWhenThereIsNoBaseline() =>
+ new BaselineService(new InMemoryFileSystem()).Apply([Sample], null).ShouldHaveSingleItem();
+}
diff --git a/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs
new file mode 100644
index 0000000..08b7ceb
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs
@@ -0,0 +1,131 @@
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Configuration;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Analysis;
+
+public class ConfigurationLoaderTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-config");
+
+ [Fact]
+ public async Task LoadAsync_ShouldReadEveryKnownSetting()
+ {
+ var json = """
+ {
+ "conflictStrategy": "Lowest",
+ "backup": false,
+ "failOn": "Critical",
+ "transitivePinning": true,
+ "excludeDirs": "samples",
+ "rules": { "OutdatedPackage": "none" },
+ "retention": { "enabled": true, "maxBackups": 3 },
+ "ignorePackages": [ "Internal.*" ]
+ }
+ """;
+
+ var fileSystem = new InMemoryFileSystem().AddFile(
+ Path.Combine(Root, ConfigurationLoader.FileName),
+ json
+ );
+
+ var (settings, path, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root);
+
+ error.ShouldBeNull();
+ path.ShouldNotBeNull();
+ settings.ShouldNotBeNull();
+ settings.ConflictStrategy.ShouldBe(VersionResolutionStrategyName.Lowest);
+ settings.Backup.ShouldBe(false);
+ settings.FailOn.ShouldBe(Severity.Critical);
+ settings.TransitivePinning.ShouldBe(true);
+ settings.Retention!.MaxBackups.ShouldBe(3);
+ settings.IgnorePackages.ShouldNotBeNull().ShouldContain("Internal.*");
+ }
+
+ [Fact]
+ public async Task LoadAsync_ShouldWalkUpTheDirectoryTree()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, ConfigurationLoader.FileName), "{}")
+ .AddDirectory(Path.Combine(Root, "src", "App"));
+
+ var (settings, _, _) = await new ConfigurationLoader(fileSystem).LoadAsync(
+ Path.Combine(Root, "src", "App")
+ );
+
+ settings.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task LoadAsync_ShouldReturnNothingWhenThereIsNoFile()
+ {
+ var (settings, path, error) = await new ConfigurationLoader(
+ new InMemoryFileSystem()
+ ).LoadAsync(Root);
+
+ settings.ShouldBeNull();
+ path.ShouldBeNull();
+ error.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task LoadAsync_ShouldReportMalformedJson()
+ {
+ var fileSystem = new InMemoryFileSystem().AddFile(
+ Path.Combine(Root, ConfigurationLoader.FileName),
+ "{ not json"
+ );
+
+ var (settings, _, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root);
+
+ settings.ShouldBeNull();
+ error.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task LoadAsync_ShouldTolerateCommentsAndTrailingCommas()
+ {
+ var json = """
+ {
+ // the default strategy
+ "conflictStrategy": "Highest",
+ }
+ """;
+
+ var fileSystem = new InMemoryFileSystem().AddFile(
+ Path.Combine(Root, ConfigurationLoader.FileName),
+ json
+ );
+
+ var (settings, _, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root);
+
+ error.ShouldBeNull();
+ settings!.ConflictStrategy.ShouldBe(VersionResolutionStrategyName.Highest);
+ }
+
+ [Fact]
+ public async Task ScaffoldAsync_ShouldWriteAConfigThatLoadsBack()
+ {
+ var fileSystem = new InMemoryFileSystem();
+ var loader = new ConfigurationLoader(fileSystem);
+
+ var path = await loader.ScaffoldAsync(Root, overwrite: false);
+
+ fileSystem.ReadText(path).ShouldContain("$schema");
+
+ var (settings, _, error) = await loader.LoadAsync(Root);
+ error.ShouldBeNull();
+ settings.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task ScaffoldAsync_ShouldNotOverwriteWithoutPermission()
+ {
+ var path = Path.Combine(Root, ConfigurationLoader.FileName);
+ var fileSystem = new InMemoryFileSystem().AddFile(path, "{ \"backup\": false }");
+
+ await new ConfigurationLoader(fileSystem).ScaffoldAsync(Root, overwrite: false);
+
+ fileSystem.ReadText(path).ShouldBe("{ \"backup\": false }");
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj b/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj
index 0ebbfec..f04bff7 100644
--- a/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj
+++ b/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj
@@ -1,5 +1,7 @@
+ net9.0
+ Major
false
true
diff --git a/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs b/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs
new file mode 100644
index 0000000..02d7778
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs
@@ -0,0 +1,136 @@
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Discovery;
+
+public class ProjectDiscoveryServiceTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-discovery");
+
+ [Fact]
+ public async Task DiscoverAsync_ShouldFindProjectsRecursively()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "src", "App", "App.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "src", "Lib", "Lib.fsproj"), Sample.LibProject);
+
+ var service = new ProjectDiscoveryService(fileSystem);
+
+ var projects = await service.DiscoverAsync(new DiscoveryOptions { RootDirectory = Root });
+
+ projects.Count.ShouldBe(2);
+ projects.Select(p => p.ResolvedKind).ShouldContain(ProjectKind.FSharp);
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_ShouldSkipBinObjAndBackupDirectories()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "App.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "bin", "Debug", "Ghost.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "obj", "Ghost.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "node_modules", "Ghost.csproj"), Sample.AppProject)
+ .AddFile(
+ Path.Combine(Root, ".centralconfig-backups", "20250101", "App.csproj"),
+ Sample.AppProject
+ );
+
+ var service = new ProjectDiscoveryService(fileSystem);
+
+ var projects = await service.DiscoverAsync(new DiscoveryOptions { RootDirectory = Root });
+
+ projects.Count.ShouldBe(1);
+ projects[0].FileName.ShouldBe("App.csproj");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_ShouldHonourExcludeRegex()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "src", "App.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "samples", "Demo.csproj"), Sample.AppProject);
+
+ var service = new ProjectDiscoveryService(fileSystem);
+
+ var projects = await service.DiscoverAsync(
+ new DiscoveryOptions { RootDirectory = Root, ExcludePattern = "^samples$" }
+ );
+
+ projects.Count.ShouldBe(1);
+ projects[0].FileName.ShouldBe("App.csproj");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_ShouldRestrictToRequestedKinds()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "App.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "Lib.fsproj"), Sample.LibProject);
+
+ var service = new ProjectDiscoveryService(fileSystem);
+
+ var projects = await service.DiscoverAsync(
+ new DiscoveryOptions { RootDirectory = Root, IncludedKinds = [ProjectKind.CSharp] }
+ );
+
+ projects.Count.ShouldBe(1);
+ projects[0].ResolvedKind.ShouldBe(ProjectKind.CSharp);
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_ShouldFollowSolutionWhenGiven()
+ {
+ var solutionPath = Path.Combine(Root, "My.sln");
+ var projectPath = Path.Combine(Root, "src", "App", "App.csproj");
+
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(projectPath, Sample.AppProject)
+ .AddFile(Path.Combine(Root, "unlisted", "Other.csproj"), Sample.AppProject)
+ .AddFile(
+ solutionPath,
+ """
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src/App/App.csproj", "{G}"
+ EndProject
+ """
+ );
+
+ var service = new ProjectDiscoveryService(fileSystem);
+
+ var projects = await service.DiscoverAsync(
+ new DiscoveryOptions { RootDirectory = Root, SolutionPath = solutionPath }
+ );
+
+ projects.Count.ShouldBe(1);
+ projects[0].Path.ShouldBe(projectPath);
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_ShouldReturnEmptyForMissingDirectory()
+ {
+ var service = new ProjectDiscoveryService(new InMemoryFileSystem());
+
+ var projects = await service.DiscoverAsync(
+ new DiscoveryOptions { RootDirectory = Path.Combine(Root, "nope") }
+ );
+
+ projects.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task SaveAsync_ShouldPreserveTheOriginalFormat()
+ {
+ var path = Path.Combine(Root, "App.csproj");
+ var fileSystem = new InMemoryFileSystem();
+ fileSystem.AddFile(path, System.Text.Encoding.UTF8.GetBytes("\r\n\r\n"));
+
+ var service = new ProjectDiscoveryService(fileSystem);
+ var project = await service.LoadAsync(path);
+
+ project.ShouldNotBeNull();
+ await service.SaveAsync(project);
+
+ System.Text.Encoding.UTF8.GetString(fileSystem.ReadBytes(path))
+ .ShouldBe("\r\n\r\n");
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs b/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs
new file mode 100644
index 0000000..9517c58
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs
@@ -0,0 +1,103 @@
+using CentralConfigGenerator.Core.Discovery;
+
+namespace CentralConfigGenerator.Core.Tests.Discovery;
+
+public class SolutionReaderTests
+{
+ [Theory]
+ [InlineData("MySolution.sln", true)]
+ [InlineData("MySolution.slnx", true)]
+ [InlineData("MySolution.slnf", true)]
+ [InlineData("MyProject.csproj", false)]
+ public void IsSolution_ShouldRecogniseSolutionExtensions(string path, bool expected) =>
+ SolutionReader.IsSolution(path).ShouldBe(expected);
+
+ [Fact]
+ public void ReadProjects_ShouldParseClassicSlnFormat()
+ {
+ var content = """
+ Microsoft Visual Studio Solution File, Format Version 12.00
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{GUID1}"
+ EndProject
+ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FOLDER}"
+ EndProject
+ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Lib", "src\Lib\Lib.fsproj", "{GUID2}"
+ EndProject
+ """;
+
+ var projects = SolutionReader.ReadProjects(
+ Path.Combine(Path.GetTempPath(), "MySolution.sln"),
+ content
+ );
+
+ projects.Count.ShouldBe(2);
+ projects.ShouldContain(p => p.EndsWith("App.csproj", StringComparison.Ordinal));
+ projects.ShouldContain(p => p.EndsWith("Lib.fsproj", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ReadProjects_ShouldSkipSolutionFolders()
+ {
+ var content = """
+ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FOLDER}"
+ EndProject
+ """;
+
+ SolutionReader
+ .ReadProjects(Path.Combine(Path.GetTempPath(), "S.sln"), content)
+ .ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ReadProjects_ShouldParseSlnxFormat()
+ {
+ var content = """
+
+
+
+
+
+
+ """;
+
+ var projects = SolutionReader.ReadProjects(
+ Path.Combine(Path.GetTempPath(), "MySolution.slnx"),
+ content
+ );
+
+ projects.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public void ReadProjects_ShouldParseSolutionFilter()
+ {
+ var content = """
+ {
+ "solution": {
+ "path": "MySolution.sln",
+ "projects": [ "src/App/App.csproj" ]
+ }
+ }
+ """;
+
+ var projects = SolutionReader.ReadProjects(
+ Path.Combine(Path.GetTempPath(), "MySolution.slnf"),
+ content
+ );
+
+ projects.Count.ShouldBe(1);
+ projects[0].ShouldEndWith("App.csproj");
+ }
+
+ [Fact]
+ public void ReadProjects_ShouldReturnEmptyForMalformedXml() =>
+ SolutionReader
+ .ReadProjects(Path.Combine(Path.GetTempPath(), "Broken.slnx"), "")
+ .ShouldBeEmpty();
+
+ [Fact]
+ public void ReadProjects_ShouldReturnEmptyForMalformedJson() =>
+ SolutionReader
+ .ReadProjects(Path.Combine(Path.GetTempPath(), "Broken.slnf"), "{ not json")
+ .ShouldBeEmpty();
+}
diff --git a/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs b/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs
new file mode 100644
index 0000000..bc978b5
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs
@@ -0,0 +1,95 @@
+using System.Text;
+using CentralConfigGenerator.Core.IO;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Tests.IO;
+
+public class EncodingDetectorTests
+{
+ [Fact]
+ public void Decode_ShouldDetectUtf8WithoutBom()
+ {
+ var bytes = new UTF8Encoding(false).GetBytes("");
+
+ var (content, format) = EncodingDetector.Decode(bytes);
+
+ content.ShouldBe("");
+ format.HasByteOrderMark.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Decode_ShouldDetectUtf8Bom()
+ {
+ var bytes = new UTF8Encoding(true).GetPreamble()
+ .Concat(new UTF8Encoding(false).GetBytes(""))
+ .ToArray();
+
+ var (content, format) = EncodingDetector.Decode(bytes);
+
+ content.ShouldBe("");
+ format.HasByteOrderMark.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Decode_ShouldDetectUtf16LittleEndian()
+ {
+ var bytes = new UnicodeEncoding(false, true).GetPreamble()
+ .Concat(new UnicodeEncoding(false, false).GetBytes(""))
+ .ToArray();
+
+ var (content, format) = EncodingDetector.Decode(bytes);
+
+ content.ShouldBe("");
+ format.HasByteOrderMark.ShouldBeTrue();
+ format.Encoding.ShouldBeOfType();
+ }
+
+ [Theory]
+ [InlineData("a\r\nb\r\nc", LineEndingStyle.CrLf)]
+ [InlineData("a\nb\nc", LineEndingStyle.Lf)]
+ [InlineData("a\rb\rc", LineEndingStyle.Cr)]
+ [InlineData("single line", LineEndingStyle.Preserve)]
+ public void Decode_ShouldDetectLineEndings(string content, LineEndingStyle expected)
+ {
+ var (_, format) = EncodingDetector.Decode(Encoding.UTF8.GetBytes(content));
+
+ format.LineEnding.ShouldBe(expected);
+ }
+
+ [Fact]
+ public void RoundTrip_ShouldPreserveBytesExactly()
+ {
+ var original = new UTF8Encoding(true).GetPreamble()
+ .Concat(new UTF8Encoding(false).GetBytes("\r\n \r\n\r\n"))
+ .ToArray();
+
+ var (content, format) = EncodingDetector.Decode(original);
+ var round = EncodingDetector.Encode(content, format);
+
+ round.ShouldBe(original);
+ }
+
+ [Fact]
+ public void Encode_ShouldRewriteLineEndingsWhenAsked()
+ {
+ var (content, format) = EncodingDetector.Decode(Encoding.UTF8.GetBytes("a\r\nb\r\n"));
+
+ var bytes = EncodingDetector.Encode(content, format with { LineEnding = LineEndingStyle.Lf });
+
+ Encoding.UTF8.GetString(bytes).ShouldBe("a\nb\n");
+ }
+
+ [Theory]
+ [InlineData("utf-8")]
+ [InlineData("UTF-8")]
+ [InlineData("us-ascii")]
+ public void Resolve_ShouldAcceptIanaNames(string name) =>
+ EncodingDetector.Resolve(name).ShouldNotBeNull();
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("not-a-real-encoding")]
+ public void Resolve_ShouldReturnNullForUnknownNames(string? name) =>
+ EncodingDetector.Resolve(name).ShouldBeNull();
+}
diff --git a/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs b/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs
new file mode 100644
index 0000000..6e491c8
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs
@@ -0,0 +1,252 @@
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.Migration;
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Services;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Migration;
+
+public class MigrationServiceTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-migration");
+
+ [Fact]
+ public async Task PlanAsync_ShouldCreateThePropsFileAndStripEveryProject()
+ {
+ var (service, _) = Create(Workspace());
+
+ var plan = await service.PlanAsync(Options());
+
+ plan.ProjectCount.ShouldBe(2);
+ plan.Changes.ShouldContain(c => c.Kind == FileChangeKind.Created);
+ plan.Changes.Count(c => c.Kind == FileChangeKind.Modified).ShouldBe(2);
+ plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("13.0.3");
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldNotWriteAnythingToDisk()
+ {
+ var fileSystem = Workspace();
+ var (service, _) = Create(fileSystem);
+
+ await service.PlanAsync(Options());
+
+ fileSystem.FileExists(Path.Combine(Root, "Directory.Packages.props")).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldRecordConflicts()
+ {
+ var (service, _) = Create(Workspace());
+
+ var plan = await service.PlanAsync(Options());
+
+ plan.Conflicts.ShouldContain(c => c.PackageId == "Newtonsoft.Json");
+ plan.Conflicts.First(c => c.PackageId == "Newtonsoft.Json").ResolvedVersion.ShouldBe("13.0.3");
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldRespectTheLowestStrategy()
+ {
+ var (service, _) = Create(Workspace());
+
+ var plan = await service.PlanAsync(
+ Options() with { ConflictStrategy = VersionResolutionStrategy.Lowest }
+ );
+
+ plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("13.0.1");
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldThrowForTheFailStrategy()
+ {
+ var (service, _) = Create(Workspace());
+
+ await Should.ThrowAsync(() =>
+ service.PlanAsync(
+ Options() with
+ {
+ ConflictStrategy = VersionResolutionStrategy.Fail,
+ FailOnConflict = true,
+ }
+ )
+ );
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldUseTheCallerSuppliedResolution()
+ {
+ var (service, _) = Create(Workspace());
+
+ var plan = await service.PlanAsync(Options(), _ => "99.0.0");
+
+ plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("99.0.0");
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldKeepInlineVersionsWhenAsked()
+ {
+ var (service, _) = Create(Workspace());
+
+ var plan = await service.PlanAsync(Options() with { KeepInlineVersions = true });
+
+ plan.Changes.Count.ShouldBe(1);
+ plan.Changes[0].Kind.ShouldBe(FileChangeKind.Created);
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldMergeIntoAnExistingPropsFile()
+ {
+ var fileSystem = Workspace()
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var (service, _) = Create(fileSystem);
+
+ var plan = await service.PlanAsync(Options() with { Merge = true });
+
+ var change = plan.Changes.First(c => c.Path.EndsWith("Directory.Packages.props", StringComparison.Ordinal));
+ change.Kind.ShouldBe(FileChangeKind.Modified);
+ change.NewContent.ShouldContain("Newtonsoft.Json");
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldAlsoHoistPropertiesWhenUnifyingIsOn()
+ {
+ var (service, _) = Create(Workspace());
+
+ var plan = await service.PlanAsync(Options() with { UnifyProperties = true });
+
+ plan.Changes.ShouldContain(c => c.Path.EndsWith("Directory.Build.props", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task PlanAsync_ShouldReturnAnEmptyPlanWhenNoProjectsExist()
+ {
+ var (service, _) = Create(new InMemoryFileSystem().AddDirectory(Root));
+
+ var plan = await service.PlanAsync(Options());
+
+ plan.ProjectCount.ShouldBe(0);
+ plan.HasChanges.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ApplyAsync_ShouldWriteEveryChangeAndTakeABackup()
+ {
+ var fileSystem = Workspace();
+ var (service, _) = Create(fileSystem);
+
+ var plan = await service.PlanAsync(Options());
+ var backup = await service.ApplyAsync(plan, Options());
+
+ fileSystem.FileExists(Path.Combine(Root, "Directory.Packages.props")).ShouldBeTrue();
+ fileSystem.ReadText(Path.Combine(Root, "a", "A.csproj")).ShouldNotContain("Version=");
+ backup.ShouldNotBeNull();
+ backup.Files.Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_ShouldSkipTheBackupWhenDisabled()
+ {
+ var (service, _) = Create(Workspace());
+
+ var plan = await service.PlanAsync(Options());
+ var backup = await service.ApplyAsync(plan, Options() with { CreateBackup = false });
+
+ backup.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task ApplyAsync_ShouldRewriteLineEndingsWhenRequested()
+ {
+ var fileSystem = new InMemoryFileSystem().AddFile(
+ Path.Combine(Root, "a", "A.csproj"),
+ System.Text.Encoding.UTF8.GetBytes(
+ "\r\n \r\n \r\n \r\n\r\n"
+ )
+ );
+
+ var (service, _) = Create(fileSystem);
+ var options = Options() with { LineEnding = LineEndingStyle.Lf, CreateBackup = false };
+
+ var plan = await service.PlanAsync(options);
+ await service.ApplyAsync(plan, options);
+
+ var written = System.Text.Encoding.UTF8.GetString(
+ fileSystem.ReadBytes(Path.Combine(Root, "a", "A.csproj"))
+ );
+
+ written.ShouldNotContain("\r\n");
+ }
+
+ [Fact]
+ public async Task PlanRevertAsync_ShouldInlineVersionsAndDeleteThePropsFile()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(
+ Path.Combine(Root, "a", "A.csproj"),
+ """
+
+
+
+
+
+ """
+ )
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var (service, _) = Create(fileSystem);
+
+ var plan = await service.PlanRevertAsync(Options());
+
+ plan.Changes.ShouldContain(c => c.Kind == FileChangeKind.Deleted);
+ plan.Changes.First(c => c.Kind == FileChangeKind.Modified).NewContent
+ .ShouldContain("Version=\"3.1.1\"");
+ }
+
+ [Fact]
+ public async Task PlanRevertAsync_ShouldReportPackagesWithNoCentralVersion()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(
+ Path.Combine(Root, "a", "A.csproj"),
+ """
+
+
+
+
+
+ """
+ )
+ .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps);
+
+ var (service, _) = Create(fileSystem);
+
+ var plan = await service.PlanRevertAsync(Options());
+
+ plan.Findings.ShouldContain(f => f.RuleId == Core.Analysis.RuleIds.MissingCentralVersion);
+ }
+
+ private static MigrationOptions Options() =>
+ new() { RootDirectory = Root, BackupDirectory = Root };
+
+ private static InMemoryFileSystem Workspace() =>
+ new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "a", "A.csproj"), Sample.AppProject)
+ .AddFile(Path.Combine(Root, "b", "B.csproj"), Sample.LibProject);
+
+ private static (MigrationService Service, InMemoryFileSystem FileSystem) Create(
+ InMemoryFileSystem fileSystem
+ )
+ {
+ var discovery = new ProjectDiscoveryService(fileSystem);
+ var service = new MigrationService(
+ discovery,
+ fileSystem,
+ new BackupService(fileSystem),
+ new VersionSelector(new VersionConflictResolver())
+ );
+
+ return (service, fileSystem);
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs
new file mode 100644
index 0000000..c739291
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs
@@ -0,0 +1,74 @@
+using CentralConfigGenerator.Core.Generators;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Migration;
+
+public class PackagesPropsEditorTests
+{
+ [Fact]
+ public void SetVersion_ShouldUpdateAnExistingEntry()
+ {
+ var updated = PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Serilog", "4.0.0");
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("Include=\"Serilog\" Version=\"4.0.0\"");
+ }
+
+ [Fact]
+ public void SetVersion_ShouldReturnNullWhenNothingChanges() =>
+ PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Serilog", "3.1.1").ShouldBeNull();
+
+ [Fact]
+ public void SetVersion_ShouldAppendANewEntry()
+ {
+ var updated = PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Polly", "8.4.1");
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("");
+ updated.ShouldContain("Serilog");
+ }
+
+ [Fact]
+ public void RemoveEntry_ShouldDeleteThePackageVersion()
+ {
+ var updated = PackagesPropsEditor.RemoveEntry(Sample.PackagesProps, "Serilog");
+
+ updated.ShouldNotBeNull();
+ updated.ShouldNotContain("Serilog");
+ updated.ShouldContain("Newtonsoft.Json");
+ }
+
+ [Fact]
+ public void RemoveEntry_ShouldReturnNullForAnUnknownPackage() =>
+ PackagesPropsEditor.RemoveEntry(Sample.PackagesProps, "Nope").ShouldBeNull();
+
+ [Fact]
+ public void NormalizeCasing_ShouldRewriteTheIncludeAttribute()
+ {
+ var content = Sample.PackagesProps.Replace("Newtonsoft.Json", "newtonsoft.json");
+
+ var updated = PackagesPropsEditor.NormalizeCasing(content, "Newtonsoft.Json");
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("Include=\"Newtonsoft.Json\"");
+ }
+
+ [Fact]
+ public void DeduplicateEntries_ShouldKeepOnlyTheFirstSpelling()
+ {
+ var content = """
+
+
+
+
+
+
+ """;
+
+ var updated = PackagesPropsEditor.DeduplicateEntries(content);
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("Version=\"3.1.1\"");
+ updated.ShouldNotContain("2.0.0");
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs
new file mode 100644
index 0000000..fa4a0ec
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs
@@ -0,0 +1,123 @@
+using CentralConfigGenerator.Core.Generators;
+
+namespace CentralConfigGenerator.Core.Tests.Migration;
+
+public class PackagesPropsWriterTests
+{
+ private static readonly Dictionary Versions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Serilog"] = "3.1.1",
+ ["Newtonsoft.Json"] = "13.0.3",
+ };
+
+ [Fact]
+ public void Create_ShouldEnableCentralManagementAndSortEntries()
+ {
+ var content = PackagesPropsWriter.Create(Versions);
+
+ content.ShouldContain("true");
+ content.IndexOf("Newtonsoft.Json", StringComparison.Ordinal)
+ .ShouldBeLessThan(content.IndexOf("Serilog", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Create_ShouldEmitTransitivePinningWhenRequested()
+ {
+ var content = PackagesPropsWriter.Create(
+ Versions,
+ new PackagesPropsOptions { TransitivePinning = true }
+ );
+
+ content.ShouldContain("true");
+ }
+
+ [Fact]
+ public void Create_ShouldEmitGlobalPackageReferencesSeparately()
+ {
+ var content = PackagesPropsWriter.Create(
+ Versions,
+ new PackagesPropsOptions { GlobalPackages = ["Serilog"] }
+ );
+
+ content.ShouldContain("");
+ content.ShouldNotContain(" { ["A&B"] = "1.0.0" }
+ );
+
+ content.ShouldContain("A&B");
+ }
+
+ [Fact]
+ public void Merge_ShouldAddMissingEntriesAndKeepComments()
+ {
+ var existing = """
+
+
+ true
+
+
+
+
+
+
+ """;
+
+ var merged = PackagesPropsWriter.Merge(existing, Versions);
+
+ merged.ShouldContain("");
+ merged.ShouldContain("Include=\"Serilog\" Version=\"3.1.1\"");
+ merged.ShouldContain("Newtonsoft.Json");
+ }
+
+ [Fact]
+ public void Merge_ShouldAddTransitivePinningToAnExistingPropertyGroup()
+ {
+ var existing = """
+
+
+ true
+
+
+ """;
+
+ var merged = PackagesPropsWriter.Merge(
+ existing,
+ Versions,
+ new PackagesPropsOptions { TransitivePinning = true }
+ );
+
+ merged.ShouldContain("CentralPackageTransitivePinningEnabled");
+ }
+
+ [Fact]
+ public void Merge_ShouldFallBackToCreateForUnparseableInput()
+ {
+ var merged = PackagesPropsWriter.Merge("", Versions);
+
+ merged.ShouldContain("true");
+ }
+
+ [Fact]
+ public void Merge_ShouldUpdateChildElementVersions()
+ {
+ var existing = """
+
+
+
+ 1.0.0
+
+
+
+ """;
+
+ var merged = PackagesPropsWriter.Merge(existing, Versions);
+
+ merged.ShouldContain("3.1.1");
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs
new file mode 100644
index 0000000..bc85450
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs
@@ -0,0 +1,185 @@
+using CentralConfigGenerator.Core.Migration;
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Migration;
+
+public class ProjectRewriterTests
+{
+ [Fact]
+ public void StripVersions_ShouldRemoveAttributeVersions()
+ {
+ var updated = ProjectRewriter.StripVersions(Project(Sample.AppProject), out var touched);
+
+ updated.ShouldNotBeNull();
+ updated.ShouldNotContain("Version=");
+ updated.ShouldContain("");
+ touched.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public void StripVersions_ShouldCollapseChildElementVersions()
+ {
+ var updated = ProjectRewriter.StripVersions(Project(Sample.LibProject), out _);
+
+ updated.ShouldNotBeNull();
+ updated.ShouldNotContain("");
+ updated.ShouldContain("");
+ }
+
+ [Fact]
+ public void StripVersions_ShouldPreserveEverythingElse()
+ {
+ var updated = ProjectRewriter.StripVersions(Project(Sample.AppProject), out _);
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("Exe");
+ updated.ShouldContain("net8.0");
+ }
+
+ [Fact]
+ public void StripVersions_ShouldReturnNullWhenThereIsNothingToDo()
+ {
+ var content = """
+
+
+
+
+
+ """;
+
+ ProjectRewriter.StripVersions(Project(content), out _).ShouldBeNull();
+ }
+
+ [Fact]
+ public void StripVersion_ShouldOnlyTouchTheNamedPackage()
+ {
+ var updated = ProjectRewriter.StripVersion(Project(Sample.AppProject), "Serilog");
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("Include=\"Newtonsoft.Json\" Version=\"13.0.1\"");
+ updated.ShouldContain("");
+ }
+
+ [Fact]
+ public void RestoreVersions_ShouldInlineCentralVersionsAfterInclude()
+ {
+ var content = """
+
+
+
+
+
+ """;
+
+ var updated = ProjectRewriter.RestoreVersions(
+ Project(content),
+ new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Serilog"] = "3.1.1" },
+ out var unresolved
+ );
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("Include=\"Serilog\" Version=\"3.1.1\" PrivateAssets=\"all\"");
+ unresolved.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void RestoreVersions_ShouldReportPackagesWithNoCentralEntry()
+ {
+ var content = """
+
+
+
+
+
+ """;
+
+ ProjectRewriter.RestoreVersions(
+ Project(content),
+ new Dictionary(),
+ out var unresolved
+ );
+
+ unresolved.ShouldBe(["Unknown"]);
+ }
+
+ [Fact]
+ public void RemoveProperties_ShouldDeleteMatchingValuesOnly()
+ {
+ var updated = ProjectRewriter.RemoveProperties(
+ Project(Sample.AppProject),
+ ["Nullable", "TargetFramework"],
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Nullable"] = "enable",
+ ["TargetFramework"] = "net9.0",
+ }
+ );
+
+ updated.ShouldNotBeNull();
+ updated.ShouldNotContain("");
+ // net8.0 does not match the expected net9.0, so it must stay.
+ updated.ShouldContain("net8.0");
+ }
+
+ [Fact]
+ public void RemoveProperties_ShouldLeaveConditionedGroupsAlone()
+ {
+ var content = """
+
+
+ enable
+
+
+ """;
+
+ ProjectRewriter.RemoveProperties(Project(content), ["Nullable"]).ShouldBeNull();
+ }
+
+ [Fact]
+ public void RemoveDuplicateReference_ShouldKeepTheFirstOccurrence()
+ {
+ var content = """
+
+
+
+
+
+
+ """;
+
+ var updated = ProjectRewriter.RemoveDuplicateReference(Project(content), "xunit");
+
+ updated.ShouldNotBeNull();
+ updated.Split("
+ ProjectRewriter.RemoveDuplicateReference(Project(Sample.AppProject), "Serilog").ShouldBeNull();
+
+ [Fact]
+ public void NormalizeCasing_ShouldRewriteToCanonicalSpelling()
+ {
+ var content = """
+
+
+
+
+
+ """;
+
+ var updated = ProjectRewriter.NormalizeCasing(Project(content), "Newtonsoft.Json");
+
+ updated.ShouldNotBeNull();
+ updated.ShouldContain("Include=\"Newtonsoft.Json\"");
+ }
+
+ [Fact]
+ public void NormalizeCasing_ShouldReturnNullWhenAlreadyCanonical() =>
+ ProjectRewriter.NormalizeCasing(Project(Sample.AppProject), "Newtonsoft.Json").ShouldBeNull();
+
+ private static ProjectFile Project(string content) =>
+ new() { Path = Path.Combine(Path.GetTempPath(), "Test.csproj"), Content = content };
+}
diff --git a/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs
new file mode 100644
index 0000000..29f27cc
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs
@@ -0,0 +1,86 @@
+using CentralConfigGenerator.Core.Migration;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Tests.Migration;
+
+public class PropertyHoisterTests
+{
+ [Fact]
+ public void FindHoistableProperties_ShouldReturnUnanimousValues()
+ {
+ var projects = new[]
+ {
+ Project("enable"),
+ Project("enable"),
+ };
+
+ var result = PropertyHoister.FindHoistableProperties(
+ projects,
+ BuildPropertyDefaults.Recommended
+ );
+
+ result["Nullable"].ShouldBe("enable");
+ }
+
+ [Fact]
+ public void FindHoistableProperties_ShouldSkipPropertiesThatDisagree()
+ {
+ var projects = new[]
+ {
+ Project("net8.0"),
+ Project("net9.0"),
+ };
+
+ PropertyHoister
+ .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended)
+ .ShouldNotContainKey("TargetFramework");
+ }
+
+ [Fact]
+ public void FindHoistableProperties_ShouldRequireAtLeastTwoProjects()
+ {
+ var projects = new[]
+ {
+ Project("enable"),
+ };
+
+ PropertyHoister
+ .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended)
+ .ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void FindHoistableProperties_ShouldNeverHoistIdentityProperties()
+ {
+ var projects = new[]
+ {
+ Project("Same"),
+ Project("Same"),
+ };
+
+ PropertyHoister
+ .FindHoistableProperties(projects, ["AssemblyName"])
+ .ShouldNotContainKey("AssemblyName");
+ }
+
+ [Fact]
+ public void FindHoistableProperties_ShouldIgnoreConditionedGroups()
+ {
+ var projects = new[]
+ {
+ Project("enable"),
+ Project("enable"),
+ };
+
+ PropertyHoister
+ .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended)
+ .ShouldBeEmpty();
+ }
+
+ private static ProjectFile Project(string body) =>
+ new()
+ {
+ Path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".csproj"),
+ Content = $"{body}",
+ };
+}
diff --git a/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs b/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs
new file mode 100644
index 0000000..a908257
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs
@@ -0,0 +1,146 @@
+using System.Text.Json;
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Reporting;
+
+namespace CentralConfigGenerator.Core.Tests.Reporting;
+
+public class ReportWriterTests
+{
+ private static readonly AnalysisReport Report = new()
+ {
+ ProjectCount = 2,
+ PackageCount = 3,
+ RootDirectory = Path.Combine(Path.GetTempPath(), "ccg-report"),
+ Duration = TimeSpan.FromSeconds(1.5),
+ Findings =
+ [
+ new Finding
+ {
+ RuleId = RuleIds.SecurityVulnerability,
+ Severity = Severity.Critical,
+ Message = "'Pkg' 1.0.0 has a known advisory.",
+ PackageId = "Pkg",
+ Version = "1.0.0",
+ ProjectPath = Path.Combine(Path.GetTempPath(), "ccg-report", "src", "A.csproj"),
+ Recommendation = "Upgrade to 1.0.1.",
+ Fix = FixKind.SetCentralVersion,
+ FixValue = "1.0.1",
+ },
+ new Finding
+ {
+ RuleId = RuleIds.FloatingVersion,
+ Severity = Severity.Moderate,
+ Message = "'xunit' uses a floating version.",
+ PackageId = "xunit",
+ },
+ ],
+ };
+
+ [Fact]
+ public void Json_ShouldProduceParseableOutputWithASummary()
+ {
+ var json = new JsonReportWriter().Write(Report);
+
+ using var document = JsonDocument.Parse(json);
+ var summary = document.RootElement.GetProperty("summary");
+
+ summary.GetProperty("projectCount").GetInt32().ShouldBe(2);
+ summary.GetProperty("bySeverity").GetProperty("critical").GetInt32().ShouldBe(1);
+ document.RootElement.GetProperty("findings").GetArrayLength().ShouldBe(2);
+ }
+
+ [Fact]
+ public void Json_ShouldNotEscapeApostrophes() =>
+ new JsonReportWriter().Write(Report).ShouldContain("'Pkg'");
+
+ [Fact]
+ public void Json_ShouldEmitWorkspaceRelativeProjectPaths()
+ {
+ var json = new JsonReportWriter().Write(Report);
+
+ json.ShouldContain("src/A.csproj");
+ }
+
+ [Fact]
+ public void Sarif_ShouldEmitValidSchemaAndRules()
+ {
+ var sarif = new SarifReportWriter().Write(Report);
+
+ using var document = JsonDocument.Parse(sarif);
+ document.RootElement.GetProperty("version").GetString().ShouldBe("2.1.0");
+ document.RootElement.TryGetProperty("$schema", out _).ShouldBeTrue();
+
+ var run = document.RootElement.GetProperty("runs")[0];
+ run.GetProperty("results").GetArrayLength().ShouldBe(2);
+ run.GetProperty("tool").GetProperty("driver").GetProperty("rules").GetArrayLength().ShouldBe(2);
+ }
+
+ [Fact]
+ public void Sarif_ShouldMapCriticalToError()
+ {
+ var sarif = new SarifReportWriter().Write(Report);
+
+ using var document = JsonDocument.Parse(sarif);
+ document
+ .RootElement.GetProperty("runs")[0]
+ .GetProperty("results")[0]
+ .GetProperty("level")
+ .GetString()
+ .ShouldBe("error");
+ }
+
+ [Fact]
+ public void Markdown_ShouldLeadWithTheVerdict()
+ {
+ var markdown = new MarkdownReportWriter().Write(Report);
+
+ markdown.ShouldStartWith("## Dependency health:");
+ markdown.ShouldContain("Critical issues found");
+ markdown.ShouldContain("
+ ReportWriterFactory.Create(format).Format.ShouldBe(format);
+}
diff --git a/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs b/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs
new file mode 100644
index 0000000..d6e41ad
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs
@@ -0,0 +1,156 @@
+using CentralConfigGenerator.Core.Services;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+
+namespace CentralConfigGenerator.Core.Tests.Services;
+
+public class BackupServiceTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-backup");
+
+ [Fact]
+ public async Task CreateAsync_ShouldCopyEveryRequestedFile()
+ {
+ var path = Path.Combine(Root, "App.csproj");
+ var fileSystem = new InMemoryFileSystem().AddFile(path, "");
+ var service = new BackupService(fileSystem);
+
+ var backup = await service.CreateAsync(Root, Root, "migrate", [path]);
+
+ backup.ShouldNotBeNull();
+ backup.Files.Count.ShouldBe(1);
+ backup.Operation.ShouldBe("migrate");
+ }
+
+ [Fact]
+ public async Task CreateAsync_ShouldReturnNullWhenNoFilesAreGiven()
+ {
+ var service = new BackupService(new InMemoryFileSystem());
+
+ (await service.CreateAsync(Root, Root, "migrate", [])).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task RestoreAsync_ShouldPutTheOriginalContentBack()
+ {
+ var path = Path.Combine(Root, "App.csproj");
+ var fileSystem = new InMemoryFileSystem().AddFile(path, "original");
+ var service = new BackupService(fileSystem);
+
+ var backup = await service.CreateAsync(Root, Root, "migrate", [path]);
+ await fileSystem.WriteAllTextAsync(path, "modified");
+
+ await service.RestoreAsync(Root, backup!.Id);
+
+ fileSystem.ReadText(path).ShouldBe("original");
+ }
+
+ [Fact]
+ public async Task RestoreAsync_ShouldDeleteFilesThatTheOperationCreated()
+ {
+ var path = Path.Combine(Root, "Directory.Packages.props");
+ var fileSystem = new InMemoryFileSystem().AddDirectory(Root);
+ var service = new BackupService(fileSystem);
+
+ // The file does not exist yet, so the backup records it as "created".
+ var backup = await service.CreateAsync(Root, Root, "migrate", [path]);
+ await fileSystem.WriteAllTextAsync(path, "");
+
+ await service.RestoreAsync(Root, backup!.Id);
+
+ fileSystem.FileExists(path).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task RestoreLatestAsync_ShouldPickTheNewestBackup()
+ {
+ var path = Path.Combine(Root, "App.csproj");
+ var fileSystem = new InMemoryFileSystem().AddFile(path, "v1");
+ var service = new BackupService(fileSystem);
+
+ await service.CreateAsync(Root, Root, "first", [path]);
+ await fileSystem.WriteAllTextAsync(path, "v2");
+ await Task.Delay(5);
+ await service.CreateAsync(Root, Root, "second", [path]);
+ await fileSystem.WriteAllTextAsync(path, "v3");
+
+ var restored = await service.RestoreLatestAsync(Root);
+
+ restored!.Operation.ShouldBe("second");
+ fileSystem.ReadText(path).ShouldBe("v2");
+ }
+
+ [Fact]
+ public async Task List_ShouldReturnBackupsNewestFirst()
+ {
+ var path = Path.Combine(Root, "App.csproj");
+ var fileSystem = new InMemoryFileSystem().AddFile(path, "x");
+ var service = new BackupService(fileSystem);
+
+ await service.CreateAsync(Root, Root, "first", [path]);
+ await Task.Delay(5);
+ await service.CreateAsync(Root, Root, "second", [path]);
+
+ var backups = service.List(Root);
+
+ backups.Count.ShouldBe(2);
+ backups[0].Operation.ShouldBe("second");
+ }
+
+ [Fact]
+ public async Task Prune_ShouldKeepOnlyTheRetainedBackups()
+ {
+ var path = Path.Combine(Root, "App.csproj");
+ var fileSystem = new InMemoryFileSystem().AddFile(path, "x");
+ var service = new BackupService(fileSystem);
+
+ for (var i = 0; i < 4; i++)
+ {
+ await service.CreateAsync(Root, Root, $"op{i}", [path]);
+ await Task.Delay(5);
+ }
+
+ var removed = service.Prune(Root, retention: 2);
+
+ removed.Count.ShouldBe(2);
+ service.List(Root).Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task PruneAll_ShouldRemoveEverything()
+ {
+ var path = Path.Combine(Root, "App.csproj");
+ var fileSystem = new InMemoryFileSystem().AddFile(path, "x");
+ var service = new BackupService(fileSystem);
+
+ await service.CreateAsync(Root, Root, "op", [path]);
+
+ service.PruneAll(Root);
+
+ service.List(Root).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task EnsureGitIgnoreAsync_ShouldAppendTheBackupFolder()
+ {
+ var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, ".gitignore"), "bin/\n");
+ var service = new BackupService(fileSystem);
+
+ await service.EnsureGitIgnoreAsync(Root, BackupService.DefaultBackupFolderName);
+
+ var content = fileSystem.ReadText(Path.Combine(Root, ".gitignore"));
+ content.ShouldContain("bin/");
+ content.ShouldContain(".centralconfig-backups/");
+ }
+
+ [Fact]
+ public async Task EnsureGitIgnoreAsync_ShouldNotDuplicateAnExistingEntry()
+ {
+ var path = Path.Combine(Root, ".gitignore");
+ var fileSystem = new InMemoryFileSystem().AddFile(path, ".centralconfig-backups/\n");
+ var service = new BackupService(fileSystem);
+
+ await service.EnsureGitIgnoreAsync(Root, BackupService.DefaultBackupFolderName);
+
+ fileSystem.ReadText(path).Split(".centralconfig-backups/").Length.ShouldBe(2);
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs b/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs
new file mode 100644
index 0000000..5337c3b
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs
@@ -0,0 +1,42 @@
+using CentralConfigGenerator.Core.Services;
+
+namespace CentralConfigGenerator.Core.Tests.Services;
+
+public class UnifiedDiffTests
+{
+ [Fact]
+ public void Create_ShouldReturnEmptyForIdenticalInput() =>
+ UnifiedDiff.Create("same\ntext", "same\ntext", "a", "b").ShouldBeEmpty();
+
+ [Fact]
+ public void Create_ShouldMarkAddedAndRemovedLines()
+ {
+ var diff = UnifiedDiff.Create("one\ntwo\nthree", "one\nTWO\nthree", "a.txt", "b.txt");
+
+ diff.ShouldContain("--- a.txt");
+ diff.ShouldContain("+++ b.txt");
+ diff.ShouldContain("-two");
+ diff.ShouldContain("+TWO");
+ diff.ShouldContain(" one");
+ }
+
+ [Fact]
+ public void Create_ShouldIncludeAHunkHeader() =>
+ UnifiedDiff.Create("a", "b", "x", "y").ShouldContain("@@");
+
+ [Fact]
+ public void Create_ShouldHandlePureInsertion()
+ {
+ var diff = UnifiedDiff.Create(string.Empty, "new line", "/dev/null", "b.txt");
+
+ diff.ShouldContain("+new line");
+ }
+
+ [Fact]
+ public void Create_ShouldNormaliseWindowsLineEndings()
+ {
+ var diff = UnifiedDiff.Create("a\r\nb", "a\nb", "x", "y");
+
+ diff.ShouldBeEmpty();
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs b/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs
new file mode 100644
index 0000000..c10c978
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs
@@ -0,0 +1,100 @@
+using CentralConfigGenerator.Core.Services;
+
+namespace CentralConfigGenerator.Core.Tests.Services;
+
+public class VersionSelectorTests
+{
+ private readonly VersionSelector _selector = new(new VersionConflictResolver());
+
+ [Fact]
+ public void Select_ShouldReturnTheOnlyVersion() =>
+ _selector
+ .Select("Pkg", ["1.0.0"], VersionResolutionStrategy.Highest, false)
+ .ShouldBe("1.0.0");
+
+ [Fact]
+ public void Select_ShouldPickTheHighestVersion() =>
+ _selector
+ .Select("Pkg", ["1.0.0", "2.1.0", "2.0.0"], VersionResolutionStrategy.Highest, false)
+ .ShouldBe("2.1.0");
+
+ [Fact]
+ public void Select_ShouldPickTheLowestVersion() =>
+ _selector
+ .Select("Pkg", ["1.0.0", "2.1.0"], VersionResolutionStrategy.Lowest, false)
+ .ShouldBe("1.0.0");
+
+ [Fact]
+ public void Select_ShouldDropPrereleasesWhenStableExists() =>
+ _selector
+ .Select("Pkg", ["2.0.0-beta", "1.9.0"], VersionResolutionStrategy.Highest, true)
+ .ShouldBe("1.9.0");
+
+ [Fact]
+ public void Select_ShouldKeepPrereleasesWhenNoStableExists() =>
+ _selector
+ .Select("Pkg", ["2.0.0-beta", "2.0.0-alpha"], VersionResolutionStrategy.Highest, true)
+ .ShouldBe("2.0.0-beta");
+
+ [Fact]
+ public void Select_ShouldThrowForTheFailStrategy() =>
+ Should.Throw(() =>
+ _selector.Select("Pkg", ["1.0.0", "2.0.0"], VersionResolutionStrategy.Fail, false)
+ );
+
+ [Fact]
+ public void Select_ShouldThrowWhenNoVersionsAreSupplied() =>
+ Should.Throw(() =>
+ _selector.Select("Pkg", [], VersionResolutionStrategy.Highest, false)
+ );
+
+ [Theory]
+ [InlineData("4.*", true)]
+ [InlineData("1.2.*", true)]
+ [InlineData("[4.0.0,)", true)]
+ [InlineData("1.2.3", false)]
+ [InlineData("[1.0.0,2.0.0)", false)]
+ public void IsFloating_ShouldDetectWildcardsAndOpenRanges(string version, bool expected) =>
+ VersionSelector.IsFloating(version).ShouldBe(expected);
+
+ [Fact]
+ public void Select_ShouldIgnorePrereleaseTagsUnderTheVersionComparisonScope()
+ {
+ // VersionComparison.Version compares only the numeric parts, so the release and the
+ // pre-release of the same number tie and the first candidate wins.
+ var chosen = _selector.Select(
+ "Pkg",
+ ["2.0.0-beta", "2.0.0"],
+ VersionResolutionStrategy.Highest,
+ ignorePrerelease: false,
+ global::NuGet.Versioning.VersionComparison.Version
+ );
+
+ chosen.ShouldBeOneOf("2.0.0", "2.0.0-beta");
+ }
+
+ [Fact]
+ public void Select_ShouldRankReleaseAbovePrereleaseUnderVersionRelease() =>
+ _selector
+ .Select(
+ "Pkg",
+ ["2.0.0-beta", "2.0.0"],
+ VersionResolutionStrategy.Highest,
+ ignorePrerelease: false,
+ global::NuGet.Versioning.VersionComparison.VersionRelease
+ )
+ .ShouldBe("2.0.0");
+
+ [Fact]
+ public void Select_ShouldFallBackToTheResolverForUnparseableVersions() =>
+ _selector
+ .Select("Pkg", ["$(SomeProperty)", "$(Other)"], VersionResolutionStrategy.Highest, false)
+ .ShouldNotBeNullOrEmpty();
+
+ [Theory]
+ [InlineData("1.0.0-beta", true)]
+ [InlineData("1.0.0", false)]
+ [InlineData("not-a-version", false)]
+ public void IsPrerelease_ShouldDetectPrereleaseTags(string version, bool expected) =>
+ VersionSelector.IsPrerelease(version).ShouldBe(expected);
+}
diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs b/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs
new file mode 100644
index 0000000..7a77352
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs
@@ -0,0 +1,75 @@
+using CentralConfigGenerator.Core.Services;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Tests.TestSupport;
+
+///
+/// A scripted dotnet CLI. Tests decide which package versions make the "test run" pass,
+/// which is what drives the bisect logic.
+///
+public sealed class FakeDotNetCli(Func, bool> testsPass, Func readProps)
+ : IDotNetCliService
+{
+ public int RestoreCount { get; private set; }
+
+ public int TestCount { get; private set; }
+
+ public bool IsAvailable { get; set; } = true;
+
+ public Task RunAsync(
+ string arguments,
+ string workingDirectory,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default
+ ) => Task.FromResult(Success());
+
+ public Task RestoreAsync(
+ string target,
+ string workingDirectory,
+ CancellationToken cancellationToken = default
+ )
+ {
+ RestoreCount++;
+ return Task.FromResult(Success());
+ }
+
+ public Task BuildAsync(
+ string target,
+ string workingDirectory,
+ CancellationToken cancellationToken = default
+ ) => Task.FromResult(Success());
+
+ public Task TestAsync(
+ string target,
+ string workingDirectory,
+ string? filter = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ TestCount++;
+
+ var versions = MsBuildXml.TryParse(readProps(), out var document, out _)
+ ? PackageReferenceReader.ReadPackageVersions(document)
+ : new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ return Task.FromResult(testsPass(versions) ? Success() : Failure());
+ }
+
+ private static ProcessResult Success() =>
+ new()
+ {
+ ExitCode = 0,
+ StandardOutput = string.Empty,
+ StandardError = string.Empty,
+ Duration = TimeSpan.Zero,
+ };
+
+ private static ProcessResult Failure() =>
+ new()
+ {
+ ExitCode = 1,
+ StandardOutput = string.Empty,
+ StandardError = "tests failed",
+ Duration = TimeSpan.Zero,
+ };
+}
diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs b/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs
new file mode 100644
index 0000000..2003e11
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs
@@ -0,0 +1,88 @@
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.NuGet;
+using NuGet.Versioning;
+
+namespace CentralConfigGenerator.Core.Tests.TestSupport;
+
+/// A scripted feed so the network-backed rules can be tested deterministically.
+public sealed class FakeNuGetFeed : INuGetFeedService
+{
+ private readonly Dictionary _snapshots =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public IReadOnlyList Sources => ["https://fake/index.json"];
+
+ public FakeNuGetFeed Add(
+ string packageId,
+ string[]? versions = null,
+ bool deprecated = false,
+ string? alternate = null,
+ string? license = null,
+ (string Version, Severity Severity)[]? vulnerabilities = null
+ )
+ {
+ var parsed = (versions ?? ["1.0.0"]).Select(NuGetVersion.Parse).OrderByDescending(v => v).ToList();
+
+ _snapshots[packageId] = new PackageMetadataSnapshot
+ {
+ PackageId = packageId,
+ Found = true,
+ AllVersions = parsed,
+ LatestStable = parsed.FirstOrDefault(v => !v.IsPrerelease),
+ LatestIncludingPrerelease = parsed.FirstOrDefault(),
+ IsDeprecated = deprecated,
+ DeprecationReasons = deprecated ? ["Legacy"] : [],
+ AlternatePackageId = alternate,
+ License = license,
+ Vulnerabilities =
+ vulnerabilities
+ ?.Select(v => new PackageVulnerability
+ {
+ Severity = v.Severity,
+ AdvisoryUrl = $"https://advisory/{packageId}",
+ AffectedRange = v.Version,
+ })
+ .ToList() ?? [],
+ };
+
+ return this;
+ }
+
+ public Task GetMetadataAsync(
+ string packageId,
+ CancellationToken cancellationToken = default
+ ) =>
+ Task.FromResult(
+ _snapshots.TryGetValue(packageId, out var snapshot)
+ ? snapshot
+ : new PackageMetadataSnapshot { PackageId = packageId, Found = false }
+ );
+
+ public async Task> GetMetadataAsync(
+ IEnumerable packageIds,
+ int maxParallelism,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var id in packageIds)
+ {
+ result[id] = await GetMetadataAsync(id, cancellationToken);
+ }
+
+ return result;
+ }
+}
+
+/// A package graph service that returns whatever the test hands it.
+public sealed class FakePackageGraphService(PackageGraph graph) : IPackageGraphService
+{
+ public Task LoadAsync(
+ string target,
+ string workingDirectory,
+ bool includeTransitive,
+ CancellationToken cancellationToken = default
+ ) => Task.FromResult(graph);
+}
diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs b/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs
new file mode 100644
index 0000000..c811c87
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs
@@ -0,0 +1,142 @@
+using System.Text;
+using CentralConfigGenerator.Core.IO;
+
+namespace CentralConfigGenerator.Core.Tests.TestSupport;
+
+///
+/// An in-memory so the file-touching services can be tested without
+/// going near a real disk.
+///
+public sealed class InMemoryFileSystem : IFileSystem
+{
+ private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase);
+
+ public InMemoryFileSystem AddFile(string path, string content)
+ {
+ var full = Normalize(path);
+ _files[full] = new UTF8Encoding(false).GetBytes(content);
+ AddParentDirectories(full);
+ return this;
+ }
+
+ public InMemoryFileSystem AddFile(string path, byte[] bytes)
+ {
+ var full = Normalize(path);
+ _files[full] = bytes;
+ AddParentDirectories(full);
+ return this;
+ }
+
+ public InMemoryFileSystem AddDirectory(string path)
+ {
+ var full = Normalize(path);
+ _directories.Add(full);
+ AddParentDirectories(full);
+ return this;
+ }
+
+ public string ReadText(string path) =>
+ new UTF8Encoding(false).GetString(_files[Normalize(path)]);
+
+ public byte[] ReadBytes(string path) => _files[Normalize(path)];
+
+ public IReadOnlyCollection Files => _files.Keys;
+
+ public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
+
+ public bool DirectoryExists(string path) => _directories.Contains(Normalize(path));
+
+ public void CreateDirectory(string path) => AddDirectory(path);
+
+ public void DeleteFile(string path) => _files.Remove(Normalize(path));
+
+ public void DeleteDirectory(string path, bool recursive)
+ {
+ var prefix = Normalize(path) + Path.DirectorySeparatorChar;
+
+ foreach (var file in _files.Keys.Where(f => f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList())
+ {
+ _files.Remove(file);
+ }
+
+ foreach (var directory in _directories.Where(d => d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList())
+ {
+ _directories.Remove(directory);
+ }
+
+ _directories.Remove(Normalize(path));
+ }
+
+ public IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive)
+ {
+ var prefix = Normalize(path) + Path.DirectorySeparatorChar;
+
+ return _files
+ .Keys.Where(f => f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Where(f =>
+ recursive
+ || !f[prefix.Length..].Contains(Path.DirectorySeparatorChar)
+ )
+ .OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public IReadOnlyList EnumerateDirectories(string path)
+ {
+ var prefix = Normalize(path) + Path.DirectorySeparatorChar;
+
+ return _directories
+ .Where(d => d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Where(d => !d[prefix.Length..].Contains(Path.DirectorySeparatorChar))
+ .OrderBy(d => d, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_files[Normalize(path)]);
+
+ public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default)
+ {
+ var full = Normalize(path);
+ _files[full] = bytes;
+ AddParentDirectories(full);
+ return Task.CompletedTask;
+ }
+
+ public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) =>
+ Task.FromResult(ReadText(path));
+
+ public Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default)
+ {
+ AddFile(path, contents);
+ return Task.CompletedTask;
+ }
+
+ public void CopyFile(string source, string destination, bool overwrite)
+ {
+ var full = Normalize(destination);
+ _files[full] = _files[Normalize(source)];
+ AddParentDirectories(full);
+ }
+
+ public DateTimeOffset GetLastWriteTime(string path) => DateTimeOffset.UtcNow;
+
+ public long GetFileSize(string path) => _files[Normalize(path)].Length;
+
+ private static string Normalize(string path) =>
+ Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar);
+
+ private void AddParentDirectories(string path)
+ {
+ // Every ancestor must be registered, even if a sibling already registered one of them.
+ for (
+ var directory = Path.GetDirectoryName(path);
+ !string.IsNullOrEmpty(directory);
+ directory = Path.GetDirectoryName(directory)
+ )
+ {
+ _directories.Add(directory);
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs b/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs
new file mode 100644
index 0000000..7de402a
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs
@@ -0,0 +1,46 @@
+namespace CentralConfigGenerator.Core.Tests.TestSupport;
+
+/// Reusable project XML fixtures.
+public static class Sample
+{
+ public const string AppProject = """
+
+
+ Exe
+ net8.0
+ enable
+
+
+
+
+
+
+ """;
+
+ public const string LibProject = """
+
+
+ net8.0
+ enable
+
+
+
+
+ 2.12.0
+
+
+
+ """;
+
+ public const string PackagesProps = """
+
+
+ true
+
+
+
+
+
+
+ """;
+}
diff --git a/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs
new file mode 100644
index 0000000..d708f80
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs
@@ -0,0 +1,121 @@
+using CentralConfigGenerator.Core.Migration;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+using CentralConfigGenerator.Core.Workspace;
+
+namespace CentralConfigGenerator.Core.Tests.Workspace;
+
+public class BatchServiceTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-batch");
+
+ [Fact]
+ public void DiscoverWorkspaces_ShouldReturnOneDirectoryPerSolution()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln")
+ .AddFile(Path.Combine(Root, "repo-b", "B.slnx"), "");
+
+ var workspaces = new BatchService(fileSystem).DiscoverWorkspaces(
+ new BatchOptions { RootDirectory = Root }
+ );
+
+ workspaces.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public void DiscoverWorkspaces_ShouldFallBackToProjectDirectories()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "repo-a", "A.csproj"), Sample.AppProject);
+
+ var workspaces = new BatchService(fileSystem).DiscoverWorkspaces(
+ new BatchOptions { RootDirectory = Root }
+ );
+
+ workspaces.ShouldHaveSingleItem();
+ }
+
+ [Fact]
+ public void DiscoverWorkspaces_ShouldRespectMaxDepth()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "a", "b", "c", "d", "Deep.sln"), "sln");
+
+ var workspaces = new BatchService(fileSystem).DiscoverWorkspaces(
+ new BatchOptions { RootDirectory = Root, MaxDepth = 2 }
+ );
+
+ workspaces.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void DiscoverWorkspaces_ShouldSkipBuildOutput()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "repo", "A.sln"), "sln")
+ .AddFile(Path.Combine(Root, "repo", "bin", "Copy.sln"), "sln");
+
+ var workspaces = new BatchService(fileSystem).DiscoverWorkspaces(
+ new BatchOptions { RootDirectory = Root }
+ );
+
+ workspaces.ShouldHaveSingleItem();
+ }
+
+ [Fact]
+ public async Task RunAsync_ShouldProcessEveryWorkspace()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln")
+ .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln");
+
+ var result = await new BatchService(fileSystem).RunAsync(
+ new BatchOptions { RootDirectory = Root },
+ (_, _) => Task.FromResult(EmptyPlan())
+ );
+
+ result.SucceededCount.ShouldBe(2);
+ result.FailedCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task RunAsync_ShouldStopAtTheFirstFailureByDefault()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln")
+ .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln");
+
+ var result = await new BatchService(fileSystem).RunAsync(
+ new BatchOptions { RootDirectory = Root },
+ (_, _) => throw new InvalidOperationException("boom")
+ );
+
+ result.Items.Count.ShouldBe(1);
+ result.Items[0].Error.ShouldBe("boom");
+ }
+
+ [Fact]
+ public async Task RunAsync_ShouldKeepGoingWhenContinueOnErrorIsSet()
+ {
+ var fileSystem = new InMemoryFileSystem()
+ .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln")
+ .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln");
+
+ var result = await new BatchService(fileSystem).RunAsync(
+ new BatchOptions { RootDirectory = Root, ContinueOnError = true },
+ (_, _) => throw new InvalidOperationException("boom")
+ );
+
+ result.FailedCount.ShouldBe(2);
+ }
+
+ private static MigrationPlan EmptyPlan() =>
+ new()
+ {
+ Changes = [],
+ ResolvedVersions = new Dictionary(),
+ Conflicts = [],
+ Findings = [],
+ ProjectCount = 0,
+ };
+}
diff --git a/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs
new file mode 100644
index 0000000..9b21c6a
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs
@@ -0,0 +1,80 @@
+using CentralConfigGenerator.Core.NuGet;
+using CentralConfigGenerator.Core.Workspace;
+
+namespace CentralConfigGenerator.Core.Tests.Workspace;
+
+public class DependencyTreeServiceTests
+{
+ private static readonly PackageGraph Graph = new()
+ {
+ IsAvailable = true,
+ Packages =
+ [
+ new ResolvedPackage
+ {
+ PackageId = "Serilog",
+ ResolvedVersion = "3.1.1",
+ RequestedVersion = "3.1.1",
+ ProjectPath = "/repo/src/App/App.csproj",
+ TargetFramework = "net8.0",
+ },
+ new ResolvedPackage
+ {
+ PackageId = "System.Text.Json",
+ ResolvedVersion = "8.0.0",
+ ProjectPath = "/repo/src/App/App.csproj",
+ TargetFramework = "net8.0",
+ IsTransitive = true,
+ },
+ ],
+ };
+
+ [Fact]
+ public void Render_ShouldDrawProjectFrameworkAndPackages()
+ {
+ var tree = DependencyTreeService.Render(Graph, includeTransitive: true);
+
+ tree.ShouldContain("App");
+ tree.ShouldContain("net8.0");
+ tree.ShouldContain("Serilog 3.1.1");
+ tree.ShouldContain("System.Text.Json 8.0.0 (transitive)");
+ }
+
+ [Fact]
+ public void Render_ShouldOmitTransitivePackagesWhenNotRequested()
+ {
+ var tree = DependencyTreeService.Render(Graph, includeTransitive: false);
+
+ tree.ShouldNotContain("System.Text.Json");
+ }
+
+ [Fact]
+ public void Render_ShouldNoteWhenTheResolvedVersionDiffersFromTheRequest()
+ {
+ var graph = Graph with
+ {
+ Packages =
+ [
+ Graph.Packages[0] with { RequestedVersion = "3.0.0", ResolvedVersion = "3.1.1" },
+ ],
+ };
+
+ DependencyTreeService.Render(graph, includeTransitive: false)
+ .ShouldContain("(requested 3.0.0)");
+ }
+
+ [Fact]
+ public async Task RenderAsync_ShouldExplainWhenTheGraphIsUnavailable()
+ {
+ var service = new DependencyTreeService(
+ new TestSupport.FakePackageGraphService(
+ PackageGraph.Empty with { Error = "restore failed" }
+ )
+ );
+
+ var output = await service.RenderAsync("/repo", "/repo", includeTransitive: false);
+
+ output.ShouldContain("Dependency graph unavailable");
+ output.ShouldContain("restore failed");
+ }
+}
diff --git a/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs
new file mode 100644
index 0000000..b6b598c
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs
@@ -0,0 +1,105 @@
+using CentralConfigGenerator.Core.NuGet;
+
+namespace CentralConfigGenerator.Core.Tests.Workspace;
+
+public class PackageGraphServiceTests
+{
+ private const string SampleJson = """
+ {
+ "version": 1,
+ "projects": [
+ {
+ "path": "/repo/src/App/App.csproj",
+ "frameworks": [
+ {
+ "framework": "net8.0",
+ "topLevelPackages": [
+ { "id": "Serilog", "requestedVersion": "3.1.1", "resolvedVersion": "3.1.1" }
+ ],
+ "transitivePackages": [
+ { "id": "System.Text.Json", "resolvedVersion": "8.0.0" }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ """;
+
+ [Fact]
+ public void Parse_ShouldReadDirectAndTransitivePackages()
+ {
+ var graph = PackageGraphService.Parse(SampleJson);
+
+ graph.IsAvailable.ShouldBeTrue();
+ graph.Direct.Count().ShouldBe(1);
+ graph.Transitive.Count().ShouldBe(1);
+ graph.Packages[0].TargetFramework.ShouldBe("net8.0");
+ }
+
+ [Fact]
+ public void Parse_ShouldToleratePrefixNoiseBeforeTheJson()
+ {
+ var graph = PackageGraphService.Parse("Restoring...\n" + SampleJson);
+
+ graph.IsAvailable.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Parse_ShouldReportAnErrorForNonJson()
+ {
+ var graph = PackageGraphService.Parse("no json here");
+
+ graph.IsAvailable.ShouldBeFalse();
+ graph.Error.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Parse_ShouldReportAnErrorForMalformedJson()
+ {
+ var graph = PackageGraphService.Parse("{ \"projects\": [ ");
+
+ graph.IsAvailable.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void DivergentPackages_ShouldFindVersionSplits()
+ {
+ var graph = new PackageGraph
+ {
+ IsAvailable = true,
+ Packages =
+ [
+ Package("A", "1.0.0", "One.csproj"),
+ Package("A", "2.0.0", "Two.csproj"),
+ Package("B", "1.0.0", "One.csproj"),
+ ],
+ };
+
+ graph.DivergentPackages.Select(g => g.Key).ShouldBe(["A"]);
+ }
+
+ [Fact]
+ public void Signature_ShouldKeyByProjectFrameworkAndPackage()
+ {
+ var graph = new PackageGraph
+ {
+ IsAvailable = true,
+ Packages = [Package("A", "1.0.0", "/repo/One.csproj")],
+ };
+
+ var signature = graph.Signature();
+
+ signature.ShouldContainKey("One.csproj|net8.0|A");
+ signature["One.csproj|net8.0|A"].ShouldBe("1.0.0");
+ }
+
+ private static ResolvedPackage Package(string id, string version, string project) =>
+ new()
+ {
+ PackageId = id,
+ ResolvedVersion = version,
+ ProjectPath = project,
+ TargetFramework = "net8.0",
+ };
+}
diff --git a/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs
new file mode 100644
index 0000000..8cf3acb
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs
@@ -0,0 +1,204 @@
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.Services;
+using CentralConfigGenerator.Core.Tests.TestSupport;
+using CentralConfigGenerator.Core.Workspace;
+
+namespace CentralConfigGenerator.Core.Tests.Workspace;
+
+public class PackageUpdateServiceTests
+{
+ private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-update");
+ private static readonly string PropsPath = Path.Combine(Root, "Directory.Packages.props");
+
+ private const string Props = """
+
+
+ true
+
+
+
+
+
+
+
+
+ """;
+
+ private static FakeNuGetFeed Feed() =>
+ new FakeNuGetFeed()
+ .Add("Alpha", ["1.0.0", "2.0.0"])
+ .Add("Beta", ["1.0.0", "2.0.0"])
+ .Add("Gamma", ["1.0.0", "2.0.0"])
+ .Add("Delta", ["1.0.0", "2.0.0"]);
+
+ [Fact]
+ public async Task UpdateAsync_ShouldReportCandidatesWithoutWritingOnDryRun()
+ {
+ var (service, fileSystem, _) = Create(_ => true);
+
+ var result = await service.UpdateAsync(Options() with { DryRun = true });
+
+ result.Candidates.Count.ShouldBe(4);
+ result.Applied.ShouldBeEmpty();
+ fileSystem.ReadText(PropsPath).ShouldBe(Props);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldApplyEverythingWhenTestsPass()
+ {
+ var (service, fileSystem, cli) = Create(_ => true);
+
+ var result = await service.UpdateAsync(Options());
+
+ result.Applied.Count.ShouldBe(4);
+ result.TestsPassed.ShouldBeTrue();
+ result.RolledBack.ShouldBeFalse();
+ fileSystem.ReadText(PropsPath).ShouldContain("Include=\"Alpha\" Version=\"2.0.0\"");
+ cli.TestCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldRollEverythingBackWhenTestsFail()
+ {
+ var (service, fileSystem, _) = Create(_ => false);
+
+ var result = await service.UpdateAsync(Options());
+
+ result.Applied.ShouldBeEmpty();
+ result.RolledBack.ShouldBeTrue();
+ result.HeldBack.Count.ShouldBe(4);
+ fileSystem.ReadText(PropsPath).ShouldBe(Props);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldKeepTheGreenSubsetWhenBisecting()
+ {
+ // Gamma is the poison pill: any set containing it fails.
+ var (service, fileSystem, _) = Create(versions => versions["Gamma"] == "1.0.0");
+
+ var result = await service.UpdateAsync(Options() with { Bisect = true });
+
+ result.TestsPassed.ShouldBeTrue();
+ result.RolledBack.ShouldBeFalse();
+ result.Applied.ShouldNotBeEmpty();
+ result.Applied.ShouldNotContain(c => c.PackageId == "Gamma");
+ result.HeldBack.ShouldContain(c => c.PackageId == "Gamma");
+ fileSystem.ReadText(PropsPath).ShouldContain("Include=\"Gamma\" Version=\"1.0.0\"");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldNameWhatItHeldBack()
+ {
+ var (service, _, _) = Create(versions => versions["Gamma"] == "1.0.0");
+
+ var result = await service.UpdateAsync(Options() with { Bisect = true });
+
+ result.Message.ShouldNotBeNull().ShouldContain("Gamma");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldRollBackWhenNoSubsetPasses()
+ {
+ var (service, fileSystem, _) = Create(_ => false);
+
+ var result = await service.UpdateAsync(Options() with { Bisect = true });
+
+ result.RolledBack.ShouldBeTrue();
+ fileSystem.ReadText(PropsPath).ShouldBe(Props);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldRespectTheBisectBudget()
+ {
+ var (service, _, cli) = Create(versions => versions["Gamma"] == "1.0.0");
+
+ await service.UpdateAsync(Options() with { Bisect = true, BisectBudget = 3 });
+
+ cli.TestCount.ShouldBeLessThanOrEqualTo(4);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldRestrictToTheOnlyList()
+ {
+ var (service, _, _) = Create(_ => true);
+
+ var result = await service.UpdateAsync(Options() with { Only = ["Alpha"] });
+
+ result.Candidates.ShouldHaveSingleItem();
+ result.Candidates[0].PackageId.ShouldBe("Alpha");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldSkipTestsWhenAsked()
+ {
+ var (service, fileSystem, cli) = Create(_ => false);
+
+ var result = await service.UpdateAsync(Options() with { SkipTests = true });
+
+ cli.TestCount.ShouldBe(0);
+ result.Applied.Count.ShouldBe(4);
+ fileSystem.ReadText(PropsPath).ShouldContain("2.0.0");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldExplainWhenThereIsNoPropsFile()
+ {
+ var fileSystem = new InMemoryFileSystem().AddDirectory(Root);
+ var service = Build(fileSystem, new FakeDotNetCli(_ => true, () => string.Empty));
+
+ var result = await service.UpdateAsync(Options());
+
+ result.Candidates.ShouldBeEmpty();
+ result.Message.ShouldNotBeNull().ShouldContain("No Directory.Packages.props");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ShouldSayWhenEverythingIsCurrent()
+ {
+ var fileSystem = new InMemoryFileSystem().AddFile(PropsPath, Props);
+ var feed = new FakeNuGetFeed()
+ .Add("Alpha", ["1.0.0"])
+ .Add("Beta", ["1.0.0"])
+ .Add("Gamma", ["1.0.0"])
+ .Add("Delta", ["1.0.0"]);
+
+ var service = new PackageUpdateService(
+ new ProjectDiscoveryService(fileSystem),
+ fileSystem,
+ feed,
+ new FakeDotNetCli(_ => true, () => fileSystem.ReadText(PropsPath)),
+ new BackupService(fileSystem)
+ );
+
+ var result = await service.UpdateAsync(Options());
+
+ result.Message.ShouldNotBeNull().ShouldContain("up to date");
+ }
+
+ private static PackageUpdateOptions Options() =>
+ new()
+ {
+ RootDirectory = Root,
+ PackagesPropsPath = PropsPath,
+ BackupDirectory = Root,
+ };
+
+ private static (PackageUpdateService Service, InMemoryFileSystem FileSystem, FakeDotNetCli Cli) Create(
+ Func, bool> testsPass
+ )
+ {
+ var fileSystem = new InMemoryFileSystem().AddFile(PropsPath, Props);
+ var cli = new FakeDotNetCli(testsPass, () => fileSystem.ReadText(PropsPath));
+
+ return (Build(fileSystem, cli), fileSystem, cli);
+ }
+
+ private static PackageUpdateService Build(InMemoryFileSystem fileSystem, FakeDotNetCli cli) =>
+ new(
+ new ProjectDiscoveryService(fileSystem),
+ fileSystem,
+ Feed(),
+ cli,
+ new BackupService(fileSystem)
+ );
+}
diff --git a/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs
new file mode 100644
index 0000000..b51b703
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs
@@ -0,0 +1,76 @@
+using CentralConfigGenerator.Core.NuGet;
+using CentralConfigGenerator.Core.Services;
+using CentralConfigGenerator.Core.Workspace;
+
+namespace CentralConfigGenerator.Core.Tests.Workspace;
+
+public class VerificationServiceTests
+{
+ private readonly VerificationService _service = new(
+ new TestSupport.FakePackageGraphService(PackageGraph.Empty),
+ new DotNetCliService()
+ );
+
+ [Fact]
+ public void Compare_ShouldReportNoDriftForIdenticalGraphs()
+ {
+ var graph = Graph(("A", "1.0.0"));
+
+ var result = _service.Compare(graph, graph);
+
+ result.IsClean.ShouldBeTrue();
+ result.GraphAvailable.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Compare_ShouldReportChangedVersions()
+ {
+ var result = _service.Compare(Graph(("A", "1.0.0")), Graph(("A", "2.0.0")));
+
+ result.Drift.ShouldHaveSingleItem();
+ result.Drift[0].Before.ShouldBe("1.0.0");
+ result.Drift[0].After.ShouldBe("2.0.0");
+ }
+
+ [Fact]
+ public void Compare_ShouldReportAdditions()
+ {
+ var result = _service.Compare(Graph(("A", "1.0.0")), Graph(("A", "1.0.0"), ("B", "1.0.0")));
+
+ result.Drift.ShouldHaveSingleItem();
+ result.Drift[0].IsAddition.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Compare_ShouldReportRemovals()
+ {
+ var result = _service.Compare(Graph(("A", "1.0.0"), ("B", "1.0.0")), Graph(("A", "1.0.0")));
+
+ result.Drift.ShouldHaveSingleItem();
+ result.Drift[0].IsRemoval.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Compare_ShouldSurfaceUnavailableGraphs()
+ {
+ var result = _service.Compare(PackageGraph.Empty, Graph(("A", "1.0.0")));
+
+ result.GraphAvailable.ShouldBeFalse();
+ result.Error.ShouldNotBeNull();
+ }
+
+ private static PackageGraph Graph(params (string Id, string Version)[] packages) =>
+ new()
+ {
+ IsAvailable = true,
+ Packages = packages
+ .Select(p => new ResolvedPackage
+ {
+ PackageId = p.Id,
+ ResolvedVersion = p.Version,
+ ProjectPath = "/repo/App.csproj",
+ TargetFramework = "net8.0",
+ })
+ .ToList(),
+ };
+}
diff --git a/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs b/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs
new file mode 100644
index 0000000..e38dbd5
--- /dev/null
+++ b/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs
@@ -0,0 +1,117 @@
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Tests.Xml;
+
+public class PackageReferenceReaderTests
+{
+ [Fact]
+ public void Read_ShouldPickUpAttributeVersions()
+ {
+ var project = Project("""
+
+
+
+ """);
+
+ var references = PackageReferenceReader.Read(project);
+
+ references.Count.ShouldBe(1);
+ references[0].PackageId.ShouldBe("Serilog");
+ references[0].Version.ShouldBe("3.1.1");
+ references[0].VersionIsElement.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Read_ShouldPickUpChildElementVersions()
+ {
+ var project = Project("""
+
+
+ 2.12.0
+
+
+ """);
+
+ var references = PackageReferenceReader.Read(project);
+
+ references[0].Version.ShouldBe("2.12.0");
+ references[0].VersionIsElement.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Read_ShouldPickUpGlobalPackageReferences()
+ {
+ var project = Project("""
+
+
+
+ """);
+
+ var references = PackageReferenceReader.Read(project);
+
+ references[0].IsGlobal.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Read_ShouldCaptureInheritedConditions()
+ {
+ var project = Project("""
+
+
+
+ """);
+
+ PackageReferenceReader.Read(project)[0].Condition.ShouldNotBeNull().ShouldContain("net8.0");
+ }
+
+ [Fact]
+ public void Read_ShouldReportPrivateAssets()
+ {
+ var project = Project("""
+
+
+
+ """);
+
+ PackageReferenceReader.Read(project)[0].IsPrivateAssets.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Read_ShouldReturnEmptyForUnparseableXml()
+ {
+ var project = new ProjectFile { Path = "Broken.csproj", Content = "" };
+
+ PackageReferenceReader.Read(project).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Read_ShouldTreatVersionlessReferencesAsNullVersion()
+ {
+ var project = Project("""
+
+
+
+ """);
+
+ PackageReferenceReader.Read(project)[0].Version.ShouldBeNull();
+ }
+
+ [Fact]
+ public void ReadPackageVersions_ShouldReadCentralEntries()
+ {
+ var document = MsBuildXml.Parse(TestSupport.Sample.PackagesProps);
+
+ var versions = PackageReferenceReader.ReadPackageVersions(document);
+
+ versions.Count.ShouldBe(2);
+ versions["newtonsoft.json"].ShouldBe("13.0.3");
+ }
+
+ private static ProjectFile Project(string body) =>
+ new()
+ {
+ Path = Path.Combine(Path.GetTempPath(), "Test.csproj"),
+ Content = $"\n{body}\n",
+ };
+}
diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs b/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs
new file mode 100644
index 0000000..0277578
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs
@@ -0,0 +1,62 @@
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.NuGet;
+
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// Everything the rules read from. Built once per run, then shared by all analyzers.
+public sealed record AnalysisContext
+{
+ public required AnalysisOptions Options { get; init; }
+
+ public required IReadOnlyList Projects { get; init; }
+
+ public required IReadOnlyList References { get; init; }
+
+ /// Versions declared in Directory.Packages.props, empty when the file is absent.
+ public required IReadOnlyDictionary CentralVersions { get; init; }
+
+ public bool CentralPackageManagementEnabled { get; init; }
+
+ public bool TransitivePinningEnabled { get; init; }
+
+ public string? PackagesPropsPath { get; init; }
+
+ public IReadOnlyDictionary Metadata { get; init; } =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ public PackageGraph Graph { get; init; } = PackageGraph.Empty;
+
+ /// Target frameworks declared per project, used by the framework alignment rule.
+ public IReadOnlyDictionary> TargetFrameworks { get; init; } =
+ new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ public IEnumerable RelevantReferences =>
+ References.Where(r => !IsIgnored(r.PackageId));
+
+ public bool IsIgnored(string packageId) =>
+ Options.IgnorePackages.Any(pattern =>
+ pattern.EndsWith('*')
+ ? packageId.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase)
+ : string.Equals(pattern, packageId, StringComparison.OrdinalIgnoreCase)
+ );
+
+ public PackageMetadataSnapshot? MetadataFor(string packageId) =>
+ Metadata.TryGetValue(packageId, out var snapshot) ? snapshot : null;
+
+ /// The version a package effectively resolves to: central entry first, then inline.
+ public string? EffectiveVersion(string packageId)
+ {
+ if (CentralVersions.TryGetValue(packageId, out var central))
+ {
+ return central;
+ }
+
+ return References
+ .Where(r =>
+ string.Equals(r.PackageId, packageId, StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(r.Version)
+ )
+ .Select(r => r.Version)
+ .FirstOrDefault();
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs b/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs
new file mode 100644
index 0000000..e02c6d0
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs
@@ -0,0 +1,353 @@
+using System.Diagnostics;
+using System.Xml.Linq;
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.IO;
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.NuGet;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Analysis;
+
+public interface IAnalysisEngine
+{
+ Task AnalyzeAsync(
+ AnalysisOptions options,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ );
+
+ Task BuildContextAsync(
+ AnalysisOptions options,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ );
+}
+
+///
+/// Builds the analysis context once, runs every enabled rule against it, then applies severity
+/// overrides and the baseline.
+///
+public sealed class AnalysisEngine(
+ IProjectDiscoveryService discovery,
+ IFileSystem fileSystem,
+ INuGetFeedService feedService,
+ IPackageGraphService graphService,
+ IBaselineService baselineService
+) : IAnalysisEngine
+{
+ public async Task AnalyzeAsync(
+ AnalysisOptions options,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var context = await BuildContextAsync(options, progress, cancellationToken);
+
+ if (context.Projects.Count == 0)
+ {
+ return new AnalysisReport
+ {
+ Findings = [],
+ ProjectCount = 0,
+ PackageCount = 0,
+ Duration = stopwatch.Elapsed,
+ RootDirectory = options.RootDirectory,
+ };
+ }
+
+ var findings = new List();
+ var incompletions = new List();
+
+ foreach (var rule in RuleCatalog.CreateAll())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!IsRuleActive(rule, options, context))
+ {
+ continue;
+ }
+
+ progress?.Report(rule.RuleId);
+
+ try
+ {
+ foreach (var finding in rule.Analyze(context))
+ {
+ findings.Add(ApplySeverityOverride(finding, options));
+ }
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ incompletions.Add($"{rule.RuleId} failed: {ex.Message}");
+ }
+ }
+
+ if (options.RequiresNetwork)
+ {
+ var unreachable = context
+ .Metadata.Values.Where(m => m.Error is not null)
+ .Select(m => $"{m.PackageId}: {m.Error}")
+ .ToList();
+
+ if (unreachable.Count > 0)
+ {
+ incompletions.Add(
+ $"{unreachable.Count} package(s) could not be checked against the feed."
+ );
+ }
+ }
+
+ if (options.Transitive && !context.Graph.IsAvailable)
+ {
+ incompletions.Add(
+ $"Transitive graph unavailable: {context.Graph.Error ?? "unknown reason"}."
+ );
+ }
+
+ var baseline = options.BaselinePath is null
+ ? null
+ : await baselineService.LoadAsync(options.BaselinePath, cancellationToken);
+
+ var filtered = baselineService.Apply(findings, baseline);
+
+ var ordered = filtered
+ .OrderByDescending(f => f.Severity)
+ .ThenBy(f => f.RuleId, StringComparer.Ordinal)
+ .ThenBy(f => f.PackageId ?? string.Empty, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return new AnalysisReport
+ {
+ Findings = ordered,
+ ProjectCount = context.Projects.Count,
+ PackageCount = context
+ .References.Select(r => r.PackageId)
+ .Concat(context.CentralVersions.Keys)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count(),
+ Duration = stopwatch.Elapsed,
+ IsIncomplete = incompletions.Count > 0,
+ Incompletions = incompletions,
+ RootDirectory = options.RootDirectory,
+ SuppressedCount = findings.Count - filtered.Count,
+ };
+ }
+
+ public async Task BuildContextAsync(
+ AnalysisOptions options,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ progress?.Report("Discovering projects");
+
+ var projects = await discovery.DiscoverAsync(
+ new DiscoveryOptions
+ {
+ RootDirectory = options.RootDirectory,
+ SolutionPath = options.SolutionPath,
+ ProjectPath = options.ProjectPath,
+ ExcludePattern = options.ExcludePattern,
+ },
+ cancellationToken
+ );
+
+ var references = projects.SelectMany(PackageReferenceReader.Read).ToList();
+ var frameworks = ReadTargetFrameworks(projects);
+
+ var (centralVersions, cpmEnabled, pinning, propsPath) = await ReadCentralFileAsync(
+ options,
+ cancellationToken
+ );
+
+ var graph = PackageGraph.Empty;
+ if (options.Transitive)
+ {
+ progress?.Report("Resolving transitive graph");
+ graph = await graphService.LoadAsync(
+ options.SolutionPath ?? options.ProjectPath ?? options.RootDirectory,
+ options.RootDirectory,
+ includeTransitive: true,
+ cancellationToken
+ );
+ }
+
+ IReadOnlyDictionary metadata =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (options.RequiresNetwork)
+ {
+ var ids = references
+ .Select(r => r.PackageId)
+ .Concat(centralVersions.Keys)
+ .Concat(graph.Transitive.Select(p => p.PackageId))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ progress?.Report($"Querying {ids.Count} packages");
+
+ metadata = await feedService.GetMetadataAsync(
+ ids,
+ options.MaxParallelism,
+ progress,
+ cancellationToken
+ );
+ }
+
+ return new AnalysisContext
+ {
+ Options = options,
+ Projects = projects,
+ References = references,
+ CentralVersions = centralVersions,
+ CentralPackageManagementEnabled = cpmEnabled,
+ TransitivePinningEnabled = pinning,
+ PackagesPropsPath = propsPath,
+ Metadata = metadata,
+ Graph = graph,
+ TargetFrameworks = frameworks,
+ };
+ }
+
+ private static bool IsRuleActive(
+ IAnalysisRule rule,
+ AnalysisOptions options,
+ AnalysisContext context
+ )
+ {
+ if (
+ options.RuleOverrides.TryGetValue(rule.RuleId, out var severity)
+ && severity is null
+ )
+ {
+ return false;
+ }
+
+ if (!rule.IsEnabled(options))
+ {
+ return false;
+ }
+
+ if (rule.RequiresNetwork && !options.RequiresNetwork)
+ {
+ return false;
+ }
+
+ return !rule.RequiresCentralPackageManagement || context.CentralPackageManagementEnabled;
+ }
+
+ private static Finding ApplySeverityOverride(Finding finding, AnalysisOptions options) =>
+ options.RuleOverrides.TryGetValue(finding.RuleId, out var severity) && severity is not null
+ ? finding with { Severity = severity.Value }
+ : finding;
+
+ private async Task<(
+ IReadOnlyDictionary Versions,
+ bool CpmEnabled,
+ bool Pinning,
+ string? Path
+ )> ReadCentralFileAsync(AnalysisOptions options, CancellationToken cancellationToken)
+ {
+ var empty = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var path = FindPackagesProps(options);
+
+ if (path is null)
+ {
+ return (empty, false, false, null);
+ }
+
+ var file = await discovery.LoadAsync(path, cancellationToken);
+ if (file is null || !MsBuildXml.TryParse(file.Content, out var document, out _))
+ {
+ return (empty, false, false, path);
+ }
+
+ var versions = PackageReferenceReader.ReadPackageVersions(document);
+
+ return (
+ versions,
+ ReadBool(document, "ManagePackageVersionsCentrally"),
+ ReadBool(document, "CentralPackageTransitivePinningEnabled"),
+ path
+ );
+ }
+
+ private string? FindPackagesProps(AnalysisOptions options)
+ {
+ var start =
+ options.SolutionPath is not null
+ ? Path.GetDirectoryName(Path.GetFullPath(options.SolutionPath))
+ : Path.GetFullPath(options.RootDirectory);
+
+ var directory = start is null ? null : new DirectoryInfo(start);
+
+ while (directory is not null)
+ {
+ var candidate = Path.Combine(directory.FullName, "Directory.Packages.props");
+ if (fileSystem.FileExists(candidate))
+ {
+ return candidate;
+ }
+
+ directory = directory.Parent;
+ }
+
+ return null;
+ }
+
+ private static bool ReadBool(XDocument document, string propertyName) =>
+ MsBuildXml
+ .Descendants(document, propertyName)
+ .Select(e => e.Value.Trim())
+ .Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase));
+
+ private static IReadOnlyDictionary> ReadTargetFrameworks(
+ IReadOnlyList projects
+ )
+ {
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var project in projects)
+ {
+ if (!MsBuildXml.TryParse(project.Content, out var document, out _))
+ {
+ continue;
+ }
+
+ var frameworks = new List();
+
+ foreach (var element in MsBuildXml.Descendants(document, "TargetFramework"))
+ {
+ var value = element.Value.Trim();
+ if (value.Length > 0 && !value.Contains('$'))
+ {
+ frameworks.Add(value);
+ }
+ }
+
+ foreach (var element in MsBuildXml.Descendants(document, "TargetFrameworks"))
+ {
+ foreach (
+ var value in element.Value.Split(
+ ';',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
+ )
+ )
+ {
+ if (!value.Contains('$'))
+ {
+ frameworks.Add(value);
+ }
+ }
+ }
+
+ if (frameworks.Count > 0)
+ {
+ result[project.Path] = frameworks.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs b/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs
new file mode 100644
index 0000000..a60665e
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs
@@ -0,0 +1,45 @@
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// Controls which analyzers run and how strict the run is.
+public sealed record AnalysisOptions
+{
+ public string RootDirectory { get; init; } = Directory.GetCurrentDirectory();
+
+ public string? SolutionPath { get; init; }
+
+ public string? ProjectPath { get; init; }
+
+ public string? ExcludePattern { get; init; }
+
+ /// Query the feed for known security advisories.
+ public bool Audit { get; init; }
+
+ /// Report packages that are behind the newest published version.
+ public bool Outdated { get; init; }
+
+ /// Report packages the author marked deprecated.
+ public bool Deprecated { get; init; }
+
+ /// Flag copyleft and unknown licences.
+ public bool Licenses { get; init; }
+
+ /// Resolve the transitive graph via dotnet list package.
+ public bool Transitive { get; init; }
+
+ public int MaxParallelism { get; init; } = 8;
+
+ public Severity FailOn { get; init; } = Severity.Info;
+
+ /// Per-rule severity overrides. A null value disables the rule entirely.
+ public IReadOnlyDictionary RuleOverrides { get; init; } =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ public string? BaselinePath { get; init; }
+
+ public IReadOnlyCollection IgnorePackages { get; init; } = [];
+
+ public bool IncludePrerelease { get; init; }
+
+ /// True when any rule that needs the network is enabled.
+ public bool RequiresNetwork => Audit || Outdated || Deprecated || Licenses;
+}
diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs b/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs
new file mode 100644
index 0000000..fca659d
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs
@@ -0,0 +1,71 @@
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// Aggregated result of a dependency health analysis run.
+public sealed record AnalysisReport
+{
+ public required IReadOnlyList Findings { get; init; }
+
+ public required int ProjectCount { get; init; }
+
+ public required int PackageCount { get; init; }
+
+ public TimeSpan Duration { get; init; }
+
+ /// True when part of the scan could not complete (for example the feed was unreachable).
+ public bool IsIncomplete { get; init; }
+
+ public IReadOnlyList Incompletions { get; init; } = [];
+
+ public string RootDirectory { get; init; } = string.Empty;
+
+ public int SuppressedCount { get; init; }
+
+ public Severity HighestSeverity =>
+ Findings.Count == 0 ? Severity.Never : Findings.Max(f => f.Severity);
+
+ public int CountOf(Severity severity) => Findings.Count(f => f.Severity == severity);
+
+ ///
+ /// A 0-100 dependency health score. Every finding subtracts a weight scaled by severity,
+ /// so a clean workspace scores 100.
+ ///
+ public int HealthScore
+ {
+ get
+ {
+ if (PackageCount == 0)
+ {
+ return 100;
+ }
+
+ double penalty = 0;
+ foreach (var finding in Findings)
+ {
+ penalty += finding.Severity switch
+ {
+ Severity.Critical => 25,
+ Severity.High => 12,
+ Severity.Moderate => 5,
+ Severity.Low => 2,
+ Severity.Info => 0.5,
+ _ => 0,
+ };
+ }
+
+ // Normalise against workspace size so large solutions are not punished twice.
+ var scale = Math.Max(1.0, Math.Sqrt(PackageCount) / 3.0);
+ var score = 100 - (penalty / scale);
+ return (int)Math.Round(Math.Clamp(score, 0, 100));
+ }
+ }
+
+ public string Grade =>
+ HealthScore switch
+ {
+ >= 90 => "A",
+ >= 80 => "B",
+ >= 70 => "C",
+ >= 60 => "D",
+ _ => "F",
+ };
+}
diff --git a/CentralConfigGenerator.Core/Analysis/AutoFixService.cs b/CentralConfigGenerator.Core/Analysis/AutoFixService.cs
new file mode 100644
index 0000000..7b15ea2
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/AutoFixService.cs
@@ -0,0 +1,267 @@
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.Generators;
+using CentralConfigGenerator.Core.IO;
+using CentralConfigGenerator.Core.Migration;
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Services.Abstractions;
+
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// The outcome of trying to auto-fix a set of findings.
+public sealed record AutoFixResult
+{
+ public required IReadOnlyList Changes { get; init; }
+
+ public required IReadOnlyList Fixed { get; init; }
+
+ public required IReadOnlyList Skipped { get; init; }
+
+ public BackupSet? Backup { get; init; }
+
+ public bool HasChanges => Changes.Count > 0;
+}
+
+public interface IAutoFixService
+{
+ Task PlanAsync(
+ AnalysisContext context,
+ IReadOnlyList findings,
+ CancellationToken cancellationToken = default
+ );
+
+ Task ApplyAsync(
+ AutoFixResult result,
+ string rootDirectory,
+ string? backupDirectory,
+ bool createBackup,
+ CancellationToken cancellationToken = default
+ );
+}
+
+///
+/// Turns auto-fixable findings into concrete file edits. Everything is planned in memory first so
+/// --fix-dry-run and --fix share the same code path.
+///
+public sealed class AutoFixService(
+ IProjectDiscoveryService discovery,
+ IFileSystem fileSystem,
+ IBackupService backupService
+) : IAutoFixService
+{
+ public async Task PlanAsync(
+ AnalysisContext context,
+ IReadOnlyList findings,
+ CancellationToken cancellationToken = default
+ )
+ {
+ // Working copies keyed by path, so several fixes can stack on the same file.
+ var buffers = new Dictionary(
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ foreach (var project in context.Projects)
+ {
+ buffers[project.Path] = (project.Content, project.Content, project.Format);
+ }
+
+ var propsPath = context.PackagesPropsPath;
+ if (propsPath is not null && !buffers.ContainsKey(propsPath))
+ {
+ var props = await discovery.LoadAsync(propsPath, cancellationToken);
+ if (props is not null)
+ {
+ buffers[propsPath] = (props.Content, props.Content, props.Format);
+ }
+ }
+
+ var fixedFindings = new List();
+ var skipped = new List();
+
+ foreach (var finding in findings.OrderByDescending(f => f.Severity))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!finding.IsAutoFixable)
+ {
+ skipped.Add(finding);
+ continue;
+ }
+
+ if (TryApply(finding, buffers, propsPath))
+ {
+ fixedFindings.Add(finding);
+ }
+ else
+ {
+ skipped.Add(finding);
+ }
+ }
+
+ var changes = buffers
+ .Where(b => !string.Equals(b.Value.Original, b.Value.Current, StringComparison.Ordinal))
+ .Select(b => new FileChange
+ {
+ Path = b.Key,
+ Kind = FileChangeKind.Modified,
+ OriginalContent = b.Value.Original,
+ NewContent = b.Value.Current,
+ Format = b.Value.Format,
+ })
+ .OrderBy(c => c.Path, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return new AutoFixResult
+ {
+ Changes = changes,
+ Fixed = fixedFindings,
+ Skipped = skipped,
+ };
+ }
+
+ public async Task ApplyAsync(
+ AutoFixResult result,
+ string rootDirectory,
+ string? backupDirectory,
+ bool createBackup,
+ CancellationToken cancellationToken = default
+ )
+ {
+ if (!result.HasChanges)
+ {
+ return null;
+ }
+
+ BackupSet? backup = null;
+
+ if (createBackup)
+ {
+ backup = await backupService.CreateAsync(
+ rootDirectory,
+ backupDirectory ?? rootDirectory,
+ "analyze --fix",
+ result.Changes.Select(c => c.Path),
+ cancellationToken
+ );
+ }
+
+ foreach (var change in result.Changes)
+ {
+ var bytes = EncodingDetector.Encode(change.NewContent, change.Format);
+ await fileSystem.WriteAllBytesAsync(change.Path, bytes, cancellationToken);
+ }
+
+ return backup;
+ }
+
+ private static bool TryApply(
+ Finding finding,
+ Dictionary buffers,
+ string? propsPath
+ )
+ {
+ switch (finding.Fix)
+ {
+ case FixKind.RemoveInlineVersion:
+ return finding.ProjectPath is not null
+ && finding.PackageId is not null
+ && Edit(
+ buffers,
+ finding.ProjectPath,
+ content =>
+ ProjectRewriter.StripVersion(
+ Wrap(finding.ProjectPath, content),
+ finding.PackageId
+ )
+ );
+
+ case FixKind.SetCentralVersion:
+ return propsPath is not null
+ && finding.PackageId is not null
+ && finding.FixValue is not null
+ && Edit(
+ buffers,
+ propsPath,
+ content =>
+ PackagesPropsEditor.SetVersion(
+ content,
+ finding.PackageId,
+ finding.FixValue
+ )
+ );
+
+ case FixKind.RemoveOrphanedPackageVersion:
+ return propsPath is not null
+ && finding.PackageId is not null
+ && Edit(
+ buffers,
+ propsPath,
+ content => PackagesPropsEditor.RemoveEntry(content, finding.PackageId)
+ );
+
+ case FixKind.RemoveDuplicateReference:
+ return finding.ProjectPath is not null
+ && finding.PackageId is not null
+ && Edit(
+ buffers,
+ finding.ProjectPath,
+ content =>
+ ProjectRewriter.RemoveDuplicateReference(
+ Wrap(finding.ProjectPath, content),
+ finding.PackageId
+ )
+ );
+
+ case FixKind.NormalizePackageCasing:
+ {
+ if (finding.FixValue is null)
+ {
+ return false;
+ }
+
+ var canonical = finding.FixValue;
+ var changed = false;
+
+ foreach (var path in buffers.Keys.ToList())
+ {
+ changed |= Edit(
+ buffers,
+ path,
+ content =>
+ string.Equals(path, propsPath, StringComparison.OrdinalIgnoreCase)
+ ? PackagesPropsEditor.NormalizeCasing(content, canonical)
+ : ProjectRewriter.NormalizeCasing(Wrap(path, content), canonical)
+ );
+ }
+
+ return changed;
+ }
+
+ default:
+ return false;
+ }
+ }
+
+ private static ProjectFile Wrap(string path, string content) =>
+ new() { Path = path, Content = content };
+
+ private static bool Edit(
+ Dictionary buffers,
+ string path,
+ Func transform
+ )
+ {
+ if (!buffers.TryGetValue(path, out var buffer))
+ {
+ return false;
+ }
+
+ var updated = transform(buffer.Current);
+ if (updated is null)
+ {
+ return false;
+ }
+
+ buffers[path] = (buffer.Original, updated, buffer.Format);
+ return true;
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/BaselineService.cs b/CentralConfigGenerator.Core/Analysis/BaselineService.cs
new file mode 100644
index 0000000..6cf9bfb
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/BaselineService.cs
@@ -0,0 +1,88 @@
+using System.Text.Json;
+using CentralConfigGenerator.Core.IO;
+
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// A recorded set of accepted findings, used to keep CI green while debt is paid down.
+public sealed record Baseline
+{
+ public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
+
+ public List Fingerprints { get; init; } = [];
+}
+
+public interface IBaselineService
+{
+ Task LoadAsync(string path, CancellationToken cancellationToken = default);
+
+ Task WriteAsync(
+ string path,
+ IEnumerable findings,
+ CancellationToken cancellationToken = default
+ );
+
+ IReadOnlyList Apply(IReadOnlyList findings, Baseline? baseline);
+}
+
+public sealed class BaselineService(IFileSystem fileSystem) : IBaselineService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ public async Task LoadAsync(
+ string path,
+ CancellationToken cancellationToken = default
+ )
+ {
+ if (!fileSystem.FileExists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ var json = await fileSystem.ReadAllTextAsync(path, cancellationToken);
+ return JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ public async Task WriteAsync(
+ string path,
+ IEnumerable findings,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var baseline = new Baseline
+ {
+ Fingerprints = findings
+ .Select(f => f.Fingerprint)
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .ToList(),
+ };
+
+ await fileSystem.WriteAllTextAsync(
+ path,
+ JsonSerializer.Serialize(baseline, JsonOptions),
+ cancellationToken
+ );
+ }
+
+ public IReadOnlyList Apply(IReadOnlyList findings, Baseline? baseline)
+ {
+ if (baseline is null || baseline.Fingerprints.Count == 0)
+ {
+ return findings;
+ }
+
+ var accepted = baseline.Fingerprints.ToHashSet(StringComparer.Ordinal);
+ return findings.Where(f => !accepted.Contains(f.Fingerprint)).ToList();
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Finding.cs b/CentralConfigGenerator.Core/Analysis/Finding.cs
new file mode 100644
index 0000000..1b9c6dd
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Finding.cs
@@ -0,0 +1,64 @@
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// What an auto-fix should do when --fix is supplied.
+public enum FixKind
+{
+ None = 0,
+
+ /// Remove an inline Version attribute from a project's PackageReference.
+ RemoveInlineVersion,
+
+ /// Set (or add) a PackageVersion entry in Directory.Packages.props.
+ SetCentralVersion,
+
+ /// Delete a duplicate PackageReference element.
+ RemoveDuplicateReference,
+
+ /// Rewrite a package id so its casing matches the canonical spelling.
+ NormalizePackageCasing,
+
+ /// Remove an unused PackageVersion entry.
+ RemoveOrphanedPackageVersion,
+}
+
+/// A single analysis result.
+public sealed record Finding
+{
+ public required string RuleId { get; init; }
+
+ public required Severity Severity { get; init; }
+
+ public required string Message { get; init; }
+
+ public string? PackageId { get; init; }
+
+ public string? Version { get; init; }
+
+ public string? ProjectPath { get; init; }
+
+ ///
+ /// What the finding is about when it is not a package — an MSBuild property name, for
+ /// example. Keeps fingerprints unique for rules that report several findings per project.
+ ///
+ public string? Subject { get; init; }
+
+ public int Line { get; init; }
+
+ public string? Recommendation { get; init; }
+
+ public FixKind Fix { get; init; } = FixKind.None;
+
+ /// Target value for the fix (for example the version to pin to, or the canonical id).
+ public string? FixValue { get; init; }
+
+ public bool IsAutoFixable => Fix != FixKind.None;
+
+ /// Stable key used for baseline suppression.
+ public string Fingerprint =>
+ $"{RuleId}|{PackageId ?? Subject ?? "-"}|{NormalizePath(ProjectPath)}|{Version ?? "-"}";
+
+ private static string NormalizePath(string? path) =>
+ string.IsNullOrEmpty(path)
+ ? "-"
+ : Path.GetFileName(path);
+}
diff --git a/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs b/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs
new file mode 100644
index 0000000..f5ceb7b
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs
@@ -0,0 +1,27 @@
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// One dependency-health check.
+public interface IAnalysisRule
+{
+ string RuleId { get; }
+
+ string Title { get; }
+
+ /// What the rule looks for, shown by the explain command.
+ string Description { get; }
+
+ /// Why it matters and what to do about it.
+ string Rationale { get; }
+
+ Severity DefaultSeverity { get; }
+
+ /// True when the rule needs feed metadata to produce results.
+ bool RequiresNetwork => false;
+
+ /// True when the rule only applies once central package management is in play.
+ bool RequiresCentralPackageManagement => false;
+
+ bool IsEnabled(AnalysisOptions options) => true;
+
+ IEnumerable Analyze(AnalysisContext context);
+}
diff --git a/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs b/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs
new file mode 100644
index 0000000..7e5551c
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs
@@ -0,0 +1,31 @@
+using CentralConfigGenerator.Core.Analysis.Rules;
+
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// The complete set of analysis rules, and lookups used by the explain command.
+public static class RuleCatalog
+{
+ public static IReadOnlyList CreateAll() =>
+ [
+ new SecurityVulnerabilityRule(),
+ new InlineVersionUnderCpmRule(),
+ new MissingCentralVersionRule(),
+ new DeprecatedPackageRule(),
+ new LicenseRiskRule(),
+ new VersionInconsistencyRule(),
+ new TransitiveConflictRule(),
+ new UnpinnedTransitiveDependencyRule(),
+ new FloatingVersionRule(),
+ new RedundantReferenceRule(),
+ new DuplicatePackageCasingRule(),
+ new OutdatedPackageRule(),
+ new PrereleaseInProductionRule(),
+ new OrphanedPackageVersionRule(),
+ new FrameworkAlignmentRule(),
+ new PropertyDriftRule(),
+ ];
+
+ public static IAnalysisRule? Find(string ruleId) =>
+ CreateAll()
+ .FirstOrDefault(r => string.Equals(r.RuleId, ruleId, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/CentralConfigGenerator.Core/Analysis/RuleIds.cs b/CentralConfigGenerator.Core/Analysis/RuleIds.cs
new file mode 100644
index 0000000..6c29579
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/RuleIds.cs
@@ -0,0 +1,42 @@
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// Stable identifiers for every analysis rule, usable with explain and --rules.
+public static class RuleIds
+{
+ public const string SecurityVulnerability = "SecurityVulnerability";
+ public const string InlineVersionUnderCpm = "InlineVersionUnderCpm";
+ public const string LicenseRisk = "LicenseRisk";
+ public const string VersionInconsistency = "VersionInconsistency";
+ public const string FloatingVersion = "FloatingVersion";
+ public const string TransitiveConflict = "TransitiveConflict";
+ public const string DuplicatePackageCasing = "DuplicatePackageCasing";
+ public const string RedundantReference = "RedundantReference";
+ public const string OutdatedPackage = "OutdatedPackage";
+ public const string DeprecatedPackage = "DeprecatedPackage";
+ public const string FrameworkAlignment = "FrameworkAlignment";
+ public const string PrereleaseInProduction = "PrereleaseInProduction";
+ public const string MissingCentralVersion = "MissingCentralVersion";
+ public const string OrphanedPackageVersion = "OrphanedPackageVersion";
+ public const string UnpinnedTransitiveDependency = "UnpinnedTransitiveDependency";
+ public const string PropertyDrift = "PropertyDrift";
+
+ public static IReadOnlyList All { get; } =
+ [
+ SecurityVulnerability,
+ InlineVersionUnderCpm,
+ LicenseRisk,
+ VersionInconsistency,
+ FloatingVersion,
+ TransitiveConflict,
+ DuplicatePackageCasing,
+ RedundantReference,
+ OutdatedPackage,
+ DeprecatedPackage,
+ FrameworkAlignment,
+ PrereleaseInProduction,
+ MissingCentralVersion,
+ OrphanedPackageVersion,
+ UnpinnedTransitiveDependency,
+ PropertyDrift,
+ ];
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs
new file mode 100644
index 0000000..ee7e9c9
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs
@@ -0,0 +1,48 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Reports packages the author has marked deprecated on the feed.
+public sealed class DeprecatedPackageRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.DeprecatedPackage;
+
+ public string Title => "Deprecated package";
+
+ public string Description => "The package author marked this package as deprecated.";
+
+ public string Rationale =>
+ "Deprecated packages stop receiving fixes, including security fixes. The feed usually "
+ + "names a replacement, so the migration path is known.";
+
+ public Severity DefaultSeverity => Severity.High;
+
+ public bool RequiresNetwork => true;
+
+ public bool IsEnabled(AnalysisOptions options) => options.Deprecated;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ foreach (var (packageId, snapshot) in context.Metadata)
+ {
+ if (!snapshot.IsDeprecated || context.IsIgnored(packageId))
+ {
+ continue;
+ }
+
+ var reasons = snapshot.DeprecationReasons.Count > 0
+ ? string.Join(", ", snapshot.DeprecationReasons)
+ : "no reason given";
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = packageId,
+ Version = context.EffectiveVersion(packageId),
+ Message = $"'{packageId}' is deprecated ({reasons}).",
+ Recommendation = snapshot.AlternatePackageId is { } alternate
+ ? $"Migrate to '{alternate}'."
+ : snapshot.DeprecationMessage ?? "Find a maintained replacement.",
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs
new file mode 100644
index 0000000..df914c7
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs
@@ -0,0 +1,52 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags package ids that differ only by letter case.
+public sealed class DuplicatePackageCasingRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.DuplicatePackageCasing;
+
+ public string Title => "Package id casing mismatch";
+
+ public string Description =>
+ "The same package is spelled with different capitalisation in different projects.";
+
+ public string Rationale =>
+ "NuGet ids are case-insensitive but MSBuild item matching is not always: mismatched "
+ + "casing produces duplicate PackageVersion entries and confusing diffs.";
+
+ public Severity DefaultSeverity => Severity.Low;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ var groups = context
+ .RelevantReferences.Select(r => r.PackageId)
+ .Concat(context.CentralVersions.Keys.Where(k => !context.IsIgnored(k)))
+ .GroupBy(id => id, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var group in groups)
+ {
+ var spellings = group.Distinct(StringComparer.Ordinal).ToList();
+ if (spellings.Count < 2)
+ {
+ continue;
+ }
+
+ // Prefer the feed's canonical spelling when it is known.
+ var canonical =
+ context.MetadataFor(group.Key)?.PackageId
+ ?? spellings.OrderBy(s => s, StringComparer.Ordinal).First();
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = canonical,
+ Message =
+ $"'{group.Key}' appears with {spellings.Count} spellings: {string.Join(", ", spellings)}.",
+ Recommendation = $"Normalise every reference to '{canonical}'.",
+ Fix = FixKind.NormalizePackageCasing,
+ FixValue = canonical,
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs
new file mode 100644
index 0000000..71f5c2b
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs
@@ -0,0 +1,66 @@
+using CentralConfigGenerator.Core.Services;
+
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags wildcard and open-ended version specifications.
+public sealed class FloatingVersionRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.FloatingVersion;
+
+ public string Title => "Floating version";
+
+ public string Description =>
+ "A version uses a wildcard (4.*) or an unbounded range ([4.0.0,)).";
+
+ public string Rationale =>
+ "Floating versions make builds non-reproducible: the same commit resolves to different "
+ + "binaries depending on when it was restored, which turns a feed publish into a surprise "
+ + "production change.";
+
+ public Severity DefaultSeverity => Severity.Moderate;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (packageId, version) in AllVersions(context))
+ {
+ if (!VersionSelector.IsFloating(version) || !seen.Add($"{packageId}|{version}"))
+ {
+ continue;
+ }
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = packageId,
+ Version = version,
+ Message = $"'{packageId}' uses the floating version '{version}'.",
+ Recommendation =
+ "Pin an exact version so restores are reproducible across machines and time.",
+ };
+ }
+ }
+
+ private static IEnumerable<(string PackageId, string Version)> AllVersions(
+ AnalysisContext context
+ )
+ {
+ foreach (var reference in context.RelevantReferences)
+ {
+ if (!string.IsNullOrWhiteSpace(reference.Version))
+ {
+ yield return (reference.PackageId, reference.Version!);
+ }
+ }
+
+ foreach (var (packageId, version) in context.CentralVersions)
+ {
+ if (!context.IsIgnored(packageId))
+ {
+ yield return (packageId, version);
+ }
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs
new file mode 100644
index 0000000..121ebee
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs
@@ -0,0 +1,68 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Reports target-framework drift across the workspace.
+public sealed class FrameworkAlignmentRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.FrameworkAlignment;
+
+ public string Title => "Target framework drift";
+
+ public string Description =>
+ "Projects in the same workspace target different framework versions.";
+
+ public string Rationale =>
+ "Mixed target frameworks split the dependency graph: each TFM resolves packages "
+ + "independently, so a single central version can still produce two different builds.";
+
+ public Severity DefaultSeverity => Severity.Low;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ if (context.TargetFrameworks.Count == 0)
+ {
+ yield break;
+ }
+
+ var distinct = context
+ .TargetFrameworks.SelectMany(pair => pair.Value)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (distinct.Count < 2)
+ {
+ yield break;
+ }
+
+ // The dominant framework is the one the most projects can build against.
+ var dominant = distinct
+ .OrderByDescending(tfm =>
+ context.TargetFrameworks.Count(pair =>
+ pair.Value.Contains(tfm, StringComparer.OrdinalIgnoreCase)
+ )
+ )
+ .ThenByDescending(tfm => tfm, StringComparer.OrdinalIgnoreCase)
+ .First();
+
+ foreach (var (projectPath, frameworks) in context.TargetFrameworks)
+ {
+ // A multi-targeted project that includes the dominant framework is aligned already.
+ if (frameworks.Contains(dominant, StringComparer.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ ProjectPath = projectPath,
+ Subject = string.Join(";", frameworks),
+ Message =
+ $"{Path.GetFileName(projectPath)} targets {string.Join(", ", frameworks)} "
+ + $"but not {dominant}, which the rest of the workspace builds against.",
+ Recommendation =
+ $"Add {dominant} to its target frameworks, or hoist TargetFramework into Directory.Build.props deliberately.",
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs
new file mode 100644
index 0000000..1a09151
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs
@@ -0,0 +1,55 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags inline versions that survive after central package management was enabled.
+public sealed class InlineVersionUnderCpmRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.InlineVersionUnderCpm;
+
+ public string Title => "Inline version under central package management";
+
+ public string Description =>
+ "A project still declares Version on a PackageReference even though "
+ + "ManagePackageVersionsCentrally is enabled.";
+
+ public string Rationale =>
+ "NuGet raises NU1008 for these references, and where it does not, the inline version "
+ + "silently wins over the central one — exactly the drift CPM is meant to prevent.";
+
+ public Severity DefaultSeverity => Severity.High;
+
+ public bool RequiresCentralPackageManagement => true;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ foreach (var reference in context.RelevantReferences)
+ {
+ if (reference.IsGlobal || string.IsNullOrWhiteSpace(reference.Version))
+ {
+ continue;
+ }
+
+ var central = context.CentralVersions.TryGetValue(reference.PackageId, out var value)
+ ? value
+ : null;
+
+ var drift =
+ central is not null
+ && !string.Equals(central, reference.Version, StringComparison.OrdinalIgnoreCase);
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = drift ? Severity.High : Severity.Moderate,
+ PackageId = reference.PackageId,
+ Version = reference.Version,
+ ProjectPath = reference.ProjectPath,
+ Message = drift
+ ? $"'{reference.PackageId}' pins {reference.Version} inline while Directory.Packages.props says {central}."
+ : $"'{reference.PackageId}' still declares an inline version of {reference.Version}.",
+ Recommendation = "Remove the Version attribute and let the central entry apply.",
+ Fix = FixKind.RemoveInlineVersion,
+ FixValue = central ?? reference.Version,
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs
new file mode 100644
index 0000000..bbac379
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs
@@ -0,0 +1,106 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags copyleft, proprietary and unknown licences.
+public sealed class LicenseRiskRule : IAnalysisRule
+{
+ /// Licence identifiers that impose reciprocal obligations on distributed software.
+ private static readonly string[] StrongCopyleft =
+ [
+ "GPL-2.0",
+ "GPL-3.0",
+ "AGPL-3.0",
+ "SSPL",
+ "OSL-3.0",
+ "EUPL",
+ ];
+
+ private static readonly string[] WeakCopyleft = ["LGPL", "MPL-2.0", "MS-RL", "CDDL", "EPL"];
+
+ public string RuleId => RuleIds.LicenseRisk;
+
+ public string Title => "Licence risk";
+
+ public string Description =>
+ "A dependency ships under a copyleft, proprietary or undeclared licence.";
+
+ public string Rationale =>
+ "Copyleft terms can propagate to the software that links the package, and an undeclared "
+ + "licence means no permission has actually been granted. Both need a human decision.";
+
+ public Severity DefaultSeverity => Severity.Moderate;
+
+ public bool RequiresNetwork => true;
+
+ public bool IsEnabled(AnalysisOptions options) => options.Licenses;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ foreach (var (packageId, snapshot) in context.Metadata)
+ {
+ if (context.IsIgnored(packageId) || !snapshot.Found)
+ {
+ continue;
+ }
+
+ var license = snapshot.License;
+
+ if (string.IsNullOrWhiteSpace(license))
+ {
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = Severity.Low,
+ PackageId = packageId,
+ Version = context.EffectiveVersion(packageId),
+ Message = $"'{packageId}' does not declare a licence.",
+ Recommendation =
+ "Confirm the terms with the author before shipping this dependency.",
+ };
+ continue;
+ }
+
+ if (StrongCopyleft.Any(l => license.Contains(l, StringComparison.OrdinalIgnoreCase)))
+ {
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = Severity.High,
+ PackageId = packageId,
+ Version = context.EffectiveVersion(packageId),
+ Message = $"'{packageId}' is licensed under {license} (strong copyleft).",
+ Recommendation =
+ "Distributing linked software may require releasing your source. Get legal sign-off.",
+ };
+ continue;
+ }
+
+ if (WeakCopyleft.Any(l => license.Contains(l, StringComparison.OrdinalIgnoreCase)))
+ {
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = Severity.Moderate,
+ PackageId = packageId,
+ Version = context.EffectiveVersion(packageId),
+ Message = $"'{packageId}' is licensed under {license} (weak copyleft).",
+ Recommendation =
+ "Modifications to the package itself must usually be published. Keep it unforked.",
+ };
+ continue;
+ }
+
+ if (license.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = Severity.Info,
+ PackageId = packageId,
+ Version = context.EffectiveVersion(packageId),
+ Message = $"'{packageId}' points at a licence URL rather than an SPDX expression.",
+ Recommendation = $"Review {license} manually.",
+ };
+ }
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs
new file mode 100644
index 0000000..6b9e45c
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs
@@ -0,0 +1,54 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags references that have neither an inline nor a central version.
+public sealed class MissingCentralVersionRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.MissingCentralVersion;
+
+ public string Title => "Package has no version anywhere";
+
+ public string Description =>
+ "A PackageReference has no inline version and no matching PackageVersion entry.";
+
+ public string Rationale =>
+ "The restore fails with NU1010. It usually means a package was added by hand after the "
+ + "migration, or a PackageVersion entry was deleted.";
+
+ public Severity DefaultSeverity => Severity.Critical;
+
+ public bool RequiresCentralPackageManagement => true;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ foreach (var reference in context.RelevantReferences)
+ {
+ if (
+ reference.IsGlobal
+ || !string.IsNullOrWhiteSpace(reference.Version)
+ || context.CentralVersions.ContainsKey(reference.PackageId)
+ )
+ {
+ continue;
+ }
+
+ var suggested = context
+ .MetadataFor(reference.PackageId)
+ ?.LatestStable?.ToNormalizedString();
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = reference.PackageId,
+ ProjectPath = reference.ProjectPath,
+ Message =
+ $"'{reference.PackageId}' has no version: no inline attribute and no PackageVersion entry.",
+ Recommendation = suggested is null
+ ? "Add a PackageVersion entry to Directory.Packages.props."
+ : $"Add .",
+ Fix = suggested is null ? FixKind.None : FixKind.SetCentralVersion,
+ FixValue = suggested,
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs
new file mode 100644
index 0000000..c02bd3a
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs
@@ -0,0 +1,48 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags PackageVersion entries that no project references any more.
+public sealed class OrphanedPackageVersionRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.OrphanedPackageVersion;
+
+ public string Title => "Unused central version entry";
+
+ public string Description =>
+ "Directory.Packages.props pins a package that no project references.";
+
+ public string Rationale =>
+ "Dead entries accumulate after refactors and make the central file look like it "
+ + "describes dependencies that no longer exist. They also confuse audit tooling.";
+
+ public Severity DefaultSeverity => Severity.Info;
+
+ public bool RequiresCentralPackageManagement => true;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ var referenced = context
+ .References.Select(r => r.PackageId)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (packageId, version) in context.CentralVersions)
+ {
+ if (referenced.Contains(packageId) || context.IsIgnored(packageId))
+ {
+ continue;
+ }
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = packageId,
+ Version = version,
+ ProjectPath = context.PackagesPropsPath,
+ Message = $"'{packageId}' is pinned centrally but referenced by no project.",
+ Recommendation = "Remove the PackageVersion entry.",
+ Fix = FixKind.RemoveOrphanedPackageVersion,
+ FixValue = packageId,
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs
new file mode 100644
index 0000000..127d08b
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs
@@ -0,0 +1,71 @@
+using NuGet.Versioning;
+
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Reports packages that lag behind the newest published version.
+public sealed class OutdatedPackageRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.OutdatedPackage;
+
+ public string Title => "Outdated package";
+
+ public string Description => "A newer version of the package is published on the feed.";
+
+ public string Rationale =>
+ "Severity scales with the size of the gap: a patch behind is noise, a major behind is a "
+ + "migration that only gets harder to do later.";
+
+ public Severity DefaultSeverity => Severity.Low;
+
+ public bool RequiresNetwork => true;
+
+ public bool IsEnabled(AnalysisOptions options) => options.Outdated;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ foreach (var (packageId, snapshot) in context.Metadata)
+ {
+ if (context.IsIgnored(packageId) || !snapshot.Found)
+ {
+ continue;
+ }
+
+ var current = context.EffectiveVersion(packageId);
+ if (current is null || !NuGetVersion.TryParse(current, out var currentVersion))
+ {
+ continue;
+ }
+
+ var latest = context.Options.IncludePrerelease
+ ? snapshot.LatestIncludingPrerelease
+ : snapshot.LatestStable;
+
+ if (latest is null || latest <= currentVersion)
+ {
+ continue;
+ }
+
+ var majorGap = latest.Major - currentVersion.Major;
+ var severity = majorGap switch
+ {
+ >= 2 => Severity.Moderate,
+ 1 => Severity.Low,
+ _ => latest.Minor > currentVersion.Minor ? Severity.Low : Severity.Info,
+ };
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = severity,
+ PackageId = packageId,
+ Version = current,
+ Message =
+ $"'{packageId}' is on {current}; {latest.ToNormalizedString()} is available"
+ + (majorGap > 0 ? $" ({majorGap} major version(s) behind)." : "."),
+ Recommendation = $"Update to {latest.ToNormalizedString()}.",
+ Fix = FixKind.SetCentralVersion,
+ FixValue = latest.ToNormalizedString(),
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs
new file mode 100644
index 0000000..d2e476c
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs
@@ -0,0 +1,57 @@
+using CentralConfigGenerator.Core.Services;
+
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags pre-release versions outside of test-only packages.
+public sealed class PrereleaseInProductionRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.PrereleaseInProduction;
+
+ public string Title => "Pre-release dependency";
+
+ public string Description => "A package resolves to a pre-release version.";
+
+ public string Rationale =>
+ "Pre-release packages can be unlisted or replaced by the author without notice, and their "
+ + "API is not covered by any compatibility promise.";
+
+ public Severity DefaultSeverity => Severity.Low;
+
+ public bool IsEnabled(AnalysisOptions options) => !options.IncludePrerelease;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ var reported = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var reference in context.RelevantReferences)
+ {
+ var version = reference.Version ?? context.EffectiveVersion(reference.PackageId);
+
+ if (
+ version is null
+ || !VersionSelector.IsPrerelease(version)
+ || !reported.Add(reference.PackageId)
+ )
+ {
+ continue;
+ }
+
+ var stable = context.MetadataFor(reference.PackageId)?.LatestStable;
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = reference.PackageId,
+ Version = version,
+ ProjectPath = reference.ProjectPath,
+ Message = $"'{reference.PackageId}' uses pre-release version {version}.",
+ Recommendation = stable is null
+ ? "Move to a stable release before shipping."
+ : $"A stable {stable.ToNormalizedString()} is available.",
+ Fix = stable is null ? FixKind.None : FixKind.SetCentralVersion,
+ FixValue = stable?.ToNormalizedString(),
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs
new file mode 100644
index 0000000..dc2d0f9
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs
@@ -0,0 +1,48 @@
+using CentralConfigGenerator.Core.Migration;
+
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Reports properties repeated identically in every project, which belong in Directory.Build.props.
+public sealed class PropertyDriftRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.PropertyDrift;
+
+ public string Title => "Property duplicated across projects";
+
+ public string Description =>
+ "Several projects declare the same MSBuild property with the same value.";
+
+ public string Rationale =>
+ "Copies drift. One project eventually gets edited and the difference is invisible in "
+ + "review. Directory.Build.props makes the value single-sourced.";
+
+ public Severity DefaultSeverity => Severity.Info;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ if (context.Projects.Count < 2)
+ {
+ yield break;
+ }
+
+ var hoistable = PropertyHoister.FindHoistableProperties(
+ context.Projects,
+ BuildPropertyDefaults.Recommended
+ );
+
+ foreach (var (name, value) in hoistable.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ Subject = name,
+ Message = $"'{name}' is set to '{value}' in every project that declares it.",
+ // Hoisting also has to delete the property from every project, which is what the
+ // 'build' command does transactionally. Doing half of it here would be worse.
+ Recommendation =
+ $"Move {name} into Directory.Build.props (run 'central-config build' or 'migrate --unify-props').",
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs
new file mode 100644
index 0000000..2a24b32
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs
@@ -0,0 +1,72 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags the same package referenced twice inside one project.
+public sealed class RedundantReferenceRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.RedundantReference;
+
+ public string Title => "Duplicate package reference";
+
+ public string Description => "One project declares the same PackageReference more than once.";
+
+ public string Rationale =>
+ "Duplicates trigger NU1504 warnings and make it ambiguous which metadata (PrivateAssets, "
+ + "IncludeAssets) actually applies.";
+
+ public Severity DefaultSeverity => Severity.Moderate;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ var groups = context.RelevantReferences.GroupBy(
+ r => (r.ProjectPath, r.PackageId),
+ TupleComparer.Instance
+ );
+
+ foreach (var group in groups)
+ {
+ var entries = group.ToList();
+ if (entries.Count < 2)
+ {
+ continue;
+ }
+
+ // References separated by a condition are legitimate (per-TFM references).
+ if (entries.All(e => !string.IsNullOrWhiteSpace(e.Condition))
+ && entries.Select(e => e.Condition).Distinct(StringComparer.OrdinalIgnoreCase).Count() == entries.Count)
+ {
+ continue;
+ }
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = group.Key.PackageId,
+ ProjectPath = group.Key.ProjectPath,
+ Message =
+ $"'{group.Key.PackageId}' is referenced {entries.Count} times in {Path.GetFileName(group.Key.ProjectPath)}.",
+ Recommendation = "Keep a single PackageReference and delete the duplicates.",
+ Fix = FixKind.RemoveDuplicateReference,
+ FixValue = group.Key.PackageId,
+ };
+ }
+ }
+
+ private sealed class TupleComparer : IEqualityComparer<(string ProjectPath, string PackageId)>
+ {
+ public static readonly TupleComparer Instance = new();
+
+ public bool Equals(
+ (string ProjectPath, string PackageId) x,
+ (string ProjectPath, string PackageId) y
+ ) =>
+ string.Equals(x.ProjectPath, y.ProjectPath, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(x.PackageId, y.PackageId, StringComparison.OrdinalIgnoreCase);
+
+ public int GetHashCode((string ProjectPath, string PackageId) obj) =>
+ HashCode.Combine(
+ obj.ProjectPath.ToLowerInvariant(),
+ obj.PackageId.ToLowerInvariant()
+ );
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs
new file mode 100644
index 0000000..d503dfb
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs
@@ -0,0 +1,90 @@
+using NuGet.Versioning;
+
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Reports known security advisories affecting the resolved versions.
+public sealed class SecurityVulnerabilityRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.SecurityVulnerability;
+
+ public string Title => "Known security vulnerability";
+
+ public string Description =>
+ "The resolved version of a package is covered by a published security advisory.";
+
+ public string Rationale =>
+ "A vulnerable dependency is exploitable in production regardless of how the rest of the "
+ + "code is written. This is the one finding that should always block a release.";
+
+ public Severity DefaultSeverity => Severity.Critical;
+
+ public bool RequiresNetwork => true;
+
+ public bool IsEnabled(AnalysisOptions options) => options.Audit;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ foreach (var (packageId, snapshot) in context.Metadata)
+ {
+ if (context.IsIgnored(packageId) || snapshot.Vulnerabilities.Count == 0)
+ {
+ continue;
+ }
+
+ var current = context.EffectiveVersion(packageId);
+ if (current is null || !NuGetVersion.TryParse(current, out var currentVersion))
+ {
+ continue;
+ }
+
+ var affecting = snapshot
+ .Vulnerabilities.Where(v =>
+ v.AffectedRange is not null
+ && NuGetVersion.TryParse(v.AffectedRange, out var affected)
+ && affected.Equals(currentVersion)
+ )
+ .ToList();
+
+ if (affecting.Count == 0)
+ {
+ continue;
+ }
+
+ var worst = affecting.Max(v => v.Severity);
+ var fixedVersion = FindFirstSafeVersion(snapshot, currentVersion);
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = worst,
+ PackageId = packageId,
+ Version = current,
+ Message =
+ $"'{packageId}' {current} has {affecting.Count} known advisory/advisories ({worst}).",
+ Recommendation = fixedVersion is null
+ ? $"Review {affecting[0].AdvisoryUrl}"
+ : $"Upgrade to {fixedVersion.ToNormalizedString()}. See {affecting[0].AdvisoryUrl}",
+ Fix = fixedVersion is null ? FixKind.None : FixKind.SetCentralVersion,
+ FixValue = fixedVersion?.ToNormalizedString(),
+ };
+ }
+ }
+
+ /// Finds the lowest version above the current one that carries no advisory.
+ private static NuGetVersion? FindFirstSafeVersion(
+ NuGet.PackageMetadataSnapshot snapshot,
+ NuGetVersion current
+ )
+ {
+ var vulnerable = snapshot
+ .Vulnerabilities.Select(v => v.AffectedRange)
+ .Where(v => v is not null)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return snapshot
+ .AllVersions.Where(v => v > current && !v.IsPrerelease)
+ .Where(v => !vulnerable.Contains(v.ToNormalizedString()))
+ .OrderBy(v => v)
+ .FirstOrDefault();
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs
new file mode 100644
index 0000000..64f18b4
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs
@@ -0,0 +1,62 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Reports packages whose transitive graph resolves to more than one version.
+public sealed class TransitiveConflictRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.TransitiveConflict;
+
+ public string Title => "Divergent transitive resolution";
+
+ public string Description =>
+ "The same package resolves to different versions in different projects or frameworks.";
+
+ public string Rationale =>
+ "Divergence is invisible in the project files but very visible at runtime, where one "
+ + "assembly loads and the other does not. Pinning centrally collapses the graph.";
+
+ public Severity DefaultSeverity => Severity.Moderate;
+
+ public bool IsEnabled(AnalysisOptions options) => options.Transitive;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ if (!context.Graph.IsAvailable)
+ {
+ yield break;
+ }
+
+ foreach (var group in context.Graph.DivergentPackages)
+ {
+ if (context.IsIgnored(group.Key))
+ {
+ continue;
+ }
+
+ var versions = group
+ .Select(p => p.ResolvedVersion)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var highest = versions[^1];
+ var isDirect = group.Any(p => !p.IsTransitive);
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = isDirect ? Severity.Moderate : Severity.Low,
+ PackageId = group.Key,
+ Version = highest,
+ Message =
+ $"'{group.Key}' resolves to {versions.Count} versions across the graph: {string.Join(", ", versions)}.",
+ Recommendation =
+ $"Pin '{group.Key}' to {highest} centrally"
+ + (context.TransitivePinningEnabled
+ ? "."
+ : " and enable CentralPackageTransitivePinningEnabled."),
+ Fix = FixKind.SetCentralVersion,
+ FixValue = highest,
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs
new file mode 100644
index 0000000..26a22fe
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs
@@ -0,0 +1,101 @@
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+///
+/// Reports vulnerable or deprecated transitive dependencies that no central entry pins,
+/// so they cannot be fixed without upgrading their parent.
+///
+public sealed class UnpinnedTransitiveDependencyRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.UnpinnedTransitiveDependency;
+
+ public string Title => "Risky transitive dependency is not pinned";
+
+ public string Description =>
+ "A transitive dependency carries an advisory or deprecation notice but has no "
+ + "PackageVersion entry that could override it.";
+
+ public string Rationale =>
+ "Transitive pinning is the only way to patch a vulnerable indirect dependency without "
+ + "waiting for the intermediate package to publish a fix.";
+
+ public Severity DefaultSeverity => Severity.High;
+
+ public bool RequiresNetwork => true;
+
+ public bool IsEnabled(AnalysisOptions options) => options.Transitive && options.Audit;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ if (!context.Graph.IsAvailable)
+ {
+ yield break;
+ }
+
+ var directIds = context
+ .References.Select(r => r.PackageId)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var package in context.Graph.Transitive)
+ {
+ if (
+ directIds.Contains(package.PackageId)
+ || context.CentralVersions.ContainsKey(package.PackageId)
+ || context.IsIgnored(package.PackageId)
+ || !seen.Add(package.PackageId)
+ )
+ {
+ continue;
+ }
+
+ var snapshot = context.MetadataFor(package.PackageId);
+ if (snapshot is null)
+ {
+ continue;
+ }
+
+ var affected = snapshot.Vulnerabilities.Any(v =>
+ string.Equals(v.AffectedRange, package.ResolvedVersion, StringComparison.OrdinalIgnoreCase)
+ );
+
+ if (!affected && !snapshot.IsDeprecated)
+ {
+ continue;
+ }
+
+ var safe = snapshot
+ .AllVersions.Where(v =>
+ !v.IsPrerelease
+ && v.ToNormalizedString() != package.ResolvedVersion
+ && !snapshot.Vulnerabilities.Any(x =>
+ string.Equals(
+ x.AffectedRange,
+ v.ToNormalizedString(),
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ )
+ .OrderByDescending(v => v)
+ .FirstOrDefault();
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = affected ? Severity.High : Severity.Moderate,
+ PackageId = package.PackageId,
+ Version = package.ResolvedVersion,
+ ProjectPath = package.ProjectPath,
+ Message =
+ $"Transitive '{package.PackageId}' {package.ResolvedVersion} is "
+ + (affected ? "vulnerable" : "deprecated")
+ + " and is not pinned centrally.",
+ Recommendation = safe is null
+ ? "Enable CentralPackageTransitivePinningEnabled and pin a safe version."
+ : $"Pin '{package.PackageId}' to {safe.ToNormalizedString()} with transitive pinning enabled.",
+ Fix = safe is null ? FixKind.None : FixKind.SetCentralVersion,
+ FixValue = safe?.ToNormalizedString(),
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs
new file mode 100644
index 0000000..2c08ab4
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs
@@ -0,0 +1,60 @@
+using CentralConfigGenerator.Core.Services;
+
+namespace CentralConfigGenerator.Core.Analysis.Rules;
+
+/// Flags packages referenced with more than one version across the workspace.
+public sealed class VersionInconsistencyRule : IAnalysisRule
+{
+ public string RuleId => RuleIds.VersionInconsistency;
+
+ public string Title => "Inconsistent package versions";
+
+ public string Description =>
+ "The same package is referenced with different versions by different projects.";
+
+ public string Rationale =>
+ "Divergent versions produce different binaries per project and make runtime binding "
+ + "failures likely. Central package management removes the drift by pinning one version.";
+
+ public Severity DefaultSeverity => Severity.Moderate;
+
+ public IEnumerable Analyze(AnalysisContext context)
+ {
+ var groups = context
+ .RelevantReferences.Where(r => !string.IsNullOrWhiteSpace(r.Version))
+ .GroupBy(r => r.PackageId, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var group in groups)
+ {
+ var versions = group
+ .Select(r => r.Version!)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (versions.Count < 2)
+ {
+ continue;
+ }
+
+ var highest = versions
+ .OrderBy(v => v, Comparer.Create((a, b) =>
+ VersionSelector.Compare(a, b, global::NuGet.Versioning.VersionComparison.VersionRelease)))
+ .Last();
+
+ yield return new Finding
+ {
+ RuleId = RuleId,
+ Severity = DefaultSeverity,
+ PackageId = group.Key,
+ Message =
+ $"'{group.Key}' is referenced with {versions.Count} versions: {string.Join(", ", versions)}.",
+ Recommendation =
+ $"Pin a single version (highest observed is {highest}) in Directory.Packages.props.",
+ Fix = FixKind.SetCentralVersion,
+ FixValue = highest,
+ Version = highest,
+ };
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Analysis/Severity.cs b/CentralConfigGenerator.Core/Analysis/Severity.cs
new file mode 100644
index 0000000..4a19cfa
--- /dev/null
+++ b/CentralConfigGenerator.Core/Analysis/Severity.cs
@@ -0,0 +1,13 @@
+namespace CentralConfigGenerator.Core.Analysis;
+
+/// Finding severity, ordered so thresholds can be compared numerically.
+public enum Severity
+{
+ /// Used only as a --fail-on threshold meaning "never fail".
+ Never = 0,
+ Info = 1,
+ Low = 2,
+ Moderate = 3,
+ High = 4,
+ Critical = 5,
+}
diff --git a/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj b/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj
index 9d48c53..8eacf32 100644
--- a/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj
+++ b/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj
@@ -1,5 +1,12 @@
-
-
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+
+
diff --git a/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs b/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs
new file mode 100644
index 0000000..9d82c4e
--- /dev/null
+++ b/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs
@@ -0,0 +1,67 @@
+using System.Text.Json.Serialization;
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Configuration;
+
+///
+/// Contents of a .centralconfig.json file. Every value is optional; anything left unset
+/// falls back to the command-line default.
+///
+public sealed record CentralConfigSettings
+{
+ [JsonPropertyName("$schema")]
+ public string? Schema { get; init; }
+
+ public VersionResolutionStrategyName? ConflictStrategy { get; init; }
+
+ public bool? Backup { get; init; }
+
+ public string? BackupDir { get; init; }
+
+ public bool? AddGitignore { get; init; }
+
+ public Severity? FailOn { get; init; }
+
+ public string? Baseline { get; init; }
+
+ public RetentionSettings? Retention { get; init; }
+
+ public Dictionary? Rules { get; init; }
+
+ public string? ExcludeDirs { get; init; }
+
+ public bool? TransitivePinning { get; init; }
+
+ public bool? IgnorePrerelease { get; init; }
+
+ public LineEndingStyle? LineEnding { get; init; }
+
+ public string? Encoding { get; init; }
+
+ /// Property names promoted into Directory.Build.props by the build command.
+ public List? BuildProperties { get; init; }
+
+ /// Packages that analysis should ignore entirely.
+ public List? IgnorePackages { get; init; }
+
+ public string? SourceDirectory { get; init; }
+}
+
+public sealed record RetentionSettings
+{
+ public bool? Enabled { get; init; }
+
+ public int? MaxBackups { get; init; }
+}
+
+/// Serialisable mirror of VersionResolutionStrategy, including Fail.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum VersionResolutionStrategyName
+{
+ Highest,
+ Lowest,
+ MostCommon,
+ Fail,
+ Manual,
+}
diff --git a/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs b/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs
new file mode 100644
index 0000000..22a8abd
--- /dev/null
+++ b/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs
@@ -0,0 +1,119 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using CentralConfigGenerator.Core.IO;
+
+namespace CentralConfigGenerator.Core.Configuration;
+
+///
+/// Discovers and reads .centralconfig.json by walking up from a starting directory,
+/// mirroring how .editorconfig and global.json are resolved.
+///
+public sealed class ConfigurationLoader(IFileSystem fileSystem)
+{
+ public const string FileName = ".centralconfig.json";
+
+ public const string SchemaUrl =
+ "https://raw.githubusercontent.com/TarasKovalenko/CentralConfigGenerator/main/schemas/centralconfig.schema.json";
+
+ public static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ public string? Find(string startDirectory)
+ {
+ var directory = new DirectoryInfo(Path.GetFullPath(startDirectory));
+
+ while (directory is not null)
+ {
+ var candidate = Path.Combine(directory.FullName, FileName);
+ if (fileSystem.FileExists(candidate))
+ {
+ return candidate;
+ }
+
+ directory = directory.Parent;
+ }
+
+ return null;
+ }
+
+ public async Task<(CentralConfigSettings? Settings, string? Path, string? Error)> LoadAsync(
+ string startDirectory,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var path = Find(startDirectory);
+ if (path is null)
+ {
+ return (null, null, null);
+ }
+
+ try
+ {
+ var json = await fileSystem.ReadAllTextAsync(path, cancellationToken);
+ var settings = JsonSerializer.Deserialize(
+ json,
+ SerializerOptions
+ );
+ return (settings, path, null);
+ }
+ catch (JsonException ex)
+ {
+ return (null, path, ex.Message);
+ }
+ }
+
+ public async Task ScaffoldAsync(
+ string directory,
+ bool overwrite,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var path = Path.Combine(Path.GetFullPath(directory), FileName);
+
+ if (fileSystem.FileExists(path) && !overwrite)
+ {
+ return path;
+ }
+
+ var template = new CentralConfigSettings
+ {
+ Schema = SchemaUrl,
+ ConflictStrategy = VersionResolutionStrategyName.Highest,
+ Backup = true,
+ AddGitignore = true,
+ FailOn = Analysis.Severity.High,
+ TransitivePinning = false,
+ IgnorePrerelease = false,
+ Retention = new RetentionSettings { Enabled = true, MaxBackups = 5 },
+ Rules = new Dictionary
+ {
+ [Analysis.RuleIds.OutdatedPackage] = "Low",
+ [Analysis.RuleIds.LicenseRisk] = "Moderate",
+ },
+ BuildProperties =
+ [
+ "TargetFramework",
+ "TargetFrameworks",
+ "ImplicitUsings",
+ "Nullable",
+ "LangVersion",
+ ],
+ };
+
+ await fileSystem.WriteAllTextAsync(
+ path,
+ JsonSerializer.Serialize(template, SerializerOptions),
+ cancellationToken
+ );
+
+ return path;
+ }
+}
diff --git a/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs b/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs
new file mode 100644
index 0000000..d320132
--- /dev/null
+++ b/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs
@@ -0,0 +1,52 @@
+using System.Text.RegularExpressions;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Discovery;
+
+///
+/// Controls which MSBuild files a scan picks up.
+///
+public sealed record DiscoveryOptions
+{
+ public static readonly string[] DefaultExcludedDirectories =
+ [
+ "bin",
+ "obj",
+ ".git",
+ ".vs",
+ ".idea",
+ "node_modules",
+ "packages",
+ "artifacts",
+ "TestResults",
+ // Our own rollback copies are full project trees; scanning them would double-count
+ // every project and let a fix rewrite the backup it is supposed to restore from.
+ ".centralconfig-backups",
+ ];
+
+ /// Root directory to scan. Ignored when is set.
+ public string RootDirectory { get; init; } = Directory.GetCurrentDirectory();
+
+ /// Optional solution (.sln/.slnx) or solution filter (.slnf) that scopes the scan.
+ public string? SolutionPath { get; init; }
+
+ /// Optional single project path that scopes the scan.
+ public string? ProjectPath { get; init; }
+
+ /// Regex applied to directory names and relative paths; matches are skipped.
+ public string? ExcludePattern { get; init; }
+
+ /// Project kinds to include. Defaults to C#, F# and VB.
+ public IReadOnlyCollection IncludedKinds { get; init; } =
+ [ProjectKind.CSharp, ProjectKind.FSharp, ProjectKind.VisualBasic];
+
+ public bool Recursive { get; init; } = true;
+
+ /// When false, the built-in bin/obj/node_modules exclusions are not applied.
+ public bool ApplyDefaultExclusions { get; init; } = true;
+
+ public Regex? CompileExcludeRegex() =>
+ string.IsNullOrWhiteSpace(ExcludePattern)
+ ? null
+ : new Regex(ExcludePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+}
diff --git a/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs b/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs
new file mode 100644
index 0000000..f01ed0a
--- /dev/null
+++ b/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs
@@ -0,0 +1,15 @@
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Discovery;
+
+public interface IProjectDiscoveryService
+{
+ Task> DiscoverAsync(
+ DiscoveryOptions options,
+ CancellationToken cancellationToken = default
+ );
+
+ Task LoadAsync(string path, CancellationToken cancellationToken = default);
+
+ Task SaveAsync(ProjectFile projectFile, CancellationToken cancellationToken = default);
+}
diff --git a/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs b/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs
new file mode 100644
index 0000000..b47aa6f
--- /dev/null
+++ b/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs
@@ -0,0 +1,183 @@
+using CentralConfigGenerator.Core.IO;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Discovery;
+
+///
+/// Finds project files by scanning a directory tree or by walking a solution, preserving each
+/// file's original encoding and line endings so they can be rewritten faithfully.
+///
+public sealed class ProjectDiscoveryService(IFileSystem fileSystem) : IProjectDiscoveryService
+{
+ public async Task> DiscoverAsync(
+ DiscoveryOptions options,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var paths = ResolvePaths(options);
+ var results = new List(paths.Count);
+
+ foreach (var path in paths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var file = await LoadAsync(path, cancellationToken);
+ if (file is not null)
+ {
+ results.Add(file);
+ }
+ }
+
+ return results
+ .OrderBy(f => f.Path, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public async Task LoadAsync(
+ string path,
+ CancellationToken cancellationToken = default
+ )
+ {
+ if (!fileSystem.FileExists(path))
+ {
+ return null;
+ }
+
+ var bytes = await fileSystem.ReadAllBytesAsync(path, cancellationToken);
+ var (content, format) = EncodingDetector.Decode(bytes);
+
+ return new ProjectFile
+ {
+ Path = path,
+ Content = content,
+ Kind = ProjectKindExtensions.FromPath(path),
+ Format = format,
+ };
+ }
+
+ public async Task SaveAsync(
+ ProjectFile projectFile,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var bytes = EncodingDetector.Encode(projectFile.Content, projectFile.Format);
+ await fileSystem.WriteAllBytesAsync(projectFile.Path, bytes, cancellationToken);
+ }
+
+ private IReadOnlyList ResolvePaths(DiscoveryOptions options)
+ {
+ var kinds = options.IncludedKinds.ToHashSet();
+
+ if (!string.IsNullOrWhiteSpace(options.ProjectPath))
+ {
+ var project = Path.GetFullPath(options.ProjectPath);
+ if (fileSystem.FileExists(project))
+ {
+ return [project];
+ }
+
+ if (fileSystem.DirectoryExists(project))
+ {
+ return ScanDirectory(project, options, kinds);
+ }
+
+ return [];
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.SolutionPath))
+ {
+ var solution = Path.GetFullPath(options.SolutionPath);
+ if (fileSystem.FileExists(solution) && SolutionReader.IsSolution(solution))
+ {
+ var content = fileSystem.ReadAllTextAsync(solution).GetAwaiter().GetResult();
+ return SolutionReader
+ .ReadProjects(solution, content)
+ .Where(fileSystem.FileExists)
+ .Where(p => kinds.Contains(ProjectKindExtensions.FromPath(p)))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ if (fileSystem.DirectoryExists(solution))
+ {
+ return ScanDirectory(solution, options, kinds);
+ }
+
+ return [];
+ }
+
+ return ScanDirectory(Path.GetFullPath(options.RootDirectory), options, kinds);
+ }
+
+ private IReadOnlyList ScanDirectory(
+ string root,
+ DiscoveryOptions options,
+ HashSet kinds
+ )
+ {
+ if (!fileSystem.DirectoryExists(root))
+ {
+ return [];
+ }
+
+ var excludeRegex = options.CompileExcludeRegex();
+ var results = new List();
+
+ foreach (var path in fileSystem.EnumerateFiles(root, "*", options.Recursive))
+ {
+ var kind = ProjectKindExtensions.FromPath(path);
+ if (!kinds.Contains(kind))
+ {
+ continue;
+ }
+
+ if (IsExcluded(root, path, options, excludeRegex))
+ {
+ continue;
+ }
+
+ results.Add(path);
+ }
+
+ return results;
+ }
+
+ internal static bool IsExcluded(
+ string root,
+ string path,
+ DiscoveryOptions options,
+ System.Text.RegularExpressions.Regex? excludeRegex
+ )
+ {
+ var relative = Path.GetRelativePath(root, path);
+ var segments = relative.Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries
+ );
+
+ if (options.ApplyDefaultExclusions)
+ {
+ // The final segment is the file name itself and must not be matched against directory names.
+ for (var i = 0; i < segments.Length - 1; i++)
+ {
+ if (
+ DiscoveryOptions.DefaultExcludedDirectories.Contains(
+ segments[i],
+ StringComparer.OrdinalIgnoreCase
+ )
+ )
+ {
+ return true;
+ }
+ }
+ }
+
+ if (excludeRegex is null)
+ {
+ return false;
+ }
+
+ return excludeRegex.IsMatch(relative.Replace(Path.DirectorySeparatorChar, '/'))
+ || segments.Any(excludeRegex.IsMatch);
+ }
+}
diff --git a/CentralConfigGenerator.Core/Discovery/SolutionReader.cs b/CentralConfigGenerator.Core/Discovery/SolutionReader.cs
new file mode 100644
index 0000000..2f2dfb3
--- /dev/null
+++ b/CentralConfigGenerator.Core/Discovery/SolutionReader.cs
@@ -0,0 +1,130 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+
+namespace CentralConfigGenerator.Core.Discovery;
+
+///
+/// Reads project paths out of .sln, .slnx and .slnf files.
+///
+public static partial class SolutionReader
+{
+ public static bool IsSolution(string path) =>
+ Path.GetExtension(path).ToLowerInvariant() is ".sln" or ".slnx" or ".slnf";
+
+ public static IReadOnlyList ReadProjects(string solutionPath, string content)
+ {
+ return Path.GetExtension(solutionPath).ToLowerInvariant() switch
+ {
+ ".slnx" => ReadSlnx(solutionPath, content),
+ ".slnf" => ReadSlnf(solutionPath, content),
+ _ => ReadSln(solutionPath, content),
+ };
+ }
+
+ private static IReadOnlyList ReadSln(string solutionPath, string content)
+ {
+ var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? ".";
+ var results = new List();
+
+ foreach (Match match in ProjectLineRegex().Matches(content))
+ {
+ var relative = match.Groups["path"].Value.Trim();
+ if (relative.Length == 0 || !HasProjectExtension(relative))
+ {
+ continue;
+ }
+
+ results.Add(Normalize(root, relative));
+ }
+
+ return results;
+ }
+
+ private static IReadOnlyList ReadSlnx(string solutionPath, string content)
+ {
+ var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? ".";
+ var results = new List();
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Parse(content);
+ }
+ catch (System.Xml.XmlException)
+ {
+ return results;
+ }
+
+ foreach (var element in document.Descendants("Project"))
+ {
+ var relative = element.Attribute("Path")?.Value;
+ if (string.IsNullOrWhiteSpace(relative) || !HasProjectExtension(relative))
+ {
+ continue;
+ }
+
+ results.Add(Normalize(root, relative));
+ }
+
+ return results;
+ }
+
+ private static IReadOnlyList ReadSlnf(string solutionPath, string content)
+ {
+ var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? ".";
+ var results = new List();
+
+ try
+ {
+ using var document = JsonDocument.Parse(content);
+ if (!document.RootElement.TryGetProperty("solution", out var solution))
+ {
+ return results;
+ }
+
+ if (solution.TryGetProperty("path", out var slnPath) && slnPath.GetString() is { } sln)
+ {
+ root = Path.GetDirectoryName(Path.GetFullPath(Normalize(root, sln))) ?? root;
+ }
+
+ if (!solution.TryGetProperty("projects", out var projects))
+ {
+ return results;
+ }
+
+ foreach (var project in projects.EnumerateArray())
+ {
+ if (project.GetString() is { } relative && HasProjectExtension(relative))
+ {
+ results.Add(Normalize(root, relative));
+ }
+ }
+ }
+ catch (JsonException)
+ {
+ // Malformed filter files simply yield no projects.
+ }
+
+ return results;
+ }
+
+ private static bool HasProjectExtension(string path) =>
+ Path.GetExtension(path).ToLowerInvariant() is ".csproj" or ".fsproj" or ".vbproj";
+
+ private static string Normalize(string root, string relative)
+ {
+ var normalized = relative.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+
+ return Path.GetFullPath(
+ Path.IsPathRooted(normalized) ? normalized : Path.Combine(root, normalized)
+ );
+ }
+
+ [GeneratedRegex(
+ "^Project\\(\"\\{[^}]+\\}\"\\)\\s*=\\s*\"[^\"]*\"\\s*,\\s*\"(?[^\"]+)\"",
+ RegexOptions.Multiline | RegexOptions.CultureInvariant
+ )]
+ private static partial Regex ProjectLineRegex();
+}
diff --git a/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs b/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs
new file mode 100644
index 0000000..e2e99e2
--- /dev/null
+++ b/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs
@@ -0,0 +1,116 @@
+using System.Text;
+using System.Xml.Linq;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Generators;
+
+/// Renders and merges Directory.Build.props from a set of hoisted properties.
+public static class BuildPropsWriter
+{
+ public const string HeaderComment =
+ " Generated by CentralConfigGenerator. Properties shared by every project in this tree. ";
+
+ public static string Create(
+ IReadOnlyDictionary properties,
+ IReadOnlyList? order = null,
+ bool includeHeaderComment = true,
+ string indent = " "
+ )
+ {
+ var builder = new StringBuilder();
+ builder.Append("\n");
+
+ if (includeHeaderComment)
+ {
+ builder.Append(indent).Append("\n");
+ }
+
+ builder.Append(indent).Append("\n");
+
+ foreach (var (name, value) in Order(properties, order))
+ {
+ builder
+ .Append(indent)
+ .Append(indent)
+ .Append('<')
+ .Append(name)
+ .Append('>')
+ .Append(Escape(value))
+ .Append("")
+ .Append(name)
+ .Append(">\n");
+ }
+
+ builder.Append(indent).Append("\n");
+ builder.Append("\n");
+ return builder.ToString();
+ }
+
+ public static string Merge(
+ string existingContent,
+ IReadOnlyDictionary properties,
+ IReadOnlyList? order = null,
+ string indent = " "
+ )
+ {
+ if (
+ string.IsNullOrWhiteSpace(existingContent)
+ || !MsBuildXml.TryParse(existingContent, out var document, out _)
+ || document.Root is null
+ )
+ {
+ return Create(properties, order);
+ }
+
+ var root = document.Root;
+ var ns = root.Name.Namespace;
+
+ var propertyGroup = MsBuildXml.Elements(root, "PropertyGroup").FirstOrDefault();
+ if (propertyGroup is null)
+ {
+ propertyGroup = new XElement(ns + "PropertyGroup");
+ root.AddFirst(new XText("\n" + indent), propertyGroup);
+ }
+
+ foreach (var (name, value) in Order(properties, order))
+ {
+ var existing = MsBuildXml.Descendants(root, name).FirstOrDefault();
+ if (existing is not null)
+ {
+ existing.Value = value;
+ continue;
+ }
+
+ propertyGroup.Add(new XText("\n" + indent + indent), new XElement(ns + name, value));
+ }
+
+ if (propertyGroup.LastNode is XElement)
+ {
+ propertyGroup.Add(new XText("\n" + indent));
+ }
+
+ return MsBuildXml.ToString(document);
+ }
+
+ private static IEnumerable> Order(
+ IReadOnlyDictionary properties,
+ IReadOnlyList? order
+ )
+ {
+ if (order is null || order.Count == 0)
+ {
+ return properties.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase);
+ }
+
+ var rank = order
+ .Select((name, index) => (name, index))
+ .ToDictionary(x => x.name, x => x.index, StringComparer.OrdinalIgnoreCase);
+
+ return properties
+ .OrderBy(p => rank.TryGetValue(p.Key, out var index) ? index : int.MaxValue)
+ .ThenBy(p => p.Key, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static string Escape(string value) =>
+ value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
+}
diff --git a/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs b/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs
new file mode 100644
index 0000000..07a3b3d
--- /dev/null
+++ b/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs
@@ -0,0 +1,171 @@
+using System.Xml.Linq;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Generators;
+
+/// Targeted edits to an existing Directory.Packages.props file.
+public static class PackagesPropsEditor
+{
+ /// Sets (or adds) the version for one package. Returns null when nothing changed.
+ public static string? SetVersion(string content, string packageId, string version, string indent = " ")
+ {
+ if (!MsBuildXml.TryParse(content, out var document, out _) || document.Root is null)
+ {
+ return null;
+ }
+
+ var element = FindEntry(document, packageId);
+
+ if (element is not null)
+ {
+ var attribute = MsBuildXml.Attribute(element, "Version");
+ if (attribute is not null)
+ {
+ if (string.Equals(attribute.Value, version, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ attribute.Value = version;
+ return MsBuildXml.ToString(document);
+ }
+
+ var child = MsBuildXml.Elements(element, "Version").FirstOrDefault();
+ if (child is not null)
+ {
+ if (string.Equals(child.Value.Trim(), version, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ child.Value = version;
+ }
+ else
+ {
+ element.SetAttributeValue("Version", version);
+ }
+
+ return MsBuildXml.ToString(document);
+ }
+
+ var root = document.Root;
+ var ns = root.Name.Namespace;
+
+ var itemGroup =
+ MsBuildXml
+ .Elements(root, "ItemGroup")
+ .LastOrDefault(g => MsBuildXml.Elements(g, "PackageVersion").Any());
+
+ if (itemGroup is null)
+ {
+ itemGroup = new XElement(ns + "ItemGroup");
+ root.Add(new XText("\n" + indent), itemGroup, new XText("\n"));
+ }
+
+ itemGroup.Add(
+ new XText("\n" + indent + indent),
+ new XElement(
+ ns + "PackageVersion",
+ new XAttribute("Include", packageId),
+ new XAttribute("Version", version)
+ )
+ );
+
+ if (itemGroup.LastNode is XElement)
+ {
+ itemGroup.Add(new XText("\n" + indent));
+ }
+
+ return MsBuildXml.ToString(document);
+ }
+
+ /// Removes a package's central version entry.
+ public static string? RemoveEntry(string content, string packageId)
+ {
+ if (!MsBuildXml.TryParse(content, out var document, out _))
+ {
+ return null;
+ }
+
+ var element = FindEntry(document, packageId);
+ if (element is null)
+ {
+ return null;
+ }
+
+ var parent = element.Parent;
+ MsBuildXml.RemoveWithWhitespace(element);
+ MsBuildXml.RemoveIfEmpty(parent);
+
+ return MsBuildXml.ToString(document);
+ }
+
+ /// Rewrites a package id so its casing matches the canonical spelling.
+ public static string? NormalizeCasing(string content, string canonicalId)
+ {
+ if (!MsBuildXml.TryParse(content, out var document, out _))
+ {
+ return null;
+ }
+
+ var changed = false;
+
+ foreach (var element in MsBuildXml.Descendants(document, "PackageVersion"))
+ {
+ var attribute = MsBuildXml.Attribute(element, "Include");
+ if (
+ attribute is null
+ || !string.Equals(attribute.Value, canonicalId, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(attribute.Value, canonicalId, StringComparison.Ordinal)
+ )
+ {
+ continue;
+ }
+
+ attribute.Value = canonicalId;
+ changed = true;
+ }
+
+ return changed ? MsBuildXml.ToString(document) : null;
+ }
+
+ /// Removes duplicate PackageVersion entries that differ only by casing.
+ public static string? DeduplicateEntries(string content)
+ {
+ if (!MsBuildXml.TryParse(content, out var document, out _))
+ {
+ return null;
+ }
+
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var changed = false;
+
+ foreach (var element in MsBuildXml.Descendants(document, "PackageVersion").ToList())
+ {
+ var id = MsBuildXml.AttributeValue(element, "Include");
+ if (string.IsNullOrWhiteSpace(id) || seen.Add(id))
+ {
+ continue;
+ }
+
+ var parent = element.Parent;
+ MsBuildXml.RemoveWithWhitespace(element);
+ MsBuildXml.RemoveIfEmpty(parent);
+ changed = true;
+ }
+
+ return changed ? MsBuildXml.ToString(document) : null;
+ }
+
+ private static XElement? FindEntry(XDocument document, string packageId) =>
+ MsBuildXml
+ .Descendants(document, "PackageVersion")
+ .FirstOrDefault(e =>
+ string.Equals(
+ MsBuildXml.AttributeValue(e, "Include")
+ ?? MsBuildXml.AttributeValue(e, "Update"),
+ packageId,
+ StringComparison.OrdinalIgnoreCase
+ )
+ );
+}
diff --git a/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs b/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs
new file mode 100644
index 0000000..4cc1c18
--- /dev/null
+++ b/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs
@@ -0,0 +1,258 @@
+using System.Text;
+using System.Xml.Linq;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Generators;
+
+/// Options for rendering a Directory.Packages.props file.
+public sealed record PackagesPropsOptions
+{
+ public bool TransitivePinning { get; init; }
+
+ /// Packages emitted as GlobalPackageReference instead of PackageVersion.
+ public IReadOnlyCollection GlobalPackages { get; init; } = [];
+
+ public bool IncludeHeaderComment { get; init; } = true;
+
+ public string Indent { get; init; } = " ";
+}
+
+///
+/// Renders and merges Directory.Packages.props. Merging preserves everything already in the file
+/// — comments, conditions, item-group layout — and only touches the entries it must.
+///
+public static class PackagesPropsWriter
+{
+ public const string HeaderComment =
+ " Generated by CentralConfigGenerator. Package versions are managed centrally. ";
+
+ public static string Create(
+ IReadOnlyDictionary versions,
+ PackagesPropsOptions? options = null
+ )
+ {
+ options ??= new PackagesPropsOptions();
+ var globals = new HashSet(options.GlobalPackages, StringComparer.OrdinalIgnoreCase);
+
+ var builder = new StringBuilder();
+ builder.Append("\n");
+
+ if (options.IncludeHeaderComment)
+ {
+ builder.Append(options.Indent).Append("\n");
+ }
+
+ builder.Append(options.Indent).Append("\n");
+ builder
+ .Append(options.Indent)
+ .Append(options.Indent)
+ .Append("true\n");
+
+ if (options.TransitivePinning)
+ {
+ builder
+ .Append(options.Indent)
+ .Append(options.Indent)
+ .Append(
+ "true\n"
+ );
+ }
+
+ builder.Append(options.Indent).Append("\n");
+
+ var ordered = versions
+ .OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var globalEntries = ordered.Where(p => globals.Contains(p.Key)).ToList();
+ var normalEntries = ordered.Where(p => !globals.Contains(p.Key)).ToList();
+
+ if (normalEntries.Count > 0)
+ {
+ builder.Append(options.Indent).Append("\n");
+ foreach (var (id, version) in normalEntries)
+ {
+ builder
+ .Append(options.Indent)
+ .Append(options.Indent)
+ .Append("\n");
+ }
+
+ builder.Append(options.Indent).Append("\n");
+ }
+
+ if (globalEntries.Count > 0)
+ {
+ builder.Append(options.Indent).Append("\n");
+ foreach (var (id, version) in globalEntries)
+ {
+ builder
+ .Append(options.Indent)
+ .Append(options.Indent)
+ .Append("\n");
+ }
+
+ builder.Append(options.Indent).Append("\n");
+ }
+
+ builder.Append("\n");
+ return builder.ToString();
+ }
+
+ ///
+ /// Merges resolved versions into an existing props file, adding what is missing and updating
+ /// what changed, without reformatting untouched regions.
+ ///
+ public static string Merge(
+ string existingContent,
+ IReadOnlyDictionary versions,
+ PackagesPropsOptions? options = null
+ )
+ {
+ options ??= new PackagesPropsOptions();
+
+ if (
+ string.IsNullOrWhiteSpace(existingContent)
+ || !MsBuildXml.TryParse(existingContent, out var document, out _)
+ || document.Root is null
+ )
+ {
+ return Create(versions, options);
+ }
+
+ var root = document.Root;
+ var ns = root.Name.Namespace;
+
+ EnsureProperty(root, ns, "ManagePackageVersionsCentrally", "true", options.Indent);
+ if (options.TransitivePinning)
+ {
+ EnsureProperty(
+ root,
+ ns,
+ "CentralPackageTransitivePinningEnabled",
+ "true",
+ options.Indent
+ );
+ }
+
+ var existing = MsBuildXml
+ .Descendants(document, "PackageVersion")
+ .ToDictionary(
+ e =>
+ MsBuildXml.AttributeValue(e, "Include")
+ ?? MsBuildXml.AttributeValue(e, "Update")
+ ?? string.Empty,
+ e => e,
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ var missing = new List>();
+
+ foreach (var pair in versions.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ if (!existing.TryGetValue(pair.Key, out var element))
+ {
+ missing.Add(pair);
+ continue;
+ }
+
+ var attribute = MsBuildXml.Attribute(element, "Version");
+ if (attribute is not null)
+ {
+ if (!string.Equals(attribute.Value, pair.Value, StringComparison.OrdinalIgnoreCase))
+ {
+ attribute.Value = pair.Value;
+ }
+ }
+ else
+ {
+ var child = MsBuildXml.Elements(element, "Version").FirstOrDefault();
+ if (child is not null)
+ {
+ child.Value = pair.Value;
+ }
+ else
+ {
+ element.SetAttributeValue("Version", pair.Value);
+ }
+ }
+ }
+
+ if (missing.Count > 0)
+ {
+ var itemGroup = MsBuildXml
+ .Elements(root, "ItemGroup")
+ .LastOrDefault(g => MsBuildXml.Elements(g, "PackageVersion").Any());
+
+ if (itemGroup is null)
+ {
+ itemGroup = new XElement(ns + "ItemGroup");
+ root.Add(new XText("\n" + options.Indent), itemGroup, new XText("\n"));
+ }
+
+ var indent = options.Indent + options.Indent;
+
+ foreach (var (id, version) in missing)
+ {
+ var element = new XElement(
+ ns + "PackageVersion",
+ new XAttribute("Include", id),
+ new XAttribute("Version", version)
+ );
+
+ itemGroup.Add(new XText("\n" + indent), element);
+ }
+
+ if (itemGroup.LastNode is not XText)
+ {
+ itemGroup.Add(new XText("\n" + options.Indent));
+ }
+ }
+
+ return MsBuildXml.ToString(document);
+ }
+
+ private static void EnsureProperty(
+ XElement root,
+ XNamespace ns,
+ string name,
+ string value,
+ string indent
+ )
+ {
+ var existing = MsBuildXml.Descendants(root, name).FirstOrDefault();
+ if (existing is not null)
+ {
+ existing.Value = value;
+ return;
+ }
+
+ var propertyGroup = MsBuildXml.Elements(root, "PropertyGroup").FirstOrDefault();
+ if (propertyGroup is null)
+ {
+ propertyGroup = new XElement(ns + "PropertyGroup");
+ root.AddFirst(new XText("\n" + indent), propertyGroup);
+ }
+
+ propertyGroup.Add(new XText("\n" + indent + indent), new XElement(ns + name, value));
+ if (propertyGroup.LastNode is not XText text || text.Value.Contains(name))
+ {
+ propertyGroup.Add(new XText("\n" + indent));
+ }
+ }
+
+ private static string Escape(string value) =>
+ value
+ .Replace("&", "&")
+ .Replace("<", "<")
+ .Replace(">", ">")
+ .Replace("\"", """);
+}
diff --git a/CentralConfigGenerator.Core/IO/EncodingDetector.cs b/CentralConfigGenerator.Core/IO/EncodingDetector.cs
new file mode 100644
index 0000000..e05b36f
--- /dev/null
+++ b/CentralConfigGenerator.Core/IO/EncodingDetector.cs
@@ -0,0 +1,103 @@
+using System.Text;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.IO;
+
+///
+/// Detects the encoding, BOM presence and line endings of a file from its raw bytes.
+///
+public static class EncodingDetector
+{
+ public static (string Content, TextFileFormat Format) Decode(byte[] bytes)
+ {
+ var (encoding, bomLength) = DetectEncoding(bytes);
+ var content = encoding.GetString(bytes, bomLength, bytes.Length - bomLength);
+
+ var format = new TextFileFormat
+ {
+ Encoding = encoding,
+ HasByteOrderMark = bomLength > 0,
+ LineEnding = LineEndingStyleExtensions.Detect(content),
+ EndsWithNewLine = content.EndsWith('\n') || content.EndsWith('\r'),
+ };
+
+ return (content, format);
+ }
+
+ public static byte[] Encode(string content, TextFileFormat format)
+ {
+ var text = LineEndingStyleExtensions.Normalize(content, format.LineEnding);
+ var encoding = format.ToWriteEncoding();
+ var preamble = format.HasByteOrderMark ? encoding.GetPreamble() : [];
+ var body = encoding.GetBytes(text);
+
+ if (preamble.Length == 0)
+ {
+ return body;
+ }
+
+ var result = new byte[preamble.Length + body.Length];
+ preamble.CopyTo(result, 0);
+ body.CopyTo(result, preamble.Length);
+ return result;
+ }
+
+ ///
+ /// Resolves an IANA web name (for example utf-8 or windows-1252) to an encoding.
+ ///
+ public static Encoding? Resolve(string? webName)
+ {
+ if (string.IsNullOrWhiteSpace(webName))
+ {
+ return null;
+ }
+
+ try
+ {
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ }
+ catch (InvalidOperationException)
+ {
+ // Provider already registered.
+ }
+
+ try
+ {
+ return Encoding.GetEncoding(webName.Trim());
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
+ private static (Encoding Encoding, int BomLength) DetectEncoding(byte[] bytes)
+ {
+ if (bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0 && bytes[3] == 0)
+ {
+ return (new UTF32Encoding(false, true), 4);
+ }
+
+ if (bytes.Length >= 4 && bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0xFE && bytes[3] == 0xFF)
+ {
+ return (new UTF32Encoding(true, true), 4);
+ }
+
+ if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
+ {
+ return (new UTF8Encoding(true), 3);
+ }
+
+ if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
+ {
+ return (new UnicodeEncoding(false, true), 2);
+ }
+
+ if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
+ {
+ return (new UnicodeEncoding(true, true), 2);
+ }
+
+ return (new UTF8Encoding(false), 0);
+ }
+}
diff --git a/CentralConfigGenerator.Core/IO/IFileSystem.cs b/CentralConfigGenerator.Core/IO/IFileSystem.cs
new file mode 100644
index 0000000..dc44d5b
--- /dev/null
+++ b/CentralConfigGenerator.Core/IO/IFileSystem.cs
@@ -0,0 +1,114 @@
+using System.Text;
+
+namespace CentralConfigGenerator.Core.IO;
+
+///
+/// Thin file-system seam so every service in the core library stays testable.
+///
+public interface IFileSystem
+{
+ bool FileExists(string path);
+
+ bool DirectoryExists(string path);
+
+ void CreateDirectory(string path);
+
+ void DeleteFile(string path);
+
+ void DeleteDirectory(string path, bool recursive);
+
+ IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive);
+
+ IReadOnlyList EnumerateDirectories(string path);
+
+ Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default);
+
+ Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default);
+
+ Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default);
+
+ Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default);
+
+ void CopyFile(string source, string destination, bool overwrite);
+
+ DateTimeOffset GetLastWriteTime(string path);
+
+ long GetFileSize(string path);
+}
+
+/// Default backed by .
+public sealed class PhysicalFileSystem : IFileSystem
+{
+ public bool FileExists(string path) => File.Exists(path);
+
+ public bool DirectoryExists(string path) => Directory.Exists(path);
+
+ public void CreateDirectory(string path) => Directory.CreateDirectory(path);
+
+ public void DeleteFile(string path) => File.Delete(path);
+
+ public void DeleteDirectory(string path, bool recursive) => Directory.Delete(path, recursive);
+
+ public IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive) =>
+ Directory
+ .EnumerateFiles(
+ path,
+ searchPattern,
+ recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly
+ )
+ .ToList();
+
+ public IReadOnlyList EnumerateDirectories(string path) =>
+ Directory.EnumerateDirectories(path).ToList();
+
+ public Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default) =>
+ File.ReadAllBytesAsync(path, cancellationToken);
+
+ public async Task WriteAllBytesAsync(
+ string path,
+ byte[] bytes,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await File.WriteAllBytesAsync(path, bytes, cancellationToken);
+ }
+
+ public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) =>
+ File.ReadAllTextAsync(path, cancellationToken);
+
+ public async Task WriteAllTextAsync(
+ string path,
+ string contents,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await File.WriteAllTextAsync(path, contents, new UTF8Encoding(false), cancellationToken);
+ }
+
+ public void CopyFile(string source, string destination, bool overwrite)
+ {
+ var directory = Path.GetDirectoryName(destination);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.Copy(source, destination, overwrite);
+ }
+
+ public DateTimeOffset GetLastWriteTime(string path) => new FileInfo(path).LastWriteTimeUtc;
+
+ public long GetFileSize(string path) => new FileInfo(path).Length;
+}
diff --git a/CentralConfigGenerator.Core/IO/TextFileFormat.cs b/CentralConfigGenerator.Core/IO/TextFileFormat.cs
new file mode 100644
index 0000000..80d6e94
--- /dev/null
+++ b/CentralConfigGenerator.Core/IO/TextFileFormat.cs
@@ -0,0 +1,44 @@
+using System.Text;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.IO;
+
+///
+/// Captures the on-disk representation of a text file so it can be rewritten byte-faithfully.
+///
+public sealed record TextFileFormat
+{
+ public static readonly TextFileFormat Default = new()
+ {
+ Encoding = new UTF8Encoding(false),
+ HasByteOrderMark = false,
+ LineEnding = LineEndingStyle.Preserve,
+ EndsWithNewLine = true,
+ };
+
+ public required Encoding Encoding { get; init; }
+
+ public required bool HasByteOrderMark { get; init; }
+
+ public required LineEndingStyle LineEnding { get; init; }
+
+ public required bool EndsWithNewLine { get; init; }
+
+ public Encoding ToWriteEncoding()
+ {
+ if (Encoding is UTF8Encoding)
+ {
+ return new UTF8Encoding(HasByteOrderMark);
+ }
+
+ if (HasByteOrderMark)
+ {
+ return Encoding;
+ }
+
+ // Strip the preamble for encodings that emit one by default (UTF-16/32).
+ return Encoding.GetPreamble().Length == 0
+ ? Encoding
+ : (Encoding)new UTF8Encoding(false);
+ }
+}
diff --git a/CentralConfigGenerator.Core/Migration/IMigrationService.cs b/CentralConfigGenerator.Core/Migration/IMigrationService.cs
new file mode 100644
index 0000000..1e73b82
--- /dev/null
+++ b/CentralConfigGenerator.Core/Migration/IMigrationService.cs
@@ -0,0 +1,26 @@
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Migration;
+
+public interface IMigrationService
+{
+ /// Plans a migration to central package management without touching disk.
+ Task PlanAsync(
+ MigrationOptions options,
+ Func? conflictPrompt = null,
+ CancellationToken cancellationToken = default
+ );
+
+ /// Plans the reverse migration: central versions pushed back into project files.
+ Task PlanRevertAsync(
+ MigrationOptions options,
+ CancellationToken cancellationToken = default
+ );
+
+ /// Writes a plan to disk, taking a backup first unless disabled.
+ Task ApplyAsync(
+ MigrationPlan plan,
+ MigrationOptions options,
+ CancellationToken cancellationToken = default
+ );
+}
diff --git a/CentralConfigGenerator.Core/Migration/MigrationOptions.cs b/CentralConfigGenerator.Core/Migration/MigrationOptions.cs
new file mode 100644
index 0000000..008b010
--- /dev/null
+++ b/CentralConfigGenerator.Core/Migration/MigrationOptions.cs
@@ -0,0 +1,110 @@
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Services;
+using NuGet.Versioning;
+
+namespace CentralConfigGenerator.Core.Migration;
+
+/// Everything that shapes a migration to (or away from) central package management.
+public sealed record MigrationOptions
+{
+ public string RootDirectory { get; init; } = Directory.GetCurrentDirectory();
+
+ public string? SolutionPath { get; init; }
+
+ public string? ProjectPath { get; init; }
+
+ /// Where Directory.Packages.props is written. Defaults to .
+ public string? OutputDirectory { get; init; }
+
+ public string? ExcludePattern { get; init; }
+
+ /// Merge into an existing Directory.Packages.props instead of replacing it.
+ public bool Merge { get; init; }
+
+ /// Leave inline Version attributes in place (analysis-only migrations).
+ public bool KeepInlineVersions { get; init; }
+
+ public VersionResolutionStrategy ConflictStrategy { get; init; } =
+ VersionResolutionStrategy.Highest;
+
+ /// Fail the run instead of auto-resolving when projects disagree on a version.
+ public bool FailOnConflict { get; init; }
+
+ public bool IgnorePrerelease { get; init; }
+
+ public bool TransitivePinning { get; init; }
+
+ public VersionComparison VersionComparison { get; init; } = VersionComparison.VersionRelease;
+
+ public LineEndingStyle LineEnding { get; init; } = LineEndingStyle.Preserve;
+
+ public string? EncodingName { get; init; }
+
+ public bool CreateBackup { get; init; } = true;
+
+ public string? BackupDirectory { get; init; }
+
+ public bool AddGitIgnore { get; init; }
+
+ /// Also promote shared project properties into Directory.Build.props.
+ public bool UnifyProperties { get; init; }
+
+ public IReadOnlyList BuildProperties { get; init; } =
+ BuildPropertyDefaults.Recommended;
+
+ public IReadOnlyList IncludedKinds { get; init; } =
+ [ProjectKind.CSharp, ProjectKind.FSharp, ProjectKind.VisualBasic];
+
+ public string ResolvedOutputDirectory =>
+ Path.GetFullPath(
+ string.IsNullOrWhiteSpace(OutputDirectory) ? RootDirectory : OutputDirectory
+ );
+
+ public string PackagesPropsPath =>
+ Path.Combine(ResolvedOutputDirectory, "Directory.Packages.props");
+
+ public string BuildPropsPath => Path.Combine(ResolvedOutputDirectory, "Directory.Build.props");
+}
+
+/// Property names that are safe and useful to hoist into Directory.Build.props.
+public static class BuildPropertyDefaults
+{
+ public static readonly IReadOnlyList Recommended =
+ [
+ "TargetFramework",
+ "TargetFrameworks",
+ "ImplicitUsings",
+ "Nullable",
+ "LangVersion",
+ "AnalysisLevel",
+ "EnforceCodeStyleInBuild",
+ "TreatWarningsAsErrors",
+ "GenerateDocumentationFile",
+ "InvariantGlobalization",
+ "Authors",
+ "Company",
+ "Product",
+ "Copyright",
+ "PackageLicenseExpression",
+ "PackageProjectUrl",
+ "RepositoryUrl",
+ "RepositoryType",
+ "Version",
+ "VersionPrefix",
+ ];
+
+ /// Properties that must never be hoisted because they are project-identity specific.
+ public static readonly IReadOnlyList NeverHoist =
+ [
+ "AssemblyName",
+ "RootNamespace",
+ "OutputType",
+ "ProjectGuid",
+ "UserSecretsId",
+ "StartupObject",
+ "PackageId",
+ "ApplicationIcon",
+ "TargetFrameworkIdentifier",
+ "TargetFrameworkVersion",
+ ];
+}
diff --git a/CentralConfigGenerator.Core/Migration/MigrationPlan.cs b/CentralConfigGenerator.Core/Migration/MigrationPlan.cs
new file mode 100644
index 0000000..6a3e476
--- /dev/null
+++ b/CentralConfigGenerator.Core/Migration/MigrationPlan.cs
@@ -0,0 +1,75 @@
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Models;
+
+namespace CentralConfigGenerator.Core.Migration;
+
+public enum FileChangeKind
+{
+ Created,
+ Modified,
+ Deleted,
+}
+
+/// A single pending file write, kept in memory so dry-run and diff cost nothing extra.
+public sealed record FileChange
+{
+ public required string Path { get; init; }
+
+ public required FileChangeKind Kind { get; init; }
+
+ public string OriginalContent { get; init; } = string.Empty;
+
+ public string NewContent { get; init; } = string.Empty;
+
+ public required IO.TextFileFormat Format { get; init; }
+
+ public string Diff(int context = 3) =>
+ UnifiedDiffFor(this, context);
+
+ private static string UnifiedDiffFor(FileChange change, int context) =>
+ Services.UnifiedDiff.Create(
+ change.OriginalContent,
+ change.NewContent,
+ change.Kind == FileChangeKind.Created ? "/dev/null" : change.Path,
+ change.Kind == FileChangeKind.Deleted ? "/dev/null" : change.Path,
+ context
+ );
+}
+
+/// A version disagreement between two or more projects.
+public sealed record PackageConflict
+{
+ public required string PackageId { get; init; }
+
+ public required IReadOnlyList References { get; init; }
+
+ public required string ResolvedVersion { get; init; }
+
+ public IEnumerable DistinctVersions =>
+ References
+ .Select(r => r.Version)
+ .Where(v => !string.IsNullOrEmpty(v))
+ .Select(v => v!)
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+}
+
+/// The complete, still-unapplied result of planning a migration.
+public sealed record MigrationPlan
+{
+ public required IReadOnlyList Changes { get; init; }
+
+ public required IReadOnlyDictionary ResolvedVersions { get; init; }
+
+ public required IReadOnlyList Conflicts { get; init; }
+
+ public required IReadOnlyList Findings { get; init; }
+
+ public required int ProjectCount { get; init; }
+
+ public int PackageCount => ResolvedVersions.Count;
+
+ public bool HasChanges => Changes.Count > 0;
+
+ public IReadOnlyList AffectedPaths =>
+ Changes.Select(c => c.Path).ToList();
+}
diff --git a/CentralConfigGenerator.Core/Migration/MigrationService.cs b/CentralConfigGenerator.Core/Migration/MigrationService.cs
new file mode 100644
index 0000000..dcfac6f
--- /dev/null
+++ b/CentralConfigGenerator.Core/Migration/MigrationService.cs
@@ -0,0 +1,452 @@
+using CentralConfigGenerator.Core.Analysis;
+using CentralConfigGenerator.Core.Discovery;
+using CentralConfigGenerator.Core.Generators;
+using CentralConfigGenerator.Core.IO;
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Services;
+using CentralConfigGenerator.Core.Services.Abstractions;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Migration;
+
+///
+/// Plans and applies the migration between inline package versions and central package
+/// management. Planning never writes to disk, which is what makes dry-run, diff and rollback
+/// all fall out of the same code path.
+///
+public sealed class MigrationService(
+ IProjectDiscoveryService discovery,
+ IFileSystem fileSystem,
+ IBackupService backupService,
+ VersionSelector versionSelector
+) : IMigrationService
+{
+ public async Task PlanAsync(
+ MigrationOptions options,
+ Func? conflictPrompt = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var projects = await DiscoverAsync(options, cancellationToken);
+ if (projects.Count == 0)
+ {
+ return EmptyPlan();
+ }
+
+ var references = projects
+ .SelectMany(PackageReferenceReader.Read)
+ .Where(r => !string.IsNullOrWhiteSpace(r.Version))
+ .ToList();
+
+ var grouped = references
+ .GroupBy(r => r.PackageId, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var conflicts = new List();
+ var findings = new List();
+
+ foreach (var group in grouped)
+ {
+ var versions = group
+ .Select(r => r.Version!)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ string chosen;
+
+ if (versions.Count == 1)
+ {
+ chosen = versions[0];
+ }
+ else
+ {
+ var pending = new PackageConflict
+ {
+ PackageId = group.Key,
+ References = group.ToList(),
+ ResolvedVersion = string.Empty,
+ };
+
+ if (options.FailOnConflict && conflictPrompt is null)
+ {
+ throw new VersionConflictException(group.Key, versions);
+ }
+
+ chosen =
+ conflictPrompt?.Invoke(pending)
+ ?? versionSelector.Select(
+ group.Key,
+ versions,
+ options.ConflictStrategy,
+ options.IgnorePrerelease,
+ options.VersionComparison
+ );
+
+ conflicts.Add(pending with { ResolvedVersion = chosen });
+
+ findings.Add(
+ new Finding
+ {
+ RuleId = RuleIds.VersionInconsistency,
+ Severity = Severity.Moderate,
+ PackageId = group.Key,
+ Version = chosen,
+ Message =
+ $"'{group.Key}' is referenced with {versions.Count} different versions ({string.Join(", ", versions)}).",
+ Recommendation = $"Unified on {chosen}.",
+ }
+ );
+ }
+
+ resolved[group.Key] = chosen;
+ }
+
+ var globals = references
+ .Where(r => r.IsGlobal)
+ .Select(r => r.PackageId)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var changes = new List();
+
+ changes.Add(
+ await BuildPackagesPropsChangeAsync(options, resolved, globals, cancellationToken)
+ );
+
+ if (!options.KeepInlineVersions)
+ {
+ foreach (var project in projects)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var updated = ProjectRewriter.StripVersions(project, out _);
+ if (updated is not null)
+ {
+ changes.Add(ToChange(project, updated));
+ }
+ }
+ }
+
+ if (options.UnifyProperties)
+ {
+ changes.AddRange(await BuildPropertyChangesAsync(options, projects, cancellationToken));
+ }
+
+ return new MigrationPlan
+ {
+ Changes = changes,
+ ResolvedVersions = resolved,
+ Conflicts = conflicts,
+ Findings = findings,
+ ProjectCount = projects.Count,
+ };
+ }
+
+ public async Task PlanRevertAsync(
+ MigrationOptions options,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var projects = await DiscoverAsync(options, cancellationToken);
+ if (projects.Count == 0)
+ {
+ return EmptyPlan();
+ }
+
+ var propsPath = options.PackagesPropsPath;
+ var centralVersions = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var findings = new List();
+
+ if (fileSystem.FileExists(propsPath))
+ {
+ var propsFile = await discovery.LoadAsync(propsPath, cancellationToken);
+ if (
+ propsFile is not null
+ && MsBuildXml.TryParse(propsFile.Content, out var propsDocument, out _)
+ )
+ {
+ foreach (var pair in PackageReferenceReader.ReadPackageVersions(propsDocument))
+ {
+ centralVersions[pair.Key] = pair.Value;
+ }
+ }
+ }
+
+ var changes = new List();
+
+ foreach (var project in projects)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var updated = ProjectRewriter.RestoreVersions(project, centralVersions, out var missing);
+
+ foreach (var packageId in missing)
+ {
+ findings.Add(
+ new Finding
+ {
+ RuleId = RuleIds.MissingCentralVersion,
+ Severity = Severity.High,
+ PackageId = packageId,
+ ProjectPath = project.Path,
+ Message =
+ $"'{packageId}' has no PackageVersion entry, so no version could be restored.",
+ Recommendation =
+ "Add the package to Directory.Packages.props or set the version manually.",
+ }
+ );
+ }
+
+ if (updated is not null)
+ {
+ changes.Add(ToChange(project, updated));
+ }
+ }
+
+ if (fileSystem.FileExists(propsPath))
+ {
+ var propsFile = await discovery.LoadAsync(propsPath, cancellationToken);
+ if (propsFile is not null)
+ {
+ changes.Add(
+ new FileChange
+ {
+ Path = propsPath,
+ Kind = FileChangeKind.Deleted,
+ OriginalContent = propsFile.Content,
+ NewContent = string.Empty,
+ Format = propsFile.Format,
+ }
+ );
+ }
+ }
+
+ return new MigrationPlan
+ {
+ Changes = changes,
+ ResolvedVersions = centralVersions,
+ Conflicts = [],
+ Findings = findings,
+ ProjectCount = projects.Count,
+ };
+ }
+
+ public async Task ApplyAsync(
+ MigrationPlan plan,
+ MigrationOptions options,
+ CancellationToken cancellationToken = default
+ )
+ {
+ BackupSet? backup = null;
+
+ if (options.CreateBackup && plan.HasChanges)
+ {
+ backup = await backupService.CreateAsync(
+ options.RootDirectory,
+ options.BackupDirectory ?? options.RootDirectory,
+ "migrate",
+ plan.AffectedPaths,
+ cancellationToken
+ );
+ }
+
+ if (options.AddGitIgnore)
+ {
+ await backupService.EnsureGitIgnoreAsync(
+ options.BackupDirectory ?? options.RootDirectory,
+ BackupService.DefaultBackupFolderName,
+ cancellationToken
+ );
+ }
+
+ foreach (var change in plan.Changes)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (change.Kind == FileChangeKind.Deleted)
+ {
+ if (fileSystem.FileExists(change.Path))
+ {
+ fileSystem.DeleteFile(change.Path);
+ }
+
+ continue;
+ }
+
+ var format = ApplyFormatOverrides(change.Format, options);
+ var bytes = EncodingDetector.Encode(change.NewContent, format);
+ await fileSystem.WriteAllBytesAsync(change.Path, bytes, cancellationToken);
+ }
+
+ return backup;
+ }
+
+ private static TextFileFormat ApplyFormatOverrides(
+ TextFileFormat format,
+ MigrationOptions options
+ )
+ {
+ var encoding = EncodingDetector.Resolve(options.EncodingName);
+
+ return format with
+ {
+ Encoding = encoding ?? format.Encoding,
+ HasByteOrderMark = encoding is not null
+ ? encoding.GetPreamble().Length > 0 && encoding is not System.Text.UTF8Encoding
+ : format.HasByteOrderMark,
+ LineEnding =
+ options.LineEnding == LineEndingStyle.Preserve
+ ? format.LineEnding
+ : options.LineEnding,
+ };
+ }
+
+ private async Task> DiscoverAsync(
+ MigrationOptions options,
+ CancellationToken cancellationToken
+ ) =>
+ await discovery.DiscoverAsync(
+ new DiscoveryOptions
+ {
+ RootDirectory = options.RootDirectory,
+ SolutionPath = options.SolutionPath,
+ ProjectPath = options.ProjectPath,
+ ExcludePattern = options.ExcludePattern,
+ IncludedKinds = options.IncludedKinds.ToList(),
+ },
+ cancellationToken
+ );
+
+ private async Task BuildPackagesPropsChangeAsync(
+ MigrationOptions options,
+ IReadOnlyDictionary resolved,
+ IReadOnlyCollection globals,
+ CancellationToken cancellationToken
+ )
+ {
+ var path = options.PackagesPropsPath;
+ var writerOptions = new PackagesPropsOptions
+ {
+ TransitivePinning = options.TransitivePinning,
+ GlobalPackages = globals,
+ };
+
+ if (fileSystem.FileExists(path))
+ {
+ var existing = await discovery.LoadAsync(path, cancellationToken);
+ var original = existing?.Content ?? string.Empty;
+
+ var content = options.Merge
+ ? PackagesPropsWriter.Merge(original, resolved, writerOptions)
+ : PackagesPropsWriter.Create(resolved, writerOptions);
+
+ return new FileChange
+ {
+ Path = path,
+ Kind = FileChangeKind.Modified,
+ OriginalContent = original,
+ NewContent = content,
+ Format = existing?.Format ?? TextFileFormat.Default,
+ };
+ }
+
+ return new FileChange
+ {
+ Path = path,
+ Kind = FileChangeKind.Created,
+ OriginalContent = string.Empty,
+ NewContent = PackagesPropsWriter.Create(resolved, writerOptions),
+ Format = TextFileFormat.Default,
+ };
+ }
+
+ private async Task> BuildPropertyChangesAsync(
+ MigrationOptions options,
+ IReadOnlyList projects,
+ CancellationToken cancellationToken
+ )
+ {
+ var hoisted = PropertyHoister.FindHoistableProperties(projects, options.BuildProperties);
+ if (hoisted.Count == 0)
+ {
+ return [];
+ }
+
+ var changes = new List();
+ var path = options.BuildPropsPath;
+
+ if (fileSystem.FileExists(path))
+ {
+ var existing = await discovery.LoadAsync(path, cancellationToken);
+ var original = existing?.Content ?? string.Empty;
+
+ changes.Add(
+ new FileChange
+ {
+ Path = path,
+ Kind = FileChangeKind.Modified,
+ OriginalContent = original,
+ NewContent = BuildPropsWriter.Merge(original, hoisted, options.BuildProperties),
+ Format = existing?.Format ?? TextFileFormat.Default,
+ }
+ );
+ }
+ else
+ {
+ changes.Add(
+ new FileChange
+ {
+ Path = path,
+ Kind = FileChangeKind.Created,
+ OriginalContent = string.Empty,
+ NewContent = BuildPropsWriter.Create(hoisted, options.BuildProperties),
+ Format = TextFileFormat.Default,
+ }
+ );
+ }
+
+ foreach (var project in projects)
+ {
+ var updated = ProjectRewriter.RemoveProperties(project, hoisted.Keys, hoisted);
+ if (updated is null)
+ {
+ continue;
+ }
+
+ var existingChange = changes.FirstOrDefault(c =>
+ string.Equals(c.Path, project.Path, StringComparison.OrdinalIgnoreCase)
+ );
+
+ if (existingChange is not null)
+ {
+ changes.Remove(existingChange);
+ }
+
+ changes.Add(ToChange(project, updated));
+ }
+
+ return changes;
+ }
+
+ private static FileChange ToChange(ProjectFile project, string updatedContent) =>
+ new()
+ {
+ Path = project.Path,
+ Kind = FileChangeKind.Modified,
+ OriginalContent = project.Content,
+ NewContent = updatedContent,
+ Format = project.Format,
+ };
+
+ private static MigrationPlan EmptyPlan() =>
+ new()
+ {
+ Changes = [],
+ ResolvedVersions = new Dictionary(),
+ Conflicts = [],
+ Findings = [],
+ ProjectCount = 0,
+ };
+}
diff --git a/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs b/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs
new file mode 100644
index 0000000..e6e4455
--- /dev/null
+++ b/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs
@@ -0,0 +1,328 @@
+using System.Xml.Linq;
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Migration;
+
+///
+/// Surgical edits to a single project file: stripping inline versions for a migration, putting
+/// them back for a revert, and removing properties that moved to Directory.Build.props.
+///
+public static class ProjectRewriter
+{
+ ///
+ /// Removes inline Version from every PackageReference.
+ /// Returns null when nothing changed or the file could not be parsed.
+ ///
+ public static string? StripVersions(ProjectFile projectFile, out IReadOnlyList touched)
+ {
+ touched = [];
+
+ if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _))
+ {
+ return null;
+ }
+
+ var changed = false;
+ var packages = new List();
+
+ foreach (var element in MsBuildXml.Descendants(document, "PackageReference"))
+ {
+ var id = MsBuildXml.AttributeValue(element, "Include")
+ ?? MsBuildXml.AttributeValue(element, "Update");
+
+ var attribute = MsBuildXml.Attribute(element, "Version");
+ if (attribute is not null)
+ {
+ attribute.Remove();
+ changed = true;
+ if (id is not null)
+ {
+ packages.Add(id);
+ }
+
+ continue;
+ }
+
+ var child = MsBuildXml.Elements(element, "Version").FirstOrDefault();
+ if (child is null)
+ {
+ continue;
+ }
+
+ MsBuildXml.RemoveWithWhitespace(child);
+ CollapseIfEmpty(element);
+ changed = true;
+ if (id is not null)
+ {
+ packages.Add(id);
+ }
+ }
+
+ touched = packages;
+ return changed ? MsBuildXml.ToString(document) : null;
+ }
+
+ /// Removes the inline version of one specific package from a project.
+ public static string? StripVersion(ProjectFile projectFile, string packageId)
+ {
+ if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _))
+ {
+ return null;
+ }
+
+ var changed = false;
+
+ foreach (var element in MsBuildXml.Descendants(document, "PackageReference"))
+ {
+ var id =
+ MsBuildXml.AttributeValue(element, "Include")
+ ?? MsBuildXml.AttributeValue(element, "Update");
+
+ if (!string.Equals(id, packageId, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var attribute = MsBuildXml.Attribute(element, "Version");
+ if (attribute is not null)
+ {
+ attribute.Remove();
+ changed = true;
+ continue;
+ }
+
+ var child = MsBuildXml.Elements(element, "Version").FirstOrDefault();
+ if (child is not null)
+ {
+ MsBuildXml.RemoveWithWhitespace(child);
+ CollapseIfEmpty(element);
+ changed = true;
+ }
+ }
+
+ return changed ? MsBuildXml.ToString(document) : null;
+ }
+
+ ///
+ /// Writes versions back onto every PackageReference that lacks one, using the supplied
+ /// central version map. Used by the revert command.
+ ///
+ public static string? RestoreVersions(
+ ProjectFile projectFile,
+ IReadOnlyDictionary centralVersions,
+ out IReadOnlyList unresolved
+ )
+ {
+ var missing = new List();
+ unresolved = missing;
+
+ if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _))
+ {
+ return null;
+ }
+
+ var changed = false;
+
+ foreach (var element in MsBuildXml.Descendants(document, "PackageReference"))
+ {
+ if (MsBuildXml.Attribute(element, "Version") is not null)
+ {
+ continue;
+ }
+
+ if (MsBuildXml.Elements(element, "Version").Any())
+ {
+ continue;
+ }
+
+ var id =
+ MsBuildXml.AttributeValue(element, "Include")
+ ?? MsBuildXml.AttributeValue(element, "Update");
+
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ continue;
+ }
+
+ if (!centralVersions.TryGetValue(id, out var version))
+ {
+ missing.Add(id);
+ continue;
+ }
+
+ InsertVersionAfterInclude(element, version);
+ changed = true;
+ }
+
+ return changed ? MsBuildXml.ToString(document) : null;
+ }
+
+ /// Removes the named properties from a project because they were hoisted upwards.
+ public static string? RemoveProperties(
+ ProjectFile projectFile,
+ IReadOnlyCollection propertyNames,
+ IReadOnlyDictionary? onlyWhenValueMatches = null
+ )
+ {
+ if (propertyNames.Count == 0)
+ {
+ return null;
+ }
+
+ if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _))
+ {
+ return null;
+ }
+
+ var names = new HashSet(propertyNames, StringComparer.OrdinalIgnoreCase);
+ var changed = false;
+
+ foreach (var group in MsBuildXml.Descendants(document, "PropertyGroup").ToList())
+ {
+ // Conditioned property groups are project-specific; leave them alone.
+ if (!string.IsNullOrWhiteSpace(MsBuildXml.AttributeValue(group, "Condition")))
+ {
+ continue;
+ }
+
+ foreach (var element in group.Elements().ToList())
+ {
+ if (!names.Contains(element.Name.LocalName))
+ {
+ continue;
+ }
+
+ if (
+ onlyWhenValueMatches is not null
+ && onlyWhenValueMatches.TryGetValue(element.Name.LocalName, out var expected)
+ && !string.Equals(element.Value.Trim(), expected, StringComparison.Ordinal)
+ )
+ {
+ continue;
+ }
+
+ MsBuildXml.RemoveWithWhitespace(element);
+ changed = true;
+ }
+
+ MsBuildXml.RemoveIfEmpty(group);
+ }
+
+ return changed ? MsBuildXml.ToString(document) : null;
+ }
+
+ /// Removes a duplicate PackageReference element for the given package id.
+ public static string? RemoveDuplicateReference(ProjectFile projectFile, string packageId)
+ {
+ if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _))
+ {
+ return null;
+ }
+
+ var matches = MsBuildXml
+ .Descendants(document, "PackageReference")
+ .Where(e =>
+ string.Equals(
+ MsBuildXml.AttributeValue(e, "Include"),
+ packageId,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ .ToList();
+
+ if (matches.Count < 2)
+ {
+ return null;
+ }
+
+ foreach (var duplicate in matches.Skip(1))
+ {
+ var parent = duplicate.Parent;
+ MsBuildXml.RemoveWithWhitespace(duplicate);
+ MsBuildXml.RemoveIfEmpty(parent);
+ }
+
+ return MsBuildXml.ToString(document);
+ }
+
+ /// Rewrites a package id so its casing matches the canonical spelling from the feed.
+ public static string? NormalizeCasing(ProjectFile projectFile, string canonicalId)
+ {
+ if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _))
+ {
+ return null;
+ }
+
+ var changed = false;
+
+ foreach (var element in MsBuildXml.Descendants(document, "PackageReference"))
+ {
+ var attribute = MsBuildXml.Attribute(element, "Include");
+ if (
+ attribute is null
+ || !string.Equals(attribute.Value, canonicalId, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(attribute.Value, canonicalId, StringComparison.Ordinal)
+ )
+ {
+ continue;
+ }
+
+ attribute.Value = canonicalId;
+ changed = true;
+ }
+
+ return changed ? MsBuildXml.ToString(document) : null;
+ }
+
+ ///
+ /// Adds a Version attribute directly after Include. LINQ to XML has no attribute-insert API,
+ /// so the attribute list is rebuilt in the desired order.
+ ///
+ private static void InsertVersionAfterInclude(XElement element, string version)
+ {
+ var attributes = element.Attributes().ToList();
+ element.RemoveAttributes();
+
+ var inserted = false;
+
+ foreach (var attribute in attributes)
+ {
+ element.Add(new XAttribute(attribute.Name, attribute.Value));
+
+ if (
+ inserted
+ || !string.Equals(attribute.Name.LocalName, "Include", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(attribute.Name.LocalName, "Update", StringComparison.OrdinalIgnoreCase)
+ )
+ {
+ continue;
+ }
+
+ element.Add(new XAttribute("Version", version));
+ inserted = true;
+ }
+
+ if (!inserted)
+ {
+ element.Add(new XAttribute("Version", version));
+ }
+ }
+
+ private static void CollapseIfEmpty(XElement element)
+ {
+ if (element.Elements().Any())
+ {
+ return;
+ }
+
+ // Drop leftover whitespace so \n becomes self-closing.
+ foreach (var node in element.Nodes().OfType().ToList())
+ {
+ if (node.Value.Trim().Length == 0)
+ {
+ node.Remove();
+ }
+ }
+ }
+}
diff --git a/CentralConfigGenerator.Core/Migration/PropertyHoister.cs b/CentralConfigGenerator.Core/Migration/PropertyHoister.cs
new file mode 100644
index 0000000..8199c83
--- /dev/null
+++ b/CentralConfigGenerator.Core/Migration/PropertyHoister.cs
@@ -0,0 +1,100 @@
+using CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.Xml;
+
+namespace CentralConfigGenerator.Core.Migration;
+
+///
+/// Works out which MSBuild properties every project agrees on, so they can be lifted into
+/// Directory.Build.props without changing the evaluated build.
+///
+public static class PropertyHoister
+{
+ ///
+ /// Returns the properties whose value is identical across all projects that declare
+ /// them, and that are declared by at least two projects. Requiring unanimity is what keeps the
+ /// migration behaviour-preserving.
+ ///
+ public static Dictionary FindHoistableProperties(
+ IReadOnlyCollection projects,
+ IReadOnlyList candidateNames,
+ int minimumProjects = 2
+ )
+ {
+ var allowed = new HashSet(candidateNames, StringComparer.OrdinalIgnoreCase);
+ var blocked = new HashSet(
+ BuildPropertyDefaults.NeverHoist,
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ var observed = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ var declaringProjects = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var project in projects)
+ {
+ if (!MsBuildXml.TryParse(project.Content, out var document, out _))
+ {
+ continue;
+ }
+
+ var seenInThisProject = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var group in MsBuildXml.Descendants(document, "PropertyGroup"))
+ {
+ // Conditioned groups vary per configuration and are never safe to hoist.
+ if (!string.IsNullOrWhiteSpace(MsBuildXml.AttributeValue(group, "Condition")))
+ {
+ continue;
+ }
+
+ foreach (var element in group.Elements())
+ {
+ var name = element.Name.LocalName;
+
+ if (blocked.Contains(name) || (allowed.Count > 0 && !allowed.Contains(name)))
+ {
+ continue;
+ }
+
+ var value = element.Value.Trim();
+ if (value.Length == 0)
+ {
+ continue;
+ }
+
+ if (!observed.TryGetValue(name, out var values))
+ {
+ values = new HashSet(StringComparer.Ordinal);
+ observed[name] = values;
+ }
+
+ values.Add(value);
+ seenInThisProject.Add(name);
+ }
+ }
+
+ foreach (var name in seenInThisProject)
+ {
+ declaringProjects[name] = declaringProjects.GetValueOrDefault(name) + 1;
+ }
+ }
+
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (name, values) in observed)
+ {
+ if (values.Count != 1)
+ {
+ continue;
+ }
+
+ if (declaringProjects.GetValueOrDefault(name) < minimumProjects)
+ {
+ continue;
+ }
+
+ result[name] = values.First();
+ }
+
+ return result;
+ }
+}
diff --git a/CentralConfigGenerator.Core/Models/BackupSet.cs b/CentralConfigGenerator.Core/Models/BackupSet.cs
new file mode 100644
index 0000000..7253d42
--- /dev/null
+++ b/CentralConfigGenerator.Core/Models/BackupSet.cs
@@ -0,0 +1,44 @@
+using System.Text.Json.Serialization;
+
+namespace CentralConfigGenerator.Core.Models;
+
+/// A single timestamped backup produced before a mutating operation.
+public sealed record BackupSet
+{
+ public required string Id { get; init; }
+
+ public required string Path { get; init; }
+
+ public required DateTimeOffset CreatedAt { get; init; }
+
+ public required string Operation { get; init; }
+
+ public required IReadOnlyList Files { get; init; }
+
+ [JsonIgnore]
+ public long SizeInBytes { get; init; }
+}
+
+/// Manifest persisted alongside the copied files inside a backup directory.
+public sealed record BackupManifest
+{
+ public string Id { get; init; } = string.Empty;
+
+ public DateTimeOffset CreatedAt { get; init; }
+
+ public string Operation { get; init; } = string.Empty;
+
+ public string RootDirectory { get; init; } = string.Empty;
+
+ public List Entries { get; init; } = [];
+}
+
+public sealed record BackupEntry
+{
+ public string OriginalPath { get; init; } = string.Empty;
+
+ public string RelativePath { get; init; } = string.Empty;
+
+ /// True when the file did not exist before the operation and should be deleted on rollback.
+ public bool WasCreated { get; init; }
+}
diff --git a/CentralConfigGenerator.Core/Models/ExitCode.cs b/CentralConfigGenerator.Core/Models/ExitCode.cs
new file mode 100644
index 0000000..05aedf1
--- /dev/null
+++ b/CentralConfigGenerator.Core/Models/ExitCode.cs
@@ -0,0 +1,32 @@
+namespace CentralConfigGenerator.Core.Models;
+
+/// Process exit codes, stable across releases so CI can branch on them.
+public static class ExitCode
+{
+ public const int Success = 0;
+ public const int ValidationError = 1;
+ public const int FileOperationError = 2;
+ public const int VersionConflict = 3;
+ public const int NoProjectsFound = 4;
+ public const int AnalysisIssuesFound = 5;
+ public const int UnexpectedError = 6;
+ public const int TestFailure = 7;
+ public const int IncompleteAnalysis = 8;
+ public const int GraphDrift = 9;
+
+ public static string Describe(int code) =>
+ code switch
+ {
+ Success => "Success",
+ ValidationError => "ValidationError",
+ FileOperationError => "FileOperationError",
+ VersionConflict => "VersionConflict",
+ NoProjectsFound => "NoProjectsFound",
+ AnalysisIssuesFound => "AnalysisIssuesFound",
+ UnexpectedError => "UnexpectedError",
+ TestFailure => "TestFailure",
+ IncompleteAnalysis => "IncompleteAnalysis",
+ GraphDrift => "GraphDrift",
+ _ => "Unknown",
+ };
+}
diff --git a/CentralConfigGenerator.Core/Models/LineEndingStyle.cs b/CentralConfigGenerator.Core/Models/LineEndingStyle.cs
new file mode 100644
index 0000000..d8555c9
--- /dev/null
+++ b/CentralConfigGenerator.Core/Models/LineEndingStyle.cs
@@ -0,0 +1,82 @@
+namespace CentralConfigGenerator.Core.Models;
+
+///
+/// Line ending style used when writing files back to disk.
+///
+public enum LineEndingStyle
+{
+ /// Keep whatever the original file used.
+ Preserve = 0,
+ Lf,
+ CrLf,
+ Cr,
+}
+
+public static class LineEndingStyleExtensions
+{
+ public static string ToNewLine(this LineEndingStyle style) =>
+ style switch
+ {
+ LineEndingStyle.Lf => "\n",
+ LineEndingStyle.CrLf => "\r\n",
+ LineEndingStyle.Cr => "\r",
+ _ => Environment.NewLine,
+ };
+
+ public static LineEndingStyle Detect(string content)
+ {
+ var crlf = 0;
+ var lf = 0;
+ var cr = 0;
+
+ for (var i = 0; i < content.Length; i++)
+ {
+ var c = content[i];
+ if (c == '\r')
+ {
+ if (i + 1 < content.Length && content[i + 1] == '\n')
+ {
+ crlf++;
+ i++;
+ }
+ else
+ {
+ cr++;
+ }
+ }
+ else if (c == '\n')
+ {
+ lf++;
+ }
+ }
+
+ if (crlf == 0 && lf == 0 && cr == 0)
+ {
+ return LineEndingStyle.Preserve;
+ }
+
+ if (crlf >= lf && crlf >= cr)
+ {
+ return LineEndingStyle.CrLf;
+ }
+
+ return lf >= cr ? LineEndingStyle.Lf : LineEndingStyle.Cr;
+ }
+
+ public static string Normalize(string content, LineEndingStyle style)
+ {
+ if (style == LineEndingStyle.Preserve)
+ {
+ return content;
+ }
+
+ var unified = content.Replace("\r\n", "\n").Replace('\r', '\n');
+ return style switch
+ {
+ LineEndingStyle.Lf => unified,
+ LineEndingStyle.CrLf => unified.Replace("\n", "\r\n"),
+ LineEndingStyle.Cr => unified.Replace('\n', '\r'),
+ _ => content,
+ };
+ }
+}
diff --git a/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs b/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs
new file mode 100644
index 0000000..44c4da2
--- /dev/null
+++ b/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs
@@ -0,0 +1,26 @@
+namespace CentralConfigGenerator.Core.Models;
+
+///
+/// A single PackageReference (or GlobalPackageReference) as declared by a project.
+///
+public sealed record PackageReferenceInfo
+{
+ public required string PackageId { get; init; }
+
+ /// The declared version string, or null when the reference has no version (CPM style).
+ public string? Version { get; init; }
+
+ public required string ProjectPath { get; init; }
+
+ /// The MSBuild Condition on the reference or its item group, when present.
+ public string? Condition { get; init; }
+
+ /// True when the version came from a child element rather than an attribute.
+ public bool VersionIsElement { get; init; }
+
+ /// True for GlobalPackageReference items.
+ public bool IsGlobal { get; init; }
+
+ /// True when the reference targets a private/development dependency.
+ public bool IsPrivateAssets { get; init; }
+}
diff --git a/CentralConfigGenerator.Core/Models/ProjectFile.cs b/CentralConfigGenerator.Core/Models/ProjectFile.cs
index b2d6782..dfc0508 100644
--- a/CentralConfigGenerator.Core/Models/ProjectFile.cs
+++ b/CentralConfigGenerator.Core/Models/ProjectFile.cs
@@ -1,8 +1,27 @@
-namespace CentralConfigGenerator.Core.Models;
+using CentralConfigGenerator.Core.IO;
+namespace CentralConfigGenerator.Core.Models;
+
+///
+/// An MSBuild file that was read from disk, together with everything needed to write it back
+/// without disturbing its original encoding or line endings.
+///
public record ProjectFile
{
public string Path { get; set; } = string.Empty;
-
+
public string Content { get; set; } = string.Empty;
-}
\ No newline at end of file
+
+ /// The kind of MSBuild file, inferred from the extension when not set explicitly.
+ public ProjectKind Kind { get; set; } = ProjectKind.Unknown;
+
+ /// The original on-disk text format. Defaults to BOM-less UTF-8.
+ public TextFileFormat Format { get; set; } = TextFileFormat.Default;
+
+ public ProjectKind ResolvedKind =>
+ Kind != ProjectKind.Unknown ? Kind : ProjectKindExtensions.FromPath(Path);
+
+ public string FileName => System.IO.Path.GetFileName(Path);
+
+ public string DirectoryPath => System.IO.Path.GetDirectoryName(Path) ?? string.Empty;
+}
diff --git a/CentralConfigGenerator.Core/Models/ProjectKind.cs b/CentralConfigGenerator.Core/Models/ProjectKind.cs
new file mode 100644
index 0000000..0048954
--- /dev/null
+++ b/CentralConfigGenerator.Core/Models/ProjectKind.cs
@@ -0,0 +1,31 @@
+namespace CentralConfigGenerator.Core.Models;
+
+///
+/// The kind of MSBuild file that was discovered on disk.
+///
+public enum ProjectKind
+{
+ Unknown = 0,
+ CSharp,
+ FSharp,
+ VisualBasic,
+ Props,
+ Targets,
+}
+
+public static class ProjectKindExtensions
+{
+ public static ProjectKind FromPath(string path) =>
+ Path.GetExtension(path).ToLowerInvariant() switch
+ {
+ ".csproj" => ProjectKind.CSharp,
+ ".fsproj" => ProjectKind.FSharp,
+ ".vbproj" => ProjectKind.VisualBasic,
+ ".props" => ProjectKind.Props,
+ ".targets" => ProjectKind.Targets,
+ _ => ProjectKind.Unknown,
+ };
+
+ public static bool IsProject(this ProjectKind kind) =>
+ kind is ProjectKind.CSharp or ProjectKind.FSharp or ProjectKind.VisualBasic;
+}
diff --git a/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs b/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs
new file mode 100644
index 0000000..e1fffbd
--- /dev/null
+++ b/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs
@@ -0,0 +1,21 @@
+namespace CentralConfigGenerator.Core.NuGet;
+
+public interface INuGetFeedService
+{
+ /// Fetches metadata for a single package, memoised for the lifetime of the service.
+ Task GetMetadataAsync(
+ string packageId,
+ CancellationToken cancellationToken = default
+ );
+
+ /// Fetches metadata for many packages with bounded parallelism.
+ Task> GetMetadataAsync(
+ IEnumerable packageIds,
+ int maxParallelism,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ );
+
+ /// The configured package sources, for diagnostics.
+ IReadOnlyList Sources { get; }
+}
diff --git a/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs b/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs
new file mode 100644
index 0000000..91aaf64
--- /dev/null
+++ b/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs
@@ -0,0 +1,234 @@
+using System.Collections.Concurrent;
+using CentralConfigGenerator.Core.Analysis;
+using NuGet.Common;
+using NuGet.Configuration;
+using NuGet.Protocol;
+using NuGet.Protocol.Core.Types;
+using NuGet.Versioning;
+
+namespace CentralConfigGenerator.Core.NuGet;
+
+///
+/// Talks to every package source configured for the workspace (nuget.config aware), collecting
+/// version lists, deprecation notices, licence data and security advisories.
+///
+public sealed class NuGetFeedService : INuGetFeedService, IDisposable
+{
+ private readonly List _repositories = [];
+ private readonly SourceCacheContext _cache = new();
+ private readonly ConcurrentDictionary> _memo =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public NuGetFeedService(string? rootDirectory = null)
+ {
+ var sources = LoadSources(rootDirectory);
+
+ foreach (var source in sources)
+ {
+ _repositories.Add(Repository.Factory.GetCoreV3(source));
+ }
+
+ if (_repositories.Count == 0)
+ {
+ _repositories.Add(
+ Repository.Factory.GetCoreV3(
+ new PackageSource("https://api.nuget.org/v3/index.json")
+ )
+ );
+ }
+
+ Sources = _repositories.Select(r => r.PackageSource.Source).ToList();
+ }
+
+ public IReadOnlyList Sources { get; }
+
+ public Task GetMetadataAsync(
+ string packageId,
+ CancellationToken cancellationToken = default
+ ) => _memo.GetOrAdd(packageId, id => FetchAsync(id, cancellationToken));
+
+ public async Task> GetMetadataAsync(
+ IEnumerable packageIds,
+ int maxParallelism,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var ids = packageIds
+ .Where(id => !string.IsNullOrWhiteSpace(id))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var results = new ConcurrentDictionary(
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ using var throttle = new SemaphoreSlim(Math.Max(1, maxParallelism));
+
+ var tasks = ids.Select(async id =>
+ {
+ await throttle.WaitAsync(cancellationToken);
+ try
+ {
+ results[id] = await GetMetadataAsync(id, cancellationToken);
+ progress?.Report(id);
+ }
+ finally
+ {
+ throttle.Release();
+ }
+ });
+
+ await Task.WhenAll(tasks);
+ return results;
+ }
+
+ private async Task FetchAsync(
+ string packageId,
+ CancellationToken cancellationToken
+ )
+ {
+ Exception? lastError = null;
+
+ foreach (var repository in _repositories)
+ {
+ try
+ {
+ var resource = await repository.GetResourceAsync(
+ cancellationToken
+ );
+
+ var metadata = (
+ await resource.GetMetadataAsync(
+ packageId,
+ includePrerelease: true,
+ includeUnlisted: false,
+ _cache,
+ NullLogger.Instance,
+ cancellationToken
+ )
+ ).ToList();
+
+ if (metadata.Count == 0)
+ {
+ continue;
+ }
+
+ return await BuildSnapshotAsync(packageId, metadata, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ lastError = ex;
+ }
+ }
+
+ return lastError is not null
+ ? PackageMetadataSnapshot.Unavailable(packageId, lastError.Message)
+ : new PackageMetadataSnapshot { PackageId = packageId, Found = false };
+ }
+
+ private static async Task BuildSnapshotAsync(
+ string packageId,
+ List metadata,
+ CancellationToken cancellationToken
+ )
+ {
+ var ordered = metadata.OrderByDescending(m => m.Identity.Version).ToList();
+ var newest = ordered[0];
+
+ var deprecation = await SafeGetDeprecationAsync(newest);
+ var vulnerabilities = new List();
+
+ foreach (var entry in ordered)
+ {
+ if (entry.Vulnerabilities is null)
+ {
+ continue;
+ }
+
+ foreach (var vulnerability in entry.Vulnerabilities)
+ {
+ vulnerabilities.Add(
+ new PackageVulnerability
+ {
+ Severity = MapSeverity(vulnerability.Severity),
+ AdvisoryUrl = vulnerability.AdvisoryUrl?.ToString() ?? string.Empty,
+ AffectedRange = entry.Identity.Version.ToNormalizedString(),
+ }
+ );
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return new PackageMetadataSnapshot
+ {
+ PackageId = newest.Identity.Id,
+ Found = true,
+ AllVersions = ordered.Select(m => m.Identity.Version).ToList(),
+ LatestStable = ordered.FirstOrDefault(m => !m.Identity.Version.IsPrerelease)?.Identity.Version,
+ LatestIncludingPrerelease = newest.Identity.Version,
+ IsDeprecated = deprecation is not null,
+ DeprecationMessage = deprecation?.Message,
+ DeprecationReasons = deprecation?.Reasons?.ToList() ?? [],
+ AlternatePackageId = deprecation?.AlternatePackage?.PackageId,
+ License = DescribeLicense(newest),
+ LicenseUrl = newest.LicenseUrl?.ToString(),
+ ProjectUrl = newest.ProjectUrl?.ToString(),
+ Vulnerabilities = vulnerabilities,
+ };
+ }
+
+ private static async Task SafeGetDeprecationAsync(
+ IPackageSearchMetadata metadata
+ )
+ {
+ try
+ {
+ return await metadata.GetDeprecationMetadataAsync();
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static string? DescribeLicense(IPackageSearchMetadata metadata)
+ {
+ if (metadata.LicenseMetadata is { } license)
+ {
+ return string.IsNullOrWhiteSpace(license.License)
+ ? license.Type.ToString()
+ : license.License;
+ }
+
+ return metadata.LicenseUrl?.ToString();
+ }
+
+ private static Severity MapSeverity(int severity) =>
+ severity switch
+ {
+ >= 3 => Severity.Critical,
+ 2 => Severity.High,
+ 1 => Severity.Moderate,
+ _ => Severity.Low,
+ };
+
+ private static List LoadSources(string? rootDirectory)
+ {
+ try
+ {
+ var settings = Settings.LoadDefaultSettings(
+ rootDirectory ?? Directory.GetCurrentDirectory()
+ );
+ var provider = new PackageSourceProvider(settings);
+ return provider.LoadPackageSources().Where(s => s.IsEnabled).ToList();
+ }
+ catch (Exception)
+ {
+ return [];
+ }
+ }
+
+ public void Dispose() => _cache.Dispose();
+}
diff --git a/CentralConfigGenerator.Core/NuGet/PackageGraph.cs b/CentralConfigGenerator.Core/NuGet/PackageGraph.cs
new file mode 100644
index 0000000..482b2a7
--- /dev/null
+++ b/CentralConfigGenerator.Core/NuGet/PackageGraph.cs
@@ -0,0 +1,63 @@
+namespace CentralConfigGenerator.Core.NuGet;
+
+/// A resolved dependency as reported by dotnet list package.
+public sealed record ResolvedPackage
+{
+ public required string PackageId { get; init; }
+
+ public required string ResolvedVersion { get; init; }
+
+ public string? RequestedVersion { get; init; }
+
+ public required string ProjectPath { get; init; }
+
+ public required string TargetFramework { get; init; }
+
+ public bool IsTransitive { get; init; }
+}
+
+/// The full package graph for a workspace, keyed for quick lookups.
+public sealed record PackageGraph
+{
+ public static readonly PackageGraph Empty = new()
+ {
+ Packages = [],
+ IsAvailable = false,
+ Error = "Package graph was not collected.",
+ };
+
+ public required IReadOnlyList Packages { get; init; }
+
+ public bool IsAvailable { get; init; }
+
+ public string? Error { get; init; }
+
+ public IEnumerable Direct => Packages.Where(p => !p.IsTransitive);
+
+ public IEnumerable Transitive => Packages.Where(p => p.IsTransitive);
+
+ /// Package ids resolved to more than one version across the workspace.
+ public IEnumerable> DivergentPackages =>
+ Packages
+ .GroupBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase)
+ .Where(g =>
+ g.Select(p => p.ResolvedVersion)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count() > 1
+ );
+
+ /// A stable signature of the resolved graph, used by verify to detect drift.
+ public IReadOnlyDictionary Signature() =>
+ Packages
+ .GroupBy(
+ p => $"{NormalizeProject(p.ProjectPath)}|{p.TargetFramework}|{p.PackageId}",
+ StringComparer.OrdinalIgnoreCase
+ )
+ .ToDictionary(
+ g => g.Key,
+ g => g.OrderBy(p => p.ResolvedVersion, StringComparer.OrdinalIgnoreCase).Last().ResolvedVersion,
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ private static string NormalizeProject(string path) => Path.GetFileName(path);
+}
diff --git a/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs b/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs
new file mode 100644
index 0000000..c65712d
--- /dev/null
+++ b/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs
@@ -0,0 +1,162 @@
+using System.Text.Json;
+using CentralConfigGenerator.Core.Services;
+
+namespace CentralConfigGenerator.Core.NuGet;
+
+public interface IPackageGraphService
+{
+ Task LoadAsync(
+ string target,
+ string workingDirectory,
+ bool includeTransitive,
+ CancellationToken cancellationToken = default
+ );
+}
+
+///