From de0fd9788a5a58d9067530744262ddc3d1b2c7b4 Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:50:44 +0200 Subject: [PATCH 1/4] Add failing tests for the kit package documents, kit.json and the manifest --- .../Install/KitJsonTests.cs | 158 ++++++++++++++++++ .../Install/KitLayoutTests.cs | 65 +++++++ .../Install/ManifestTests.cs | 100 +++++++++++ .../Packages/KitCatalogTests.cs | 47 ++++++ .../Packages/KitFilterTests.cs | 78 +++++++++ .../Packages/KitPackageMetadataTests.cs | 58 +++++++ 6 files changed, 506 insertions(+) create mode 100644 manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitJsonTests.cs create mode 100644 manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitLayoutTests.cs create mode 100644 manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/ManifestTests.cs create mode 100644 manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitCatalogTests.cs create mode 100644 manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitFilterTests.cs create mode 100644 manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitPackageMetadataTests.cs diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitJsonTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitJsonTests.cs new file mode 100644 index 0000000..0e4adbc --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitJsonTests.cs @@ -0,0 +1,158 @@ +using DragoAnt.MSBuildKit.Manager.Install; +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Install; + +public sealed class KitJsonTests +{ + // language=json + private const string Schema2 = """ + { + "schema": 2, + "filters": [ + { + "package": "DragoAnt.MSBuildKit.Company", + "source": "nuget.org" + } + ], + "parts": { + "enable": [ + "PackageAsProj" + ], + "disable": [] + }, + "prerelease": false, + "source": "feed" + } + + """; + + [Fact] + public void Parse_Schema2_ReadsEveryField() + { + var kit = KitJson.Parse(Schema2); + + kit.ReadSchema.Should().Be(2); + kit.Legacy.Should().BeNull(); + kit.Filters.Should().ContainSingle().Which.Should().Be(new KitJsonFilter("DragoAnt.MSBuildKit.Company", "nuget.org")); + kit.Parts.Enable.Should().Equal("PackageAsProj"); + kit.Parts.Disable.Should().BeEmpty(); + kit.Prerelease.Should().BeFalse(); + kit.Source.Should().Be(KitSourceKind.Feed); + } + + [Fact] + public void ToJson_Schema2_IsByteStable() + { + var text = Schema2.ReplaceLineEndings("\n"); + + KitJson.Parse(text).ToJson().Should().Be(text); + } + + [Fact] + public void Parse_Schema2_WithOnlyTheSchema_UsesDefaults() + { + var kit = KitJson.Parse("""{ "schema": 2 }"""); + + kit.Filters.Should().BeEmpty(); + kit.Parts.Enable.Should().BeEmpty(); + kit.Parts.Disable.Should().BeEmpty(); + kit.Prerelease.Should().BeFalse(); + kit.Source.Should().Be(KitSourceKind.Feed); + } + + [Fact] + public void Parse_Schema2_AcceptsCommentsAndTrailingCommas() + { + var kit = KitJson.Parse(""" + { + // hand-edited + "schema": 2, + "parts": { "enable": [ "EF", ], }, + "source": "local", + } + """); + + kit.Parts.Enable.Should().Equal("EF"); + kit.Source.Should().Be(KitSourceKind.Local); + } + + [Fact] + public void Parse_Schema1_SingleLineParts_BecomeEnabledParts() + { + var kit = KitJson.Parse(""" + { + "repository": "DragoAnt/MSBuildKit", + "version": "0.2.1", + "sha256": "ab12", + "parts": ["EF","PackageAsProj"] + } + """); + + kit.ReadSchema.Should().Be(1); + kit.Legacy.Should().Be(new KitJsonLegacy("DragoAnt/MSBuildKit", "0.2.1", "ab12")); + kit.Parts.Enable.Should().Equal("EF", "PackageAsProj"); + kit.Parts.Disable.Should().BeEmpty(); + kit.Filters.Should().BeEmpty(); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public void Parse_Schema1_MultiLinePartsArray_KeepsEveryPart(string newline) + { + var text = string.Join(newline, + "{", + " \"repository\": \"DragoAnt/MSBuildKit\",", + " \"version\": \"0.2.1\",", + " \"sha256\": \"\",", + " \"parts\": [", + " \"Project.RoslynComponent\",", + " \"Project.CodeAnalyzer\",", + " \"EF\"", + " ]", + "}", + ""); + + var kit = KitJson.Parse(text); + + kit.Parts.Enable.Should().Equal("Project.RoslynComponent", "Project.CodeAnalyzer", "EF"); + } + + [Fact] + public void Parse_Schema1_EmptyParts_EnablesNothing() + { + var kit = KitJson.Parse("""{ "repository": "DragoAnt/MSBuildKit", "version": "0.0.1", "sha256": "", "parts": [] }"""); + + kit.ReadSchema.Should().Be(1); + kit.Parts.Enable.Should().BeEmpty(); + } + + [Fact] + public void ToJson_AfterSchema1_WritesSchema2WithoutTheLegacyFields() + { + var json = KitJson.Parse("""{ "repository": "DragoAnt/MSBuildKit", "version": "0.2.1", "sha256": "", "parts": [ "EF" ] }""").ToJson(); + + json.Should().Contain("\"schema\": 2").And.NotContain("repository").And.NotContain("sha256"); + var reread = KitJson.Parse(json); + reread.ReadSchema.Should().Be(2); + reread.Parts.Enable.Should().Equal("EF"); + } + + [Theory] + [InlineData("""{ "schema": 3 }""", "schema")] + [InlineData("""{ "schema": 2, "source": "cloud" }""", "cloud")] + [InlineData("""{ "schema": 2, "parts": { "enabel": [ "EF" ] } }""", "enabel")] + [InlineData("""{ "schema": 2, "filters": [ { "source": "nuget.org" } ] }""", "package")] + [InlineData("""{ "schema": 2, "parts": { "enable": [ "EF" ], "disable": [ "EF" ] } }""", "'EF'")] + [InlineData("""{ "repository": "DragoAnt/MSBuildKit", "parts": "EF" }""", "parts")] + [InlineData("""{ }""", "schema")] + [InlineData("""[ 1 ]""", "object")] + public void Parse_InvalidDocument_Throws(string json, string mentioned) + { + var act = () => KitJson.Parse(json); + + act.Should().Throw() + .Which.Message.Should().Contain(KitLayout.KitJsonFileName).And.Contain(mentioned); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitLayoutTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitLayoutTests.cs new file mode 100644 index 0000000..0095c8f --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/KitLayoutTests.cs @@ -0,0 +1,65 @@ +using DragoAnt.MSBuildKit.Manager.Install; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Install; + +public sealed class KitLayoutTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "repo"); + + [Fact] + public void Default_PutsTheKitInDotMskit() + { + var layout = new KitLayout(Root); + + layout.KitDirName.Should().Be(".mskit"); + layout.KitPath.Should().Be(Path.Combine(Root, ".mskit")); + layout.KitJsonPath.Should().Be(Path.Combine(Root, ".mskit", "kit.json")); + layout.MsbuildPath.Should().Be(Path.Combine(Root, ".mskit", "msbuild")); + layout.InitPropsPath.Should().Be(Path.Combine(Root, ".mskit", "msbuild", "init.props")); + layout.InitTargetsPath.Should().Be(Path.Combine(Root, ".mskit", "msbuild", "init.targets")); + layout.LocalPath.Should().Be(Path.Combine(Root, ".mskit", ".local")); + layout.ManagerPath.Should().Be(Path.Combine(Root, ".mskit", ".manager")); + layout.PackagesProjectPath.Should().Be(Path.Combine(Root, ".mskit", ".manager", "packages.csproj")); + layout.LockFilePath.Should().Be(Path.Combine(Root, ".mskit", ".manager", "packages.lock.json")); + layout.ManifestPath.Should().Be(Path.Combine(Root, ".mskit", ".manager", "manifest.json")); + layout.PackageContentPath("DragoAnt.MSBuildKit.Testing") + .Should().Be(Path.Combine(Root, ".mskit", "msbuild", "DragoAnt.MSBuildKit.Testing")); + } + + [Theory] + [InlineData(".toolkit")] + [InlineData("build/kit")] + public void CustomKitDir_IsUsedForEveryPath(string kitDir) + { + var layout = new KitLayout(Root, kitDir); + + layout.KitDirName.Should().Be(kitDir); + layout.ManifestPath.Should().Be(Path.Combine(Root, kitDir, ".manager", "manifest.json")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("..")] + [InlineData("a/../b")] + [InlineData("/abs")] + [InlineData("C:/abs")] + public void InvalidKitDir_Throws(string kitDir) + { + var act = () => new KitLayout(Root, kitDir); + + act.Should().Throw().WithMessage("*kit*"); + } + + [Theory] + [InlineData("")] + [InlineData("a/b")] + [InlineData("a\\b")] + [InlineData("..")] + public void PackageContentPath_RejectsAnIdThatIsNotOneFolderName(string packageId) + { + var act = () => new KitLayout(Root).PackageContentPath(packageId); + + act.Should().Throw(); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/ManifestTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/ManifestTests.cs new file mode 100644 index 0000000..4405930 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Install/ManifestTests.cs @@ -0,0 +1,100 @@ +using DragoAnt.MSBuildKit.Manager.Install; +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Install; + +public sealed class ManifestTests +{ + private const string HashA = "0000000000000000000000000000000000000000000000000000000000000000"; + private const string HashB = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + + // language=json + private const string Canonical = """ + { + "schema": 1, + "packages": [ + { + "id": "Contoso.MSBuildKit", + "version": "1.0.0" + }, + { + "id": "DragoAnt.MSBuildKit.Core", + "version": "0.3.0-beta.1" + } + ], + "files": [ + { + "path": "msbuild/DragoAnt.MSBuildKit.Core/init.props", + "package": "DragoAnt.MSBuildKit.Core", + "sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + }, + { + "path": "msbuild/init.company.props", + "package": "Contoso.MSBuildKit", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "path": "msbuild/init.props", + "package": null, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + + """; + + [Fact] + public void RoundTrip_IsByteStable() + { + var text = Canonical.ReplaceLineEndings("\n"); + + var once = Manifest.Parse(text).ToJson(); + + once.Should().Be(text); + Manifest.Parse(once).ToJson().Should().Be(once); + } + + [Fact] + public void ToJson_SortsPackagesAndFiles_AndWritesLfOnly() + { + var manifest = new Manifest( + [new ManifestPackage("DragoAnt.MSBuildKit.Core", "0.3.0-beta.1"), new ManifestPackage("Contoso.MSBuildKit", "1.0.0")], + [ + new ManifestFile("msbuild/init.props", null, HashA), + new ManifestFile("msbuild/init.company.props", "Contoso.MSBuildKit", HashA), + new ManifestFile("msbuild/DragoAnt.MSBuildKit.Core/init.props", "DragoAnt.MSBuildKit.Core", HashB), + ]); + + var json = manifest.ToJson(); + + json.Should().Be(Canonical.ReplaceLineEndings("\n")); + json.Should().NotContain("\r"); + } + + [Fact] + public void Parse_ReadsFilesAndPackages() + { + var manifest = Manifest.Parse(Canonical); + + manifest.Packages.Should().HaveCount(2); + manifest.Files.Should().Contain(new ManifestFile("msbuild/init.props", null, HashA)); + manifest.Files.Should().Contain(new ManifestFile("msbuild/DragoAnt.MSBuildKit.Core/init.props", "DragoAnt.MSBuildKit.Core", HashB)); + } + + [Theory] + [InlineData("""{ "schema": 1, "packages": [], "files": [ { "path": "../x", "package": null, "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } ] }""", "../x")] + [InlineData("""{ "schema": 1, "packages": [], "files": [ { "path": "msbuild\\x", "package": null, "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } ] }""", "msbuild\\x")] + [InlineData("""{ "schema": 1, "packages": [], "files": [ { "path": "/x", "package": null, "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } ] }""", "/x")] + [InlineData("""{ "schema": 1, "packages": [], "files": [ { "path": "x", "package": null, "sha256": "ABC" } ] }""", "ABC")] + [InlineData("""{ "schema": 1, "packages": [], "files": [ { "path": "x", "package": "Missing.Package", "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } ] }""", "Missing.Package")] + [InlineData("""{ "schema": 1, "packages": [], "files": [ { "path": "a/X", "package": null, "sha256": "0000000000000000000000000000000000000000000000000000000000000000" }, { "path": "a/x", "package": null, "sha256": "0000000000000000000000000000000000000000000000000000000000000000" } ] }""", "a/x")] + [InlineData("""{ "schema": 1, "packages": [ { "id": "A", "version": "1.0.0" }, { "id": "a", "version": "2.0.0" } ], "files": [] }""", "'a'")] + [InlineData("""{ "schema": 2, "packages": [], "files": [] }""", "schema")] + public void Parse_InvalidDocument_Throws(string json, string mentioned) + { + var act = () => Manifest.Parse(json); + + act.Should().Throw() + .Which.Message.Should().Contain(KitLayout.ManifestFileName).And.Contain(mentioned); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitCatalogTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitCatalogTests.cs new file mode 100644 index 0000000..fbd8351 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitCatalogTests.cs @@ -0,0 +1,47 @@ +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Packages; + +public sealed class KitCatalogTests +{ + [Fact] + public void Parse_ReadsThePartsInOrder() + { + // language=json + const string json = """ + { + "schema": 1, + "parts": [ + { "id": "Project.CodeAnalyzer", "kind": "optional", "order": 210, "requires": [ "Project.RoslynComponent" ] }, + { "id": "Core", "kind": "required", "order": 0 }, + { "id": "Project.RoslynComponent", "kind": "optional", "order": 200, "requires": [] }, + { "id": "Testing", "kind": "default", "order": 120 } + ] + } + """; + + var catalog = KitCatalog.Parse(json); + + catalog.Parts.Select(p => p.Id).Should().Equal("Core", "Testing", "Project.RoslynComponent", "Project.CodeAnalyzer"); + catalog.Find("Project.CodeAnalyzer")!.Requires.Should().Equal("Project.RoslynComponent"); + catalog.Find("Core")!.Kind.Should().Be(KitPartKind.Required); + catalog.Find("Testing")!.Kind.Should().Be(KitPartKind.Default); + catalog.Find("Core")!.Requires.Should().BeEmpty(); + catalog.Find("Missing").Should().BeNull(); + } + + [Theory] + [InlineData("""{ "schema": 1, "parts": [ { "id": "A", "kind": "default", "order": 1 }, { "id": "A", "kind": "optional", "order": 2 } ] }""", "'A'")] + [InlineData("""{ "schema": 1, "parts": [ { "id": "A", "kind": "default", "order": 1, "requires": [ "B" ] } ] }""", "'B'")] + [InlineData("""{ "schema": 1, "parts": [ { "id": "A", "kind": "sometimes", "order": 1 } ] }""", "sometimes")] + [InlineData("""{ "schema": 1, "parts": [ { "kind": "default", "order": 1 } ] }""", "id")] + [InlineData("""{ "schema": 1 }""", "parts")] + [InlineData("""{ "schema": 3, "parts": [] }""", "schema")] + public void Parse_InvalidDocument_Throws(string json, string mentioned) + { + var act = () => KitCatalog.Parse(json); + + act.Should().Throw() + .Which.Message.Should().Contain(KitCatalog.PackagePath).And.Contain(mentioned); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitFilterTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitFilterTests.cs new file mode 100644 index 0000000..665a383 --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitFilterTests.cs @@ -0,0 +1,78 @@ +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Packages; + +public sealed class KitFilterTests +{ + [Fact] + public void Parse_CompanyFilter_ReadsEverySection() + { + // language=json + const string json = """ + { + "schema": 1, + "tier": "company", + "name": "contoso", + "sources": [ + { "name": "contoso", "url": "https://packages.contoso.example/api/v3/index.json", "patterns": [ "Contoso.*" ] } + ], + "packages": [ + { "id": "Contoso.MSBuildKit.Signing", "version": "[1.2.0, 2.0.0)" } + ], + "parts": { + "enable": [ "Vcs.GitLab", "EF" ], + "disable": [ "Vcs.GitHub" ], + "locked": [ "Locals.Secrets" ] + }, + "core": { "version": "[0.3.0, 1.0.0)", "prerelease": false } + } + """; + + var filter = KitFilter.Parse(json); + + filter.Tier.Should().Be(KitTier.Company); + filter.Name.Should().Be("contoso"); + filter.Extends.Should().BeNull(); + filter.Sources.Should().ContainSingle().Which.Should().BeEquivalentTo( + new KitFilterSource("contoso", "https://packages.contoso.example/api/v3/index.json", ["Contoso.*"])); + filter.Packages.Should().ContainSingle().Which.Should().Be(new KitFilterPackage("Contoso.MSBuildKit.Signing", "[1.2.0, 2.0.0)")); + filter.Parts.Enable.Should().Equal("Vcs.GitLab", "EF"); + filter.Parts.Disable.Should().Equal("Vcs.GitHub"); + filter.Parts.Locked.Should().Equal("Locals.Secrets"); + filter.Core.Should().Be(new KitFilterCore("[0.3.0, 1.0.0)", false)); + } + + [Fact] + public void Parse_TeamFilter_KeepsExtendsAndDefaultsTheRest() + { + var filter = KitFilter.Parse("""{ "schema": 1, "tier": "team", "name": "web", "extends": "Contoso.MSBuildKit" }"""); + + filter.Tier.Should().Be(KitTier.Team); + filter.Extends.Should().Be("Contoso.MSBuildKit"); + filter.Sources.Should().BeEmpty(); + filter.Packages.Should().BeEmpty(); + filter.Parts.Enable.Should().BeEmpty(); + filter.Parts.Locked.Should().BeEmpty(); + filter.Core.Should().BeNull(); + } + + [Theory] + [InlineData("""{ "schema": 1, "tier": "core", "name": "x" }""", "core")] + [InlineData("""{ "schema": 1, "tier": "team", "name": "web" }""", "extends")] + [InlineData("""{ "schema": 1, "tier": "company", "name": "x", "extends": "Other.MSBuildKit" }""", "extends")] + [InlineData("""{ "schema": 1, "tier": "team", "name": "web", "extends": "C.MSBuildKit", "parts": { "locked": [ "EF" ] } }""", "locked")] + [InlineData("""{ "schema": 1, "tier": "company" }""", "name")] + [InlineData("""{ "schema": 1, "tier": "company", "name": "x", "packages": [ { "id": "C.MSBuildKit.A", "version": "not a range" } ] }""", "not a range")] + [InlineData("""{ "schema": 1, "tier": "company", "name": "x", "core": { "version": "[1.0" } }""", "[1.0")] + [InlineData("""{ "schema": 1, "tier": "company", "name": "x", "sources": [ { "name": "s", "patterns": [ "C.*" ] } ] }""", "url")] + [InlineData("""{ "schema": 1, "tier": "company", "name": "x", "sources": [ { "name": "s", "url": "https://x.example/index.json", "patterns": [] } ] }""", "patterns")] + [InlineData("""{ "schema": 1, "tier": "company", "name": "x", "parts": { "enable": [ "EF" ], "disable": [ "EF" ] } }""", "'EF'")] + [InlineData("""{ "schema": 1, "tier": "company", "name": "x", "properties": { "A": "1" } }""", "properties")] + public void Parse_InvalidDocument_Throws(string json, string mentioned) + { + var act = () => KitFilter.Parse(json); + + act.Should().Throw() + .Which.Message.Should().Contain(KitFilter.PackagePath).And.Contain(mentioned); + } +} diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitPackageMetadataTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitPackageMetadataTests.cs new file mode 100644 index 0000000..0e938fc --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/KitPackageMetadataTests.cs @@ -0,0 +1,58 @@ +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Packages; + +public sealed class KitPackageMetadataTests +{ + [Fact] + public void Parse_ReadsEveryField() + { + // language=json + const string json = """ + { + "schema": 1, + "id": "DragoAnt.MSBuildKit.Testing", + "tier": "core", + "order": 120, + "contentHash": "sha256:3f6c" + } + """; + + var metadata = KitPackageMetadata.Parse(json); + + metadata.Should().Be(new KitPackageMetadata("DragoAnt.MSBuildKit.Testing", KitTier.Core, 120, "sha256:3f6c")); + } + + [Fact] + public void Parse_WithoutContentHash_LeavesItNull() + { + var metadata = KitPackageMetadata.Parse("""{ "schema": 1, "id": "Contoso.MSBuildKit", "tier": "company", "order": 0 }"""); + + metadata.ContentHash.Should().BeNull(); + metadata.Tier.Should().Be(KitTier.Company); + } + + [Fact] + public void ToJson_RoundTrips() + { + var metadata = new KitPackageMetadata("Contoso.Web.MSBuildKit", KitTier.Team, 7, "sha256:00"); + + KitPackageMetadata.Parse(metadata.ToJson()).Should().Be(metadata); + } + + [Theory] + [InlineData("""{ "schema": 2, "id": "A", "tier": "core", "order": 1 }""", "schema")] + [InlineData("""{ "id": "A", "tier": "core", "order": 1 }""", "schema")] + [InlineData("""{ "schema": 1, "tier": "core", "order": 1 }""", "id")] + [InlineData("""{ "schema": 1, "id": "A", "tier": "galaxy", "order": 1 }""", "galaxy")] + [InlineData("""{ "schema": 1, "id": "A", "tier": "core" }""", "order")] + [InlineData("""{ "schema": 1, "id": "A", "tier": "core", "order": 1, "extra": true }""", "extra")] + [InlineData("""not json""", KitPackageMetadata.PackagePath)] + public void Parse_InvalidDocument_Throws(string json, string mentioned) + { + var act = () => KitPackageMetadata.Parse(json); + + act.Should().Throw() + .Which.Message.Should().Contain(KitPackageMetadata.PackagePath).And.Contain(mentioned); + } +} From 78771ec31129e4aed4e0391974b8b3d71f5716ce Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:51:54 +0200 Subject: [PATCH 2/4] Add the failing selection table tests --- .../Packages/SelectionTests.cs | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/SelectionTests.cs diff --git a/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/SelectionTests.cs b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/SelectionTests.cs new file mode 100644 index 0000000..d43f54b --- /dev/null +++ b/manager/tests/DragoAnt.MSBuildKit.Manager.Tests/Packages/SelectionTests.cs @@ -0,0 +1,247 @@ +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Tests.Packages; + +public sealed class SelectionTests +{ + private const string CompanyId = "Contoso.MSBuildKit"; + private const string TeamId = "Contoso.Web.MSBuildKit"; + + private static readonly KitCatalog Catalog = new( + [ + Part("Core", KitPartKind.Required, 0), + Part("Trunk", KitPartKind.Required, 10), + Part("Vcs.GitHub", KitPartKind.Default, 20), + Part("Testing", KitPartKind.Default, 30), + Part("Locals.Secrets", KitPartKind.Default, 40), + Part("Project.RoslynComponent", KitPartKind.Optional, 50), + Part("Project.CodeAnalyzer", KitPartKind.Optional, 60, "Project.RoslynComponent"), + Part("Project.CodeFixer", KitPartKind.Optional, 70, "Project.RoslynComponent", "Project.CodeAnalyzer"), + Part("EF", KitPartKind.Optional, 80), + Part("Vcs.GitLab", KitPartKind.Optional, 90), + ]); + + [Fact] + public void CoreDefaults_RequiredAndDefaultOn_OptionalOff() + { + var result = Selection.Compute(Catalog, [], PartToggles.Empty); + + result.Parts.Select(p => p.Id).Should().Equal(Catalog.Parts.Select(p => p.Id)); + result.Parts.Should().OnlyContain(p => p.DecidedBy == SelectionLayer.Core && p.RequiredBy == null && !p.Locked); + result.EnabledParts.Select(p => p.Id).Should().Equal("Core", "Trunk", "Vcs.GitHub", "Testing", "Locals.Secrets"); + result.Company.Should().BeNull(); + result.Team.Should().BeNull(); + } + + [Theory] + [InlineData(SelectionLayer.Company)] + [InlineData(SelectionLayer.Team)] + [InlineData(SelectionLayer.Repository)] + public void EachLayer_EnablesAndDisables(SelectionLayer layer) + { + var toggles = new PartToggles(Enable: ["EF"], Disable: ["Vcs.GitHub"]); + + var result = layer switch + { + SelectionLayer.Company => Selection.Compute(Catalog, [Company(enable: toggles.Enable, disable: toggles.Disable)], PartToggles.Empty), + SelectionLayer.Team => Selection.Compute(Catalog, [Company(), Team(enable: toggles.Enable, disable: toggles.Disable)], PartToggles.Empty), + _ => Selection.Compute(Catalog, [Company(), Team()], toggles), + }; + + result.Decision("EF").Should().Match(d => d.Enabled && d.DecidedBy == layer); + result.Decision("Vcs.GitHub").Should().Match(d => !d.Enabled && d.DecidedBy == layer); + result.Decision("Testing").DecidedBy.Should().Be(SelectionLayer.Core); + } + + [Fact] + public void LaterLayer_WinsPerPart() + { + var result = Selection.Compute(Catalog, + [ + Company(enable: ["EF", "Vcs.GitLab"], disable: ["Vcs.GitHub", "Testing"]), + Team(enable: ["Vcs.GitHub"], disable: ["EF"]), + ], + new PartToggles(Enable: ["EF"], Disable: ["Vcs.GitLab"])); + + result.Decision("EF").Should().Match(d => d.Enabled && d.DecidedBy == SelectionLayer.Repository); + result.Decision("Vcs.GitLab").Should().Match(d => !d.Enabled && d.DecidedBy == SelectionLayer.Repository); + result.Decision("Vcs.GitHub").Should().Match(d => d.Enabled && d.DecidedBy == SelectionLayer.Team); + result.Decision("Testing").Should().Match(d => !d.Enabled && d.DecidedBy == SelectionLayer.Company); + result.Decision("Locals.Secrets").Should().Match(d => d.Enabled && d.DecidedBy == SelectionLayer.Core); + } + + [Theory] + [InlineData(SelectionLayer.Company, "Core")] + [InlineData(SelectionLayer.Team, "Trunk")] + [InlineData(SelectionLayer.Repository, "Core")] + public void RequiredPart_IsNeverDisabled(SelectionLayer layer, string part) + { + var act = () => ComputeWithLayer(layer, disable: [part]); + + var error = act.Should().Throw().Which; + error.Layer.Should().Be(layer); + error.Message.Should().Contain($"'{part}'").And.Contain("required"); + } + + [Theory] + [InlineData(SelectionLayer.Team, true)] + [InlineData(SelectionLayer.Team, false)] + [InlineData(SelectionLayer.Repository, true)] + [InlineData(SelectionLayer.Repository, false)] + public void CompanyLockedPart_IsRefusedForTeamAndRepository(SelectionLayer layer, bool disable) + { + var company = Company(locked: ["Locals.Secrets"]); + PartToggles toggles = disable ? new([], ["Locals.Secrets"]) : new(["Locals.Secrets"], []); + + var act = () => layer == SelectionLayer.Team + ? Selection.Compute(Catalog, [company, Team(enable: toggles.Enable, disable: toggles.Disable)], PartToggles.Empty) + : Selection.Compute(Catalog, [company, Team()], toggles); + + var error = act.Should().Throw().Which; + error.Layer.Should().Be(layer); + error.Message.Should().Contain("'Locals.Secrets'").And.Contain("locked").And.Contain(CompanyId); + } + + [Fact] + public void CompanyLockedPart_KeepsTheCompanyDecision() + { + var result = Selection.Compute(Catalog, [Company(disable: ["EF"], locked: ["EF", "Testing"])], PartToggles.Empty); + + result.Decision("EF").Should().Match(d => !d.Enabled && d.Locked && d.DecidedBy == SelectionLayer.Company); + result.Decision("Testing").Should().Match(d => d.Enabled && d.Locked && d.DecidedBy == SelectionLayer.Core); + } + + [Fact] + public void Requires_IsClosedTransitively() + { + var result = Selection.Compute(Catalog, [], new PartToggles(Enable: ["Project.CodeFixer"], Disable: [])); + + result.Decision("Project.CodeFixer").Should().Match(d => d.Enabled && d.RequiredBy == null); + result.Decision("Project.CodeAnalyzer").Should().Match( + d => d.Enabled && d.RequiredBy == "Project.CodeFixer" && d.DecidedBy == SelectionLayer.Repository); + result.Decision("Project.RoslynComponent").Should().Match(d => d.Enabled && d.RequiredBy != null); + } + + [Fact] + public void Requires_OfAPartAnotherLayerDisabled_Throws() + { + var act = () => Selection.Compute(Catalog, + [Company(disable: ["Project.RoslynComponent"])], + new PartToggles(Enable: ["Project.CodeAnalyzer"], Disable: [])); + + var error = act.Should().Throw().Which; + error.Layer.Should().Be(SelectionLayer.Repository); + error.Message.Should().Contain("'Project.CodeAnalyzer'").And.Contain("'Project.RoslynComponent'").And.Contain(CompanyId); + } + + [Theory] + [InlineData(SelectionLayer.Company, "enable")] + [InlineData(SelectionLayer.Company, "disable")] + [InlineData(SelectionLayer.Company, "locked")] + [InlineData(SelectionLayer.Team, "enable")] + [InlineData(SelectionLayer.Team, "disable")] + [InlineData(SelectionLayer.Repository, "enable")] + [InlineData(SelectionLayer.Repository, "disable")] + public void UnknownPart_Throws_NamingTheLayer(SelectionLayer layer, string list) + { + string[] unknown = ["Nope"]; + + var act = () => list switch + { + "enable" => ComputeWithLayer(layer, enable: unknown), + "disable" => ComputeWithLayer(layer, disable: unknown), + _ => Selection.Compute(Catalog, [Company(locked: unknown)], PartToggles.Empty), + }; + + var error = act.Should().Throw().Which; + error.Layer.Should().Be(layer); + error.Message.Should().Contain("'Nope'").And.Contain("unknown"); + } + + [Fact] + public void RepositoryEnablingAndDisablingOnePart_Throws() + { + var act = () => Selection.Compute(Catalog, [], new PartToggles(Enable: ["EF"], Disable: ["EF"])); + + act.Should().Throw().Which.Layer.Should().Be(SelectionLayer.Repository); + } + + [Fact] + public void TwoCompanyFilters_Throw() + { + var act = () => Selection.Compute(Catalog, [Company(), Company("Fabrikam.MSBuildKit")], PartToggles.Empty); + + var error = act.Should().Throw().Which; + error.Layer.Should().Be(SelectionLayer.Company); + error.Message.Should().Contain(CompanyId).And.Contain("Fabrikam.MSBuildKit"); + } + + [Fact] + public void TwoTeamFilters_Throw() + { + var act = () => Selection.Compute(Catalog, [Company(), Team(), Team(id: "Contoso.Api.MSBuildKit")], PartToggles.Empty); + + act.Should().Throw().Which.Layer.Should().Be(SelectionLayer.Team); + } + + [Fact] + public void TeamFilter_NotExtendingTheAttachedCompany_Throws() + { + var act = () => Selection.Compute(Catalog, [Company(), Team(extends: "Fabrikam.MSBuildKit")], PartToggles.Empty); + + var error = act.Should().Throw().Which; + error.Layer.Should().Be(SelectionLayer.Team); + error.Message.Should().Contain(TeamId).And.Contain("Fabrikam.MSBuildKit").And.Contain(CompanyId); + } + + [Fact] + public void TeamFilter_WithoutACompany_Throws() + { + var act = () => Selection.Compute(Catalog, [Team()], PartToggles.Empty); + + act.Should().Throw().Which.Message.Should().Contain(TeamId).And.Contain(CompanyId); + } + + [Fact] + public void TeamFilter_ExtendsComparesIdsIgnoringCase() + { + var result = Selection.Compute(Catalog, [Company(), Team(extends: CompanyId.ToLowerInvariant())], PartToggles.Empty); + + result.Team!.PackageId.Should().Be(TeamId); + } + + [Fact] + public void FilterPackages_AreCollectedCompanyFirst() + { + var company = new AttachedFilter(CompanyId, Filter(KitTier.Company, null, packages: [new KitFilterPackage("Contoso.MSBuildKit.Signing", "[1.0.0, 2.0.0)")])); + var team = new AttachedFilter(TeamId, Filter(KitTier.Team, CompanyId, packages: [new KitFilterPackage("Contoso.Web.MSBuildKit.Pages", null)])); + + var result = Selection.Compute(Catalog, [team, company], PartToggles.Empty); + + result.Company!.PackageId.Should().Be(CompanyId); + result.Team!.PackageId.Should().Be(TeamId); + result.Packages.Select(p => p.Id).Should().Equal("Contoso.MSBuildKit.Signing", "Contoso.Web.MSBuildKit.Pages"); + } + + private static SelectionResult ComputeWithLayer(SelectionLayer layer, string[]? enable = null, string[]? disable = null) => layer switch + { + SelectionLayer.Company => Selection.Compute(Catalog, [Company(enable: enable, disable: disable)], PartToggles.Empty), + SelectionLayer.Team => Selection.Compute(Catalog, [Company(), Team(enable: enable, disable: disable)], PartToggles.Empty), + _ => Selection.Compute(Catalog, [Company(), Team()], new PartToggles(enable ?? [], disable ?? [])), + }; + + private static KitCatalogPart Part(string id, KitPartKind kind, int order, params string[] requires) => new(id, kind, order, requires); + + private static AttachedFilter Company( + string id = CompanyId, IReadOnlyList? enable = null, IReadOnlyList? disable = null, IReadOnlyList? locked = null) => + new(id, Filter(KitTier.Company, null, enable, disable, locked)); + + private static AttachedFilter Team( + string id = TeamId, string extends = CompanyId, IReadOnlyList? enable = null, IReadOnlyList? disable = null) => + new(id, Filter(KitTier.Team, extends, enable, disable)); + + private static KitFilter Filter( + KitTier tier, string? extends, IReadOnlyList? enable = null, IReadOnlyList? disable = null, + IReadOnlyList? locked = null, IReadOnlyList? packages = null) => + new(tier, "fixture", extends, [], packages ?? [], new KitFilterParts(enable ?? [], disable ?? [], locked ?? []), null); +} From 9b657c38d418999ac48c7462a6fba9364d9ff9e6 Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:16:42 +0200 Subject: [PATCH 3/4] Add the kit package model, kit.json, the manifest and the selection - mskit.package.json, catalog.json and filter.json (schema 1), strict reads - Selection: core -> company -> team -> repository, required, locked, requires - kit.json schema 2 plus the schema-1 reader that keeps multi-line parts - manifest.json, sorted and LF-only, byte-stable round trip; KitLayout --- manager/Directory.Packages.props | 1 + .../DragoAnt.MSBuildKit.Manager.csproj | 1 + .../Install/KitJson.cs | 111 +++++++++++++++ .../Install/KitJsonFilter.cs | 4 + .../Install/KitJsonLegacy.cs | 4 + .../Install/KitLayout.cs | 57 ++++++++ .../Install/KitSourceKind.cs | 8 ++ .../Install/Manifest.cs | 95 +++++++++++++ .../Install/ManifestFile.cs | 7 + .../Install/ManifestPackage.cs | 4 + .../Packages/AttachedFilter.cs | 4 + .../Packages/KitCatalog.cs | 68 +++++++++ .../Packages/KitCatalogPart.cs | 4 + .../Packages/KitDocumentException.cs | 7 + .../Packages/KitDocumentFormat.cs | 122 ++++++++++++++++ .../Packages/KitFilter.cs | 108 ++++++++++++++ .../Packages/KitFilterCore.cs | 4 + .../Packages/KitFilterPackage.cs | 4 + .../Packages/KitFilterParts.cs | 7 + .../Packages/KitFilterSource.cs | 4 + .../Packages/KitPackageMetadata.cs | 42 ++++++ .../Packages/KitPartKind.cs | 14 ++ .../Packages/KitTier.cs | 9 ++ .../Packages/PartDecision.cs | 10 ++ .../Packages/PartToggles.cs | 7 + .../Packages/Selection.cs | 134 ++++++++++++++++++ .../Packages/SelectionException.cs | 7 + .../Packages/SelectionLayer.cs | 10 ++ .../Packages/SelectionResult.cs | 14 ++ 29 files changed, 871 insertions(+) create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJson.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonFilter.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonLegacy.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/KitLayout.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/KitSourceKind.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/Manifest.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestFile.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestPackage.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/AttachedFilter.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalog.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalogPart.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentException.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentFormat.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilter.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterCore.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterPackage.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterParts.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterSource.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPackageMetadata.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPartKind.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitTier.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartDecision.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartToggles.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/Selection.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionException.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionLayer.cs create mode 100644 manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionResult.cs diff --git a/manager/Directory.Packages.props b/manager/Directory.Packages.props index 4bb3b86..ad38fa5 100644 --- a/manager/Directory.Packages.props +++ b/manager/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj b/manager/src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj index 659e5c3..07b99f3 100644 --- a/manager/src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj +++ b/manager/src/DragoAnt.MSBuildKit.Manager/DragoAnt.MSBuildKit.Manager.csproj @@ -13,6 +13,7 @@ + diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJson.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJson.cs new file mode 100644 index 0000000..ef6ebbc --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJson.cs @@ -0,0 +1,111 @@ +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// +/// <kit>/kit.json: the repository layer of the selection. Reads schema 2 and the schema 1 that update.sh +/// writes (its parts become enabled parts); always writes schema 2. +/// +internal sealed record KitJson(IReadOnlyList Filters, PartToggles Parts, bool Prerelease, KitSourceKind Source) +{ + public const int CurrentSchema = 2; + private const string Document = KitLayout.KitJsonFileName; + private static readonly string[] Schema1Fields = ["repository", "version", "sha256", "parts"]; + + public static KitJson Empty { get; } = new([], PartToggles.Empty, false, KitSourceKind.Feed); + + /// The schema the document was read from; 2 for a new one. + public int ReadSchema { get; init; } = CurrentSchema; + + /// The schema-1 fields, when the document was schema 1. + public KitJsonLegacy? Legacy { get; init; } + + public static KitJson Parse(string json) + { + var root = KitDocumentFormat.ReadRoot(Document, json, lenient: true); + var schema = KitDocumentFormat.ReadSchema(Document, root); + if (schema is null && Schema1Fields.Any(f => root.TryGetProperty(f, out _))) + return ParseSchema1(root); + + KitDocumentFormat.RequireSchema(Document, root, CurrentSchema); + var dto = KitDocumentFormat.Deserialize(Document, root); + + var filters = (dto.Filters ?? []).Select(f => new KitJsonFilter( + KitDocumentFormat.Required(Document, f.Package, "a filter's package"), + string.IsNullOrWhiteSpace(f.Source) ? null : f.Source)).ToList(); + var parts = new PartToggles(dto.Parts?.Enable ?? [], dto.Parts?.Disable ?? []); + KitDocumentFormat.RequireDisjoint(Document, parts.Enable, parts.Disable); + var source = dto.Source switch + { + null or "feed" => KitSourceKind.Feed, + "local" => KitSourceKind.Local, + _ => throw new KitDocumentException(Document, $"unknown source '{dto.Source}' (expected feed or local)"), + }; + + return new KitJson(filters, parts, dto.Prerelease ?? false, source); + } + + public string ToJson() => KitDocumentFormat.Write(writer => + { + writer.WriteStartObject(); + writer.WriteNumber("schema", CurrentSchema); + writer.WriteStartArray("filters"); + foreach (var filter in Filters) + { + writer.WriteStartObject(); + writer.WriteString("package", filter.Package); + if (filter.Source is not null) + writer.WriteString("source", filter.Source); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteStartObject("parts"); + KitDocumentFormat.WriteStringArray(writer, "enable", Parts.Enable); + KitDocumentFormat.WriteStringArray(writer, "disable", Parts.Disable); + writer.WriteEndObject(); + writer.WriteBoolean("prerelease", Prerelease); + writer.WriteString("source", Source == KitSourceKind.Local ? "local" : "feed"); + writer.WriteEndObject(); + }); + + private static KitJson ParseSchema1(System.Text.Json.JsonElement root) + { + var dto = KitDocumentFormat.Deserialize(Document, root); + return Empty with + { + Parts = new PartToggles(dto.Parts ?? [], []), + ReadSchema = 1, + Legacy = new KitJsonLegacy(dto.Repository, dto.Version, dto.Sha256), + }; + } + + private sealed class Dto + { + public int? Schema { get; init; } + public FilterDto[]? Filters { get; init; } + public PartsDto? Parts { get; init; } + public bool? Prerelease { get; init; } + public string? Source { get; init; } + } + + private sealed class FilterDto + { + public string? Package { get; init; } + public string? Source { get; init; } + } + + private sealed class PartsDto + { + public string[]? Enable { get; init; } + public string[]? Disable { get; init; } + } + + private sealed class Schema1Dto + { + public string? Repository { get; init; } + public string? Version { get; init; } + public string? Sha256 { get; init; } + public string[]? Parts { get; init; } + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonFilter.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonFilter.cs new file mode 100644 index 0000000..c27e0d5 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonFilter.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// A company or team package the repository attached, and the NuGet source it comes from. +internal sealed record KitJsonFilter(string Package, string? Source); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonLegacy.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonLegacy.cs new file mode 100644 index 0000000..9cb3034 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitJsonLegacy.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// The fields of a schema-1 kit.json (written by update.sh) that schema 2 no longer has. +internal sealed record KitJsonLegacy(string? Repository, string? Version, string? Sha256); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitLayout.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitLayout.cs new file mode 100644 index 0000000..d517bc8 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitLayout.cs @@ -0,0 +1,57 @@ +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// Every path of an installed kit. The only place that knows the folder and file names. +internal sealed class KitLayout +{ + public const string DefaultKitDirName = ".mskit"; + public const string MsbuildDirName = "msbuild"; + public const string LocalDirName = ".local"; + public const string ManagerDirName = ".manager"; + public const string KitJsonFileName = "kit.json"; + public const string InitPropsFileName = "init.props"; + public const string InitTargetsFileName = "init.targets"; + public const string PackagesProjectFileName = "packages.csproj"; + public const string LockFileName = "packages.lock.json"; + public const string ManifestFileName = "manifest.json"; + + /// The solution root, the folder holding Directory.Build.props. + /// The kit folder relative to ; no .. segment. + public KitLayout(string root, string kitDirName = DefaultKitDirName) + { + RequireRelativeKitDir(kitDirName); + Root = root; + KitDirName = kitDirName; + KitPath = Path.Combine(root, kitDirName); + } + + public string Root { get; } + public string KitDirName { get; } + public string KitPath { get; } + public string KitJsonPath => Path.Combine(KitPath, KitJsonFileName); + public string MsbuildPath => Path.Combine(KitPath, MsbuildDirName); + public string InitPropsPath => Path.Combine(MsbuildPath, InitPropsFileName); + public string InitTargetsPath => Path.Combine(MsbuildPath, InitTargetsFileName); + public string LocalPath => Path.Combine(KitPath, LocalDirName); + public string ManagerPath => Path.Combine(KitPath, ManagerDirName); + public string PackagesProjectPath => Path.Combine(ManagerPath, PackagesProjectFileName); + public string LockFilePath => Path.Combine(ManagerPath, LockFileName); + public string ManifestPath => Path.Combine(ManagerPath, ManifestFileName); + + /// The folder a package's msbuild/** content is deployed to: msbuild/<package id>. + public string PackageContentPath(string packageId) + { + if (string.IsNullOrWhiteSpace(packageId) || packageId is "." or ".." || packageId.IndexOfAny(['/', '\\']) >= 0) + throw new ArgumentException($"'{packageId}' is not a package id", nameof(packageId)); + return Path.Combine(MsbuildPath, packageId); + } + + private static void RequireRelativeKitDir(string kitDirName) + { + var segments = kitDirName.Split('/', '\\'); + var rooted = Path.IsPathRooted(kitDirName) + || kitDirName.StartsWith('/') || kitDirName.StartsWith('\\') + || (kitDirName.Length >= 2 && char.IsAsciiLetter(kitDirName[0]) && kitDirName[1] == ':'); + if (string.IsNullOrWhiteSpace(kitDirName) || rooted || segments.Any(s => s is ".." or "")) + throw new ArgumentException($"the kit folder '{kitDirName}' must be a relative path without '..'", nameof(kitDirName)); + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitSourceKind.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitSourceKind.cs new file mode 100644 index 0000000..9c8394a --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/KitSourceKind.cs @@ -0,0 +1,8 @@ +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// Where the kit's packages come from: the configured feeds, or a recorded local folder or checkout. +internal enum KitSourceKind +{ + Feed, + Local, +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/Manifest.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/Manifest.cs new file mode 100644 index 0000000..d96fabf --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/Manifest.cs @@ -0,0 +1,95 @@ +using DragoAnt.MSBuildKit.Manager.Packages; + +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// +/// <kit>/.manager/manifest.json: every file the last install deployed and its SHA-256 — the source of +/// orphan removal and verify. Written sorted, indented, LF-only, so it round-trips byte for byte. +/// +internal sealed record Manifest(IReadOnlyList Packages, IReadOnlyList Files) +{ + public const int Schema = 1; + private const string Document = KitLayout.ManifestFileName; + + public static Manifest Parse(string json) + { + var root = KitDocumentFormat.ReadRoot(Document, json); + KitDocumentFormat.RequireSchema(Document, root, Schema); + var dto = KitDocumentFormat.Deserialize(Document, root); + + var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var packages = (dto.Packages ?? []).Select(p => + { + var id = KitDocumentFormat.Required(Document, p.Id, "a package's id"); + if (!packageIds.Add(id)) + throw new KitDocumentException(Document, $"package '{id}' is listed twice"); + return new ManifestPackage(id, KitDocumentFormat.Required(Document, p.Version, $"package '{id}' version")); + }).ToList(); + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + var files = (dto.Files ?? []).Select(f => + { + var path = KitDocumentFormat.Required(Document, f.Path, "a file's path"); + if (path.Contains('\\') || path.StartsWith('/') || path.Contains(':') || path.Split('/').Any(s => s is "" or "." or "..")) + throw new KitDocumentException(Document, $"file path '{path}' is not a relative forward-slash path inside the kit folder"); + if (!paths.Add(path)) + throw new KitDocumentException(Document, $"file '{path}' is listed twice"); + var sha256 = f.Sha256 ?? string.Empty; + if (sha256.Length != 64 || !sha256.All(char.IsAsciiHexDigitLower)) + throw new KitDocumentException(Document, $"file '{path}' has sha256 '{sha256}', expected 64 lowercase hex digits"); + if (f.Package is not null && !packageIds.Contains(f.Package)) + throw new KitDocumentException(Document, $"file '{path}' names package '{f.Package}', which is not listed"); + return new ManifestFile(path, f.Package, sha256); + }).ToList(); + + return new Manifest(packages, files); + } + + public string ToJson() => KitDocumentFormat.Write(writer => + { + writer.WriteStartObject(); + writer.WriteNumber("schema", Schema); + writer.WriteStartArray("packages"); + foreach (var package in Packages.OrderBy(p => p.Id, StringComparer.Ordinal)) + { + writer.WriteStartObject(); + writer.WriteString("id", package.Id); + writer.WriteString("version", package.Version); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteStartArray("files"); + foreach (var file in Files.OrderBy(f => f.Path, StringComparer.Ordinal)) + { + writer.WriteStartObject(); + writer.WriteString("path", file.Path); + writer.WriteString("package", file.Package); + writer.WriteString("sha256", file.Sha256); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + }); + + private sealed class Dto + { + public int? Schema { get; init; } + public PackageDto[]? Packages { get; init; } + public FileDto[]? Files { get; init; } + } + + private sealed class PackageDto + { + public string? Id { get; init; } + public string? Version { get; init; } + } + + private sealed class FileDto + { + public string? Path { get; init; } + public string? Package { get; init; } + public string? Sha256 { get; init; } + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestFile.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestFile.cs new file mode 100644 index 0000000..ed9e813 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestFile.cs @@ -0,0 +1,7 @@ +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// +/// A deployed file, relative to the kit folder with forward slashes. is +/// for a file the tool generates (the aggregator). +/// +internal sealed record ManifestFile(string Path, string? Package, string Sha256); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestPackage.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestPackage.cs new file mode 100644 index 0000000..d7a9a6b --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Install/ManifestPackage.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Install; + +/// A package the last install deployed, at its exact version. +internal sealed record ManifestPackage(string Id, string Version); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/AttachedFilter.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/AttachedFilter.cs new file mode 100644 index 0000000..231fc93 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/AttachedFilter.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// A company or team package attached to the repository, with the filter it ships. +internal sealed record AttachedFilter(string PackageId, KitFilter Filter); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalog.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalog.cs new file mode 100644 index 0000000..7492430 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalog.cs @@ -0,0 +1,68 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// mskit/catalog.json in the trunk package: every core part, ordered by . +internal sealed class KitCatalog +{ + public const string PackagePath = "mskit/catalog.json"; + public const int Schema = 1; + + private readonly Dictionary _byId = new(StringComparer.Ordinal); + + public KitCatalog(IEnumerable parts) + { + Parts = [.. parts.OrderBy(p => p.Order).ThenBy(p => p.Id, StringComparer.Ordinal)]; + foreach (var part in Parts) + { + if (!_byId.TryAdd(part.Id, part)) + throw new KitDocumentException(PackagePath, $"part '{part.Id}' is listed twice"); + } + + foreach (var part in Parts) + foreach (var required in part.Requires) + { + if (!_byId.ContainsKey(required)) + throw new KitDocumentException(PackagePath, $"part '{part.Id}' requires unknown part '{required}'"); + } + } + + public IReadOnlyList Parts { get; } + + public KitCatalogPart? Find(string id) => _byId.GetValueOrDefault(id); + + public static KitCatalog Parse(string json) + { + var root = KitDocumentFormat.ReadRoot(PackagePath, json); + KitDocumentFormat.RequireSchema(PackagePath, root, Schema); + var dto = KitDocumentFormat.Deserialize(PackagePath, root); + if (dto.Parts is null) + throw new KitDocumentException(PackagePath, "parts is missing"); + + return new KitCatalog(dto.Parts.Select(p => new KitCatalogPart( + KitDocumentFormat.Required(PackagePath, p.Id, "a part's id"), + ParseKind(p.Kind, p.Id!), + p.Order ?? throw new KitDocumentException(PackagePath, $"part '{p.Id}' has no order"), + p.Requires ?? []))); + } + + private static KitPartKind ParseKind(string? kind, string id) => kind switch + { + "required" => KitPartKind.Required, + "default" => KitPartKind.Default, + "optional" => KitPartKind.Optional, + _ => throw new KitDocumentException(PackagePath, $"part '{id}' has unknown kind '{kind}' (expected required, default or optional)"), + }; + + private sealed class Dto + { + public int? Schema { get; init; } + public PartDto[]? Parts { get; init; } + } + + private sealed class PartDto + { + public string? Id { get; init; } + public string? Kind { get; init; } + public int? Order { get; init; } + public string[]? Requires { get; init; } + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalogPart.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalogPart.cs new file mode 100644 index 0000000..0319f14 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitCatalogPart.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// One core part in the trunk's catalog.json. +internal sealed record KitCatalogPart(string Id, KitPartKind Kind, int Order, IReadOnlyList Requires); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentException.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentException.cs new file mode 100644 index 0000000..ab9b0ae --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentException.cs @@ -0,0 +1,7 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// A kit document (mskit.package.json, catalog.json, filter.json, kit.json, the manifest) is malformed. +internal sealed class KitDocumentException(string document, string message) : Exception($"{document}: {message}") +{ + public string Document { get; } = document; +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentFormat.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentFormat.cs new file mode 100644 index 0000000..1cdc03e --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitDocumentFormat.cs @@ -0,0 +1,122 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// Reading and writing the kit's JSON documents: one strict reader, one LF-only indented writer. +internal static class KitDocumentFormat +{ + private static readonly JsonSerializerOptions ReadOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow, + // ReadRoot already applied the document's strictness; a lenient root's raw text still carries its comments. + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + private static readonly JsonWriterOptions WriteOptions = new() + { + Indented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + /// Parses and returns its root object; allows comments and trailing commas. + public static JsonElement ReadRoot(string document, string json, bool lenient = false) + { + try + { + using var parsed = JsonDocument.Parse(json, new JsonDocumentOptions + { + CommentHandling = lenient ? JsonCommentHandling.Skip : JsonCommentHandling.Disallow, + AllowTrailingCommas = lenient, + }); + if (parsed.RootElement.ValueKind != JsonValueKind.Object) + throw new KitDocumentException(document, $"expected a JSON object, found {parsed.RootElement.ValueKind}"); + return parsed.RootElement.Clone(); + } + catch (JsonException ex) + { + throw new KitDocumentException(document, ex.Message); + } + } + + /// The schema number of , or when it has none. + public static int? ReadSchema(string document, JsonElement root) + { + if (!root.TryGetProperty("schema", out var schema)) + return null; + if (schema.ValueKind != JsonValueKind.Number || !schema.TryGetInt32(out var value)) + throw new KitDocumentException(document, $"schema must be a number, found {schema}"); + return value; + } + + public static void RequireSchema(string document, JsonElement root, int expected) + { + var schema = ReadSchema(document, root); + if (schema != expected) + throw new KitDocumentException(document, schema is null + ? $"schema is missing (expected {expected})" + : $"schema {schema} is not supported (expected {expected})"); + } + + public static T Deserialize(string document, JsonElement root) + { + try + { + return root.Deserialize(ReadOptions) ?? throw new KitDocumentException(document, "the document is empty"); + } + catch (JsonException ex) + { + throw new KitDocumentException(document, ex.Message); + } + } + + public static string Required(string document, string? value, string name) => + string.IsNullOrWhiteSpace(value) ? throw new KitDocumentException(document, $"{name} is missing") : value; + + public static KitTier ParseTier(string document, string? value) => value switch + { + "core" => KitTier.Core, + "company" => KitTier.Company, + "team" => KitTier.Team, + null => throw new KitDocumentException(document, "tier is missing"), + _ => throw new KitDocumentException(document, $"unknown tier '{value}' (expected core, company or team)"), + }; + + public static string TierName(KitTier tier) => tier switch + { + KitTier.Core => "core", + KitTier.Company => "company", + _ => "team", + }; + + /// Throws when a part is listed twice, or in both and . + public static void RequireDisjoint(string document, IReadOnlyList enable, IReadOnlyList disable) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var part in enable.Concat(disable)) + { + if (!seen.Add(part)) + throw new KitDocumentException(document, $"part '{part}' is listed twice in enable/disable"); + } + } + + /// Writes an indented document with LF line ends and a final newline, whatever the OS. + public static string Write(Action write) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, WriteOptions)) + write(writer); + return Encoding.UTF8.GetString(stream.ToArray()).Replace("\r\n", "\n", StringComparison.Ordinal) + "\n"; + } + + public static void WriteStringArray(Utf8JsonWriter writer, string name, IEnumerable values) + { + writer.WriteStartArray(name); + foreach (var value in values) + writer.WriteStringValue(value); + writer.WriteEndArray(); + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilter.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilter.cs new file mode 100644 index 0000000..1149049 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilter.cs @@ -0,0 +1,108 @@ +using NuGet.Versioning; + +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// mskit/filter.json in a company or team package. +internal sealed record KitFilter( + KitTier Tier, + string Name, + string? Extends, + IReadOnlyList Sources, + IReadOnlyList Packages, + KitFilterParts Parts, + KitFilterCore? Core) +{ + public const string PackagePath = "mskit/filter.json"; + public const int Schema = 1; + + public static KitFilter Parse(string json) + { + var root = KitDocumentFormat.ReadRoot(PackagePath, json); + KitDocumentFormat.RequireSchema(PackagePath, root, Schema); + var dto = KitDocumentFormat.Deserialize(PackagePath, root); + + var tier = KitDocumentFormat.ParseTier(PackagePath, dto.Tier); + var name = KitDocumentFormat.Required(PackagePath, dto.Name, "name"); + var extends = string.IsNullOrWhiteSpace(dto.Extends) ? null : dto.Extends; + switch (tier) + { + case KitTier.Core: + throw new KitDocumentException(PackagePath, "a filter's tier is company or team, not core"); + case KitTier.Team when extends is null: + throw new KitDocumentException(PackagePath, "a team filter names the company package it extends"); + case KitTier.Company when extends is not null: + throw new KitDocumentException(PackagePath, $"a company filter extends nothing, found extends '{extends}'"); + } + + var parts = new KitFilterParts(dto.Parts?.Enable ?? [], dto.Parts?.Disable ?? [], dto.Parts?.Locked ?? []); + KitDocumentFormat.RequireDisjoint(PackagePath, parts.Enable, parts.Disable); + if (tier == KitTier.Team && parts.Locked.Count > 0) + throw new KitDocumentException(PackagePath, "only a company filter may list locked parts"); + + var sources = (dto.Sources ?? []).Select(s => + { + var sourceName = KitDocumentFormat.Required(PackagePath, s.Name, "a source's name"); + var url = KitDocumentFormat.Required(PackagePath, s.Url, $"source '{sourceName}' url"); + if (s.Patterns is not { Length: > 0 }) + throw new KitDocumentException(PackagePath, $"source '{sourceName}' has no patterns"); + return new KitFilterSource(sourceName, url, s.Patterns); + }).ToList(); + + var packages = (dto.Packages ?? []).Select(p => + { + var id = KitDocumentFormat.Required(PackagePath, p.Id, "a package's id"); + return new KitFilterPackage(id, RequireRange(p.Version, $"package '{id}'")); + }).ToList(); + + var core = dto.Core is null ? null : new KitFilterCore(RequireRange(dto.Core.Version, "core"), dto.Core.Prerelease); + + return new KitFilter(tier, name, extends, sources, packages, parts, core); + } + + private static string? RequireRange(string? version, string owner) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + return VersionRange.TryParse(version, out _) + ? version + : throw new KitDocumentException(PackagePath, $"{owner} version '{version}' is not a NuGet version range"); + } + + private sealed class Dto + { + public int? Schema { get; init; } + public string? Tier { get; init; } + public string? Name { get; init; } + public string? Extends { get; init; } + public SourceDto[]? Sources { get; init; } + public PackageDto[]? Packages { get; init; } + public PartsDto? Parts { get; init; } + public CoreDto? Core { get; init; } + } + + private sealed class SourceDto + { + public string? Name { get; init; } + public string? Url { get; init; } + public string[]? Patterns { get; init; } + } + + private sealed class PackageDto + { + public string? Id { get; init; } + public string? Version { get; init; } + } + + private sealed class PartsDto + { + public string[]? Enable { get; init; } + public string[]? Disable { get; init; } + public string[]? Locked { get; init; } + } + + private sealed class CoreDto + { + public string? Version { get; init; } + public bool? Prerelease { get; init; } + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterCore.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterCore.cs new file mode 100644 index 0000000..61984d2 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterCore.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// The core version range and prerelease policy a company allows. +internal sealed record KitFilterCore(string? Version, bool? Prerelease); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterPackage.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterPackage.cs new file mode 100644 index 0000000..1951141 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterPackage.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// A package a filter attaches, with an optional NuGet version range. +internal sealed record KitFilterPackage(string Id, string? Version); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterParts.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterParts.cs new file mode 100644 index 0000000..e17b5de --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterParts.cs @@ -0,0 +1,7 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// The parts a filter switches on or off; (company only) freezes them for the later layers. +internal sealed record KitFilterParts(IReadOnlyList Enable, IReadOnlyList Disable, IReadOnlyList Locked) +{ + public static KitFilterParts Empty { get; } = new([], [], []); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterSource.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterSource.cs new file mode 100644 index 0000000..8b7fb95 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitFilterSource.cs @@ -0,0 +1,4 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// A NuGet source a filter's packages come from; become its package source mapping. +internal sealed record KitFilterSource(string Name, string Url, IReadOnlyList Patterns); diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPackageMetadata.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPackageMetadata.cs new file mode 100644 index 0000000..bd0ead1 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPackageMetadata.cs @@ -0,0 +1,42 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// mskit.package.json: what makes a NuGet package a kit package. +internal sealed record KitPackageMetadata(string Id, KitTier Tier, int Order, string? ContentHash) +{ + public const string PackagePath = "mskit.package.json"; + public const int Schema = 1; + + public static KitPackageMetadata Parse(string json) + { + var root = KitDocumentFormat.ReadRoot(PackagePath, json); + KitDocumentFormat.RequireSchema(PackagePath, root, Schema); + var dto = KitDocumentFormat.Deserialize(PackagePath, root); + + return new KitPackageMetadata( + KitDocumentFormat.Required(PackagePath, dto.Id, "id"), + KitDocumentFormat.ParseTier(PackagePath, dto.Tier), + dto.Order ?? throw new KitDocumentException(PackagePath, "order is missing"), + string.IsNullOrEmpty(dto.ContentHash) ? null : dto.ContentHash); + } + + public string ToJson() => KitDocumentFormat.Write(writer => + { + writer.WriteStartObject(); + writer.WriteNumber("schema", Schema); + writer.WriteString("id", Id); + writer.WriteString("tier", KitDocumentFormat.TierName(Tier)); + writer.WriteNumber("order", Order); + if (ContentHash is not null) + writer.WriteString("contentHash", ContentHash); + writer.WriteEndObject(); + }); + + private sealed class Dto + { + public int? Schema { get; init; } + public string? Id { get; init; } + public string? Tier { get; init; } + public int? Order { get; init; } + public string? ContentHash { get; init; } + } +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPartKind.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPartKind.cs new file mode 100644 index 0000000..0cac00c --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitPartKind.cs @@ -0,0 +1,14 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// How a core part is selected when no layer names it. +internal enum KitPartKind +{ + /// Always on; no layer may disable it. + Required, + + /// On unless a layer disables it. + Default, + + /// Off unless a layer enables it. + Optional, +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitTier.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitTier.cs new file mode 100644 index 0000000..07c1fc5 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/KitTier.cs @@ -0,0 +1,9 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// Who publishes a kit package; a later tier overrides an earlier one. +internal enum KitTier +{ + Core, + Company, + Team, +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartDecision.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartDecision.cs new file mode 100644 index 0000000..6a90b33 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartDecision.cs @@ -0,0 +1,10 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// +/// One part's effective state and the layer that decided it — the data behind parts --explain. +/// names the enabled part that pulled this one in through requires. +/// +internal sealed record PartDecision(KitCatalogPart Part, bool Enabled, SelectionLayer DecidedBy, string? RequiredBy, bool Locked) +{ + public string Id => Part.Id; +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartToggles.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartToggles.cs new file mode 100644 index 0000000..a303e10 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/PartToggles.cs @@ -0,0 +1,7 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// The repository layer's part switches, from kit.json. +internal sealed record PartToggles(IReadOnlyList Enable, IReadOnlyList Disable) +{ + public static PartToggles Empty { get; } = new([], []); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/Selection.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/Selection.cs new file mode 100644 index 0000000..5656b3b --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/Selection.cs @@ -0,0 +1,134 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// +/// Computes the effective parts: core catalog defaults, then the company filter, the team filter and the repository's +/// kit.json, a later layer winning per part; then closes the result over requires. +/// +internal static class Selection +{ + private const string RepositoryName = "the repository (kit.json)"; + + public static SelectionResult Compute(KitCatalog catalog, IReadOnlyList filters, PartToggles repository) + { + var (company, team) = ResolveFilters(filters); + + var states = catalog.Parts.ToDictionary( + p => p.Id, + p => new PartState(p, p.Kind != KitPartKind.Optional, SelectionLayer.Core, "the core catalog"), + StringComparer.Ordinal); + var locked = new HashSet(StringComparer.Ordinal); + string? lockOwner = null; + + if (company is not null) + { + var who = Describe(company); + Apply(catalog, states, SelectionLayer.Company, who, company.Filter.Parts.Enable, company.Filter.Parts.Disable, locked, lockOwner); + foreach (var id in company.Filter.Parts.Locked) + locked.Add(RequireKnown(catalog, SelectionLayer.Company, who, id).Id); + lockOwner = who; + } + + if (team is not null) + Apply(catalog, states, SelectionLayer.Team, Describe(team), team.Filter.Parts.Enable, team.Filter.Parts.Disable, locked, lockOwner); + + Apply(catalog, states, SelectionLayer.Repository, RepositoryName, repository.Enable, repository.Disable, locked, lockOwner); + + CloseOverRequires(catalog, states); + + var decisions = catalog.Parts + .Select(p => states[p.Id]) + .Select(s => new PartDecision(s.Part, s.Enabled, s.Layer, s.RequiredBy, locked.Contains(s.Part.Id))) + .ToList(); + var packages = new[] { company, team } + .Where(f => f is not null) + .SelectMany(f => f!.Filter.Packages) + .ToList(); + return new SelectionResult(decisions, company, team, packages); + } + + private static (AttachedFilter? Company, AttachedFilter? Team) ResolveFilters(IReadOnlyList filters) + { + if (filters.FirstOrDefault(f => f.Filter.Tier == KitTier.Core) is { } core) + throw new ArgumentException($"'{core.PackageId}' carries a core-tier filter", nameof(filters)); + + var companies = filters.Where(f => f.Filter.Tier == KitTier.Company).ToList(); + if (companies.Count > 1) + throw new SelectionException(SelectionLayer.Company, + $"at most one company filter may be attached, found {string.Join(", ", companies.Select(f => $"'{f.PackageId}'"))}"); + + var teams = filters.Where(f => f.Filter.Tier == KitTier.Team).ToList(); + if (teams.Count > 1) + throw new SelectionException(SelectionLayer.Team, + $"at most one team filter may be attached, found {string.Join(", ", teams.Select(f => $"'{f.PackageId}'"))}"); + + var company = companies.SingleOrDefault(); + var team = teams.SingleOrDefault(); + if (team is not null) + { + var extends = team.Filter.Extends; + if (company is null) + throw new SelectionException(SelectionLayer.Team, + $"{Describe(team)} extends '{extends}', which is not attached"); + if (!string.Equals(extends, company.PackageId, StringComparison.OrdinalIgnoreCase)) + throw new SelectionException(SelectionLayer.Team, + $"{Describe(team)} extends '{extends}', but the attached company filter is '{company.PackageId}'"); + } + + return (company, team); + } + + private static void Apply( + KitCatalog catalog, + Dictionary states, + SelectionLayer layer, + string who, + IReadOnlyList enable, + IReadOnlyList disable, + HashSet locked, + string? lockOwner) + { + var named = new HashSet(StringComparer.Ordinal); + foreach (var (id, on) in enable.Select(id => (id, true)).Concat(disable.Select(id => (id, false)))) + { + var part = RequireKnown(catalog, layer, who, id); + if (!named.Add(id)) + throw new SelectionException(layer, $"{who} lists part '{id}' twice in enable/disable"); + if (locked.Contains(id)) + throw new SelectionException(layer, $"{who} cannot {(on ? "enable" : "disable")} part '{id}': {lockOwner} locked it"); + if (!on && part.Kind == KitPartKind.Required) + throw new SelectionException(layer, $"{who} cannot disable part '{id}': it is required"); + + states[id] = new PartState(part, on, layer, who); + } + } + + private static void CloseOverRequires(KitCatalog catalog, Dictionary states) + { + var pending = new Queue(catalog.Parts.Select(p => states[p.Id]).Where(s => s.Enabled)); + while (pending.TryDequeue(out var requirer)) + { + foreach (var id in requirer.Part.Requires) + { + var required = states[id]; + if (required.Enabled) + continue; + if (required.Layer != SelectionLayer.Core) + throw new SelectionException( + (SelectionLayer)Math.Max((int)requirer.Layer, (int)required.Layer), + $"part '{requirer.Part.Id}' ({requirer.Who}) requires part '{id}', which {required.Who} disabled"); + + var enabled = new PartState(required.Part, true, requirer.Layer, requirer.Who, requirer.Part.Id); + states[id] = enabled; + pending.Enqueue(enabled); + } + } + } + + private static KitCatalogPart RequireKnown(KitCatalog catalog, SelectionLayer layer, string who, string id) => + catalog.Find(id) ?? throw new SelectionException(layer, $"{who} names unknown part '{id}'"); + + private static string Describe(AttachedFilter filter) => + $"the {KitDocumentFormat.TierName(filter.Filter.Tier)} filter '{filter.PackageId}'"; + + private sealed record PartState(KitCatalogPart Part, bool Enabled, SelectionLayer Layer, string Who, string? RequiredBy = null); +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionException.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionException.cs new file mode 100644 index 0000000..5bd3064 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionException.cs @@ -0,0 +1,7 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// A layer asked for a selection the rules refuse; names it. +internal sealed class SelectionException(SelectionLayer layer, string message) : Exception(message) +{ + public SelectionLayer Layer { get; } = layer; +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionLayer.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionLayer.cs new file mode 100644 index 0000000..1552236 --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionLayer.cs @@ -0,0 +1,10 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// The layers of the selection, in precedence order: a later layer wins per part. +public enum SelectionLayer +{ + Core, + Company, + Team, + Repository, +} diff --git a/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionResult.cs b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionResult.cs new file mode 100644 index 0000000..fc75b3b --- /dev/null +++ b/manager/src/DragoAnt.MSBuildKit.Manager/Packages/SelectionResult.cs @@ -0,0 +1,14 @@ +namespace DragoAnt.MSBuildKit.Manager.Packages; + +/// The effective selection: every catalog part in order, the attached filters, and the packages they add. +internal sealed record SelectionResult( + IReadOnlyList Parts, + AttachedFilter? Company, + AttachedFilter? Team, + IReadOnlyList Packages) +{ + public IEnumerable EnabledParts => Parts.Where(p => p.Enabled).Select(p => p.Part); + + public PartDecision Decision(string id) => + Parts.FirstOrDefault(p => p.Id == id) ?? throw new KeyNotFoundException($"part '{id}' is not in the catalog"); +} From a88e8ae799781a14ac93a02efbf2c45b5165da68 Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:23:32 +0200 Subject: [PATCH 4/4] Note the manager's package model in the changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6deed4d..b72994d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project are documented here. The format follows [Kee - Every warning and error links to its section of the [code reference](./docs/reference/codes.md) (`HelpLink`, shown by the terminal logger and IDEs). `MSKit_CodesHelpBaseUrl` points the links at another copy of the page. - `MSKit_DefaultPackageIconUrl`: when the kit packs its own icon, this URL is also written as `PackageIconUrl`, so clients that predate embedded icons show it; nuget.org keeps showing the embedded one. Empty by default; an owner sets it in its owner layer. `MSKITPKG008` now reports only a `PackageIconUrl` the project sets itself. See [docs/packaging.md](./docs/packaging.md#package-metadata). - `manager/`: the first build of `mskit-manager`, the `DragoAnt.MSBuildKit.Manager` .NET tool (`net8.0`, `net10.0`, `RollForward=Major`) that will install, update and migrate the kit. This build has one command, `status [--json]`, which prints the tool version; the logo goes to stderr, only on a terminal and never with `--no-logo`, so `--json` output always parses. Each run writes a log under `/mskit-manager/logs/`, named after the command, newest 20 kept. Not published yet. +- `manager/`: the package model behind the coming `install` — readers for `mskit.package.json`, the trunk's `catalog.json` and a company or team `filter.json`; the part selection (core defaults, then the company filter, the team filter and the repository's `kit.json`, a later layer winning per part, with required and company-locked parts refused and `requires` closed); `kit.json` schema 2, which also reads the schema 1 that `update.sh` writes and keeps a `parts` array spread over several lines; and the install manifest. No command uses them yet. ### Changed