diff --git a/bicep-types.sln b/bicep-types.sln index 9a5ad3c2..8a914816 100644 --- a/bicep-types.sln +++ b/bicep-types.sln @@ -7,8 +7,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DD29C249-759 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types", "src\Bicep.Types\Bicep.Types.csproj", "{2C152898-5869-453F-8EE1-8B531A031B19}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types.Validation", "src\Bicep.Types.Validation\Bicep.Types.Validation.csproj", "{6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types.UnitTests", "src\Bicep.Types.UnitTests\Bicep.Types.UnitTests.csproj", "{E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types.Validation.UnitTests", "src\Bicep.Types.Validation.UnitTests\Bicep.Types.Validation.UnitTests.csproj", "{7E894B3C-B6EF-4B42-B616-076558A7C446}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -22,13 +26,23 @@ Global {2C152898-5869-453F-8EE1-8B531A031B19}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C152898-5869-453F-8EE1-8B531A031B19}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C152898-5869-453F-8EE1-8B531A031B19}.Release|Any CPU.Build.0 = Release|Any CPU + {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Release|Any CPU.Build.0 = Release|Any CPU {E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Debug|Any CPU.Build.0 = Debug|Any CPU {E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Release|Any CPU.ActiveCfg = Release|Any CPU {E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Release|Any CPU.Build.0 = Release|Any CPU + {7E894B3C-B6EF-4B42-B616-076558A7C446}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E894B3C-B6EF-4B42-B616-076558A7C446}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E894B3C-B6EF-4B42-B616-076558A7C446}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E894B3C-B6EF-4B42-B616-076558A7C446}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {2C152898-5869-453F-8EE1-8B531A031B19} = {DD29C249-759E-4F30-B9B1-A47CB337F870} + {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B} = {DD29C249-759E-4F30-B9B1-A47CB337F870} {E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF} = {DD29C249-759E-4F30-B9B1-A47CB337F870} + {7E894B3C-B6EF-4B42-B616-076558A7C446} = {DD29C249-759E-4F30-B9B1-A47CB337F870} EndGlobalSection EndGlobal diff --git a/src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj b/src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj new file mode 100644 index 00000000..1a407221 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypePackageValidationResultTests.cs b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypePackageValidationResultTests.cs new file mode 100644 index 00000000..63af2c1c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypePackageValidationResultTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Diagnostics; + +[TestClass] +public class TypePackageValidationResultTests +{ + [TestMethod] + public void Result_is_valid_when_there_are_no_error_diagnostics() + { + var result = Create(new[] { Warning() }, new TypePackageValidationOptions()); + + result.IsValid.Should().BeTrue(); + } + + [TestMethod] + public void Result_is_invalid_when_an_error_diagnostic_exists() + { + var result = Create(new[] { Error() }, new TypePackageValidationOptions()); + + result.IsValid.Should().BeFalse(); + } + + [TestMethod] + public void Summary_counts_all_detected_diagnostics_before_filtering_and_truncation() + { + var options = new TypePackageValidationOptions + { + IncludeWarnings = false, + IncludeInformationalDiagnostics = false, + MaxDiagnostics = 1, + }; + + var result = Create(new[] { Error(), Warning(), Information() }, options); + + result.Summary.ErrorCount.Should().Be(1); + result.Summary.WarningCount.Should().Be(1); + result.Summary.InfoCount.Should().Be(1); + } + + [TestMethod] + public void IsValid_reflects_detected_errors_even_when_filtered_or_truncated() + { + var options = new TypePackageValidationOptions { MaxDiagnostics = 1 }; + + var result = Create(new[] { Error(), Warning(), Warning() }, options); + + result.IsValid.Should().BeFalse(); + } + + [TestMethod] + public void Warning_filtering_affects_only_returned_diagnostics() + { + var options = new TypePackageValidationOptions { IncludeWarnings = false }; + + var result = Create(new[] { Warning(), Error() }, options); + + result.Diagnostics.Should().OnlyContain(d => d.Severity != TypeValidationDiagnosticSeverity.Warning); + result.Summary.WarningCount.Should().Be(1); + } + + [TestMethod] + public void Informational_diagnostics_are_excluded_by_default_but_counted() + { + var result = Create(new[] { Information() }, new TypePackageValidationOptions()); + + result.Diagnostics.Should().BeEmpty(); + result.Summary.InfoCount.Should().Be(1); + } + + [TestMethod] + public void Informational_diagnostics_are_returned_when_requested() + { + var options = new TypePackageValidationOptions { IncludeInformationalDiagnostics = true }; + + var result = Create(new[] { Information() }, options); + + result.Diagnostics.Should().ContainSingle(); + } + + [TestMethod] + public void Null_max_diagnostics_applies_no_cap() + { + var options = new TypePackageValidationOptions { MaxDiagnostics = null }; + + var result = Create(new[] { Error("BCPVT100", "a.json"), Error("BCPVT101", "b.json"), Error("BCPVT102", "c.json") }, options); + + result.DiagnosticsTruncated.Should().BeFalse(); + result.Diagnostics.Should().HaveCount(3); + } + + [TestMethod] + public void Positive_max_diagnostics_truncates_and_sets_flag() + { + var options = new TypePackageValidationOptions { MaxDiagnostics = 2 }; + + var result = Create(new[] { Error("BCPVT100", "a.json"), Error("BCPVT101", "b.json"), Error("BCPVT102", "c.json") }, options); + + result.DiagnosticsTruncated.Should().BeTrue(); + result.Diagnostics.Should().HaveCount(2); + result.Summary.ErrorCount.Should().Be(3); + } + + [TestMethod] + public void Max_diagnostics_not_exceeded_leaves_truncation_false() + { + var options = new TypePackageValidationOptions { MaxDiagnostics = 5 }; + + var result = Create(new[] { Error("BCPVT100", "a.json"), Error("BCPVT101", "b.json") }, options); + + result.DiagnosticsTruncated.Should().BeFalse(); + result.Diagnostics.Should().HaveCount(2); + } + + [TestMethod] + public void Returned_diagnostics_are_sorted() + { + var later = new TypeValidationDiagnostic("BCPVT200", TypeValidationDiagnosticSeverity.Error, "b", path: "types.json", line: 2, column: 1); + var earlier = new TypeValidationDiagnostic("BCPVT100", TypeValidationDiagnosticSeverity.Error, "a", path: "index.json", line: 1, column: 1); + + var result = Create(new[] { later, earlier }, new TypePackageValidationOptions()); + + result.Diagnostics[0].Should().BeSameAs(earlier); + result.Diagnostics[1].Should().BeSameAs(later); + } + + private static TypePackageValidationResult Create( + IEnumerable diagnostics, + TypePackageValidationOptions options) + => TypePackageValidationResult.Create(TypePackageValidationMode.CanonicalWriter, diagnostics, options); + + private static TypeValidationDiagnostic Error(string code = "BCPVT001", string? path = null) + => new(code, TypeValidationDiagnosticSeverity.Error, "error", path: path); + + private static TypeValidationDiagnostic Warning(string code = "BCPVT500", string? path = null) + => new(code, TypeValidationDiagnosticSeverity.Warning, "warning", path: path); + + private static TypeValidationDiagnostic Information(string code = "BCPVT900", string? path = null) + => new(code, TypeValidationDiagnosticSeverity.Info, "info", path: path); +} diff --git a/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticTests.cs b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticTests.cs new file mode 100644 index 00000000..4c251a5d --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Diagnostics; + +[TestClass] +public class TypeValidationDiagnosticTests +{ + [TestMethod] + public void Diagnostics_sort_deterministically_by_path_line_column_and_code() + { + var typesLater = Diag("BCPVT200", path: "types.json", line: 5, column: 2); + var typesLaterHigherCode = Diag("BCPVT201", path: "types.json", line: 5, column: 2); + var indexFirst = Diag("BCPVT100", path: "index.json", line: 1, column: 1); + + var list = new List { typesLater, typesLaterHigherCode, indexFirst }; + list.Sort(TypeValidationDiagnosticComparer.Instance); + + list.Should().ContainInOrder(indexFirst, typesLater, typesLaterHigherCode); + } + + [TestMethod] + public void Input_level_diagnostics_sort_before_file_level_diagnostics() + { + var inputLevel = Diag("BCPVT001", path: null); + var fileLevel = Diag("BCPVT100", path: "index.json", line: 1, column: 1); + + var list = new List { fileLevel, inputLevel }; + list.Sort(TypeValidationDiagnosticComparer.Instance); + + list[0].Should().BeSameAs(inputLevel); + list[1].Should().BeSameAs(fileLevel); + } + + [TestMethod] + public void Related_locations_are_preserved() + { + var related = new TypeValidationDiagnosticRelatedLocation( + message: "declared here", + path: "types.json", + jsonPointer: "/0", + line: 3, + column: 1); + + var diagnostic = new TypeValidationDiagnostic( + "BCPVT400", + TypeValidationDiagnosticSeverity.Error, + "wrong target kind", + path: "index.json", + relatedLocations: new[] { related }); + + diagnostic.RelatedLocations.Should().ContainSingle() + .Which.Message.Should().Be("declared here"); + } + + [TestMethod] + public void Diagnostic_defaults_to_no_related_locations() + { + var diagnostic = new TypeValidationDiagnostic( + "BCPVT100", + TypeValidationDiagnosticSeverity.Error, + "message"); + + diagnostic.RelatedLocations.Should().BeEmpty(); + } + + private static TypeValidationDiagnostic Diag(string code, string? path, int? line = null, int? column = null) + => new(code, TypeValidationDiagnosticSeverity.Error, $"message for {code}", path: path, line: line, column: column); +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/canonicalWriter.result.json new file mode 100644 index 00000000..c2edcb35 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT022", + "severity": "error", + "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses reserved legacy built-in kind 8 (\u0027ResourceRef\u0027), which CanonicalWriter packages must not emit.", + "path": "types.json", + "jsonPointer": "/2/kind", + "line": 16, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/compatibleReader.result.json new file mode 100644 index 00000000..c4616f90 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT023", + "severity": "warning", + "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses reserved legacy built-in kind 8 (\u0027ResourceRef\u0027), accepted only for CompatibleReader mode.", + "path": "types.json", + "jsonPointer": "/2/kind", + "line": 16, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/types.json new file mode 100644 index 00000000..24fdf2ab --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/types.json @@ -0,0 +1,18 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + }, + { + "$type": "BuiltInType", + "kind": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/scenario.json new file mode 100644 index 00000000..edc2f73c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/scenario.json @@ -0,0 +1 @@ +{"name":"builtin-type-resource-ref","description":"A BuiltInType uses the reserved legacy kind 8 (ResourceRef), which has no canonical replacement: rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/canonicalWriter.result.json new file mode 100644 index 00000000..e0e8b8c8 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT022", + "severity": "error", + "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses legacy built-in kind 5 (\u0027String\u0027). CanonicalWriter packages must use \u0027StringType\u0027.", + "path": "types.json", + "jsonPointer": "/2/kind", + "line": 16, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/compatibleReader.result.json new file mode 100644 index 00000000..a6380e8f --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT023", + "severity": "warning", + "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses legacy built-in kind 5 (\u0027String\u0027) accepted only for CompatibleReader mode. Prefer \u0027StringType\u0027.", + "path": "types.json", + "jsonPointer": "/2/kind", + "line": 16, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/types.json new file mode 100644 index 00000000..643037b3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/types.json @@ -0,0 +1,18 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + }, + { + "$type": "BuiltInType", + "kind": 5 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/scenario.json new file mode 100644 index 00000000..677cc04a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/scenario.json @@ -0,0 +1 @@ +{"name":"builtin-type-string","description":"A BuiltInType uses a documented legacy kind (String): rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/canonicalWriter.result.json new file mode 100644 index 00000000..c3ed6a04 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/canonicalWriter.result.json @@ -0,0 +1,39 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT022", + "severity": "error", + "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/flags", + "line": 6, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 3, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/compatibleReader.result.json new file mode 100644 index 00000000..49cf98f2 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT023", + "severity": "warning", + "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/flags", + "line": 6, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/types.json new file mode 100644 index 00000000..dcff6f33 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/types.json @@ -0,0 +1,13 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "flags": 1 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/scenario.json new file mode 100644 index 00000000..206c9508 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/scenario.json @@ -0,0 +1 @@ +{"name":"resource-scope-flags-read-only","description":"A ResourceType uses only the legacy flags scope field: rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/canonicalWriter.result.json new file mode 100644 index 00000000..cda4a6fb --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT022", + "severity": "error", + "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/flags", + "line": 8, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/compatibleReader.result.json new file mode 100644 index 00000000..c9de5629 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT023", + "severity": "warning", + "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/flags", + "line": 8, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/types.json new file mode 100644 index 00000000..de4e3b4e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/types.json @@ -0,0 +1,15 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "readableScopes": 8, + "writableScopes": 8, + "flags": 0 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/scenario.json new file mode 100644 index 00000000..8170f349 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/scenario.json @@ -0,0 +1 @@ +{"name":"resource-scope-modern-plus-flags-zero","description":"A ResourceType carries the modern scope pair plus flags:0. flags:0 is not effective-legacy so this is not mixing; the flags field is classified per-field (error in CanonicalWriter, warning in CompatibleReader).","category":"compatibility","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/canonicalWriter.result.json new file mode 100644 index 00000000..25632b09 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/canonicalWriter.result.json @@ -0,0 +1,48 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT022", + "severity": "error", + "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/scopeType", + "line": 6, + "column": 5 + }, + { + "code": "BCPVT022", + "severity": "error", + "message": "Property \u0027readOnlyScopes\u0027 at \u0027/0/readOnlyScopes\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/readOnlyScopes", + "line": 7, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 4, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/compatibleReader.result.json new file mode 100644 index 00000000..8a81ea98 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/compatibleReader.result.json @@ -0,0 +1,30 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT023", + "severity": "warning", + "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/scopeType", + "line": 6, + "column": 5 + }, + { + "code": "BCPVT023", + "severity": "warning", + "message": "Property \u0027readOnlyScopes\u0027 at \u0027/0/readOnlyScopes\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/readOnlyScopes", + "line": 7, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 2, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/types.json new file mode 100644 index 00000000..62affd6d --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/types.json @@ -0,0 +1,14 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "scopeType": 0, + "readOnlyScopes": 1 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/scenario.json new file mode 100644 index 00000000..ff5c2b47 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/scenario.json @@ -0,0 +1 @@ +{"name":"resource-scope-read-only-scopes","description":"A ResourceType uses the legacy scopeType plus readOnlyScopes scope fields (no modern pair): both are rejected in CanonicalWriter and accepted with warnings in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/canonicalWriter.result.json new file mode 100644 index 00000000..1a5d9a12 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/canonicalWriter.result.json @@ -0,0 +1,39 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT022", + "severity": "error", + "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/scopeType", + "line": 6, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 3, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/compatibleReader.result.json new file mode 100644 index 00000000..8c64d115 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT023", + "severity": "warning", + "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/scopeType", + "line": 6, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/types.json new file mode 100644 index 00000000..b92f1b72 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/types.json @@ -0,0 +1,13 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "scopeType": 4 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/scenario.json new file mode 100644 index 00000000..26131f27 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/scenario.json @@ -0,0 +1 @@ +{"name":"resource-scope-type","description":"A ResourceType uses only the legacy scopeType scope field: rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/canonicalWriter.result.json new file mode 100644 index 00000000..1a5d9a12 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/canonicalWriter.result.json @@ -0,0 +1,39 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + }, + { + "code": "BCPVT022", + "severity": "error", + "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/scopeType", + "line": 6, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 3, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/compatibleReader.result.json new file mode 100644 index 00000000..8c64d115 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT023", + "severity": "warning", + "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.", + "path": "types.json", + "jsonPointer": "/0/scopeType", + "line": 6, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/types.json new file mode 100644 index 00000000..3503d1a2 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/types.json @@ -0,0 +1,13 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "scopeType": 0 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/scenario.json new file mode 100644 index 00000000..f7a6fde2 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/scenario.json @@ -0,0 +1 @@ +{"name":"resource-scope-zero","description":"A ResourceType uses only the legacy scopeType field set to 0: still classified (rejected in CanonicalWriter, warned in CompatibleReader), confirming scopeType:0 is effective-legacy unlike flags:0.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/expected/canonicalWriter.result.json new file mode 100644 index 00000000..fa55bc48 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT026", + "severity": "error", + "message": "IntegerType at '/2' in 'types.json' has minValue 10 greater than maxValue 5.", + "path": "types.json", + "jsonPointer": "/2", + "line": 27, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/index.json new file mode 100644 index 00000000..698ceb39 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/1" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/types.json new file mode 100644 index 00000000..19d14c4a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/types.json @@ -0,0 +1,29 @@ +[ + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "size": { + "type": { + "$ref": "#/2" + }, + "flags": 0, + "description": "The widget size." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/0" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "IntegerType", + "minValue": 10, + "maxValue": 5 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/scenario.json new file mode 100644 index 00000000..bab399fe --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/scenario.json @@ -0,0 +1,14 @@ +{ + "name": "archive-member-location", + "description": "A reachable defect inside an archive member reports the package-relative member path (types.json) and json pointer, never a temp extraction path.", + "category": "diagnostic-quality", + "inputs": [ + { + "kind": "archiveFile", + "path": "package.tgz" + } + ], + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/expected/canonicalWriter.result.json new file mode 100644 index 00000000..602000f9 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/expected/canonicalWriter.result.json @@ -0,0 +1,17 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT002", + "severity": "error", + "message": "The package at '/package.tgz' does not contain an 'index.json' file at the package root." + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/package/types.json new file mode 100644 index 00000000..196130a3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/package/types.json @@ -0,0 +1,5 @@ +[ + { + "$type": "StringType" + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/scenario.json new file mode 100644 index 00000000..44d3a9fb --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/scenario.json @@ -0,0 +1,14 @@ +{ + "name": "archive-missing-index", + "description": "A valid gzip/tar archive container that has no index.json at the package root reports the standard missing-index diagnostic.", + "category": "invalid.archive", + "inputs": [ + { + "kind": "archiveFile", + "path": "package.tgz" + } + ], + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/expected/canonicalWriter.result.json new file mode 100644 index 00000000..e433ba25 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT017", + "severity": "error", + "message": "Reference at '/resourceFunctions/Sample.Provider~1widgets/2026-01-01/0/$ref' in 'index.json' targets missing type file 'functions.json'.", + "path": "index.json", + "jsonPointer": "/resourceFunctions/Sample.Provider~1widgets/2026-01-01/0/$ref", + "line": 11, + "column": 19 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/index.json new file mode 100644 index 00000000..b54d655a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/index.json @@ -0,0 +1,17 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/1" + } + }, + "resourceFunctions": { + "Sample.Provider/widgets": { + "2026-01-01": [ + { + "$ref": "functions.json#/0" + } + ] + } + }, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/types.json new file mode 100644 index 00000000..e34d790d --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/types.json @@ -0,0 +1,16 @@ +[ + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": {} + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/0" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/scenario.json new file mode 100644 index 00000000..980c88ab --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/scenario.json @@ -0,0 +1,14 @@ +{ + "name": "archive-resource-function-missing-type-file", + "description": "An archive whose index.json declares a resourceFunctions root that references a type file absent from the archive reports BCPVT017. Guards the known archive-writer gap where resource-function-only type files can be omitted.", + "category": "invalid.graph", + "inputs": [ + { + "kind": "archiveFile", + "path": "package.tgz" + } + ], + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..87f1b148 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Settings configurationType must reference an object type (\u0027ObjectType\u0027 or \u0027DiscriminatedObjectType\u0027), but the target is \u0027StringType\u0027.", + "path": "index.json", + "jsonPointer": "/settings/configurationType/$ref", + "line": 10, + "column": 15, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/index.json new file mode 100644 index 00000000..159fe66e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/index.json @@ -0,0 +1,13 @@ +{ + "resources": {}, + "resourceFunctions": {}, + "namespaceFunctions": [], + "settings": { + "name": "SampleConfig", + "isSingleton": true, + "version": "1.0.0", + "configurationType": { + "$ref": "types.json#/0" + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/types.json new file mode 100644 index 00000000..196130a3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/types.json @@ -0,0 +1,5 @@ +[ + { + "$type": "StringType" + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/scenario.json new file mode 100644 index 00000000..e67e3b47 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "configuration-type-target-kind", + "description": "The settings.configurationType index entry references a non-object-like type.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..7be65b7e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT021", + "severity": "error", + "message": "Reference at \u0027/1/elements/a/$ref\u0027 in \u0027types.json\u0027 for role \u0027discriminated object element\u0027 must target an object type (\u0027ObjectType\u0027), but the target is \u0027StringType\u0027.", + "path": "types.json", + "jsonPointer": "/1/elements/a/$ref", + "line": 18, + "column": 17, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/2", + "line": 22, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/types.json new file mode 100644 index 00000000..5ce37ea5 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/types.json @@ -0,0 +1,25 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "DiscriminatedObjectType", + "name": "disc", + "discriminator": "kind", + "baseProperties": {}, + "elements": { + "a": { + "$ref": "#/2" + } + } + }, + { + "$type": "StringType" + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/scenario.json new file mode 100644 index 00000000..f6967c3a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "discriminated-object-element-target-kind", + "description": "A DiscriminatedObjectType element references a non-ObjectType.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..38cce742 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Fallback resource type must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027ObjectType\u0027.", + "path": "index.json", + "jsonPointer": "/fallbackResourceType/$ref", + "line": 6, + "column": 13, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/index.json new file mode 100644 index 00000000..8555fd57 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/index.json @@ -0,0 +1,8 @@ +{ + "resources": {}, + "resourceFunctions": {}, + "namespaceFunctions": [], + "fallbackResourceType": { + "$ref": "types.json#/0" + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/types.json new file mode 100644 index 00000000..7c28dfc0 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/types.json @@ -0,0 +1,7 @@ +[ + { + "$type": "ObjectType", + "name": "notAResource", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/scenario.json new file mode 100644 index 00000000..ed18adce --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "fallback-resource-target-kind", + "description": "The fallbackResourceType index entry references a non-ResourceType object.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..ebab8792 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,48 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT021", + "severity": "error", + "message": "Reference at \u0027/2/parameters/0/type/$ref\u0027 in \u0027types.json\u0027 for role \u0027function parameter type\u0027 must target a value type, but the target is \u0027ResourceType\u0027.", + "path": "types.json", + "jsonPointer": "/2/parameters/0/type/$ref", + "line": 29, + "column": 19, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ] + }, + { + "code": "BCPVT021", + "severity": "error", + "message": "Reference at \u0027/2/output/$ref\u0027 in \u0027types.json\u0027 for role \u0027function output type\u0027 must target a value type, but the target is \u0027ResourceType\u0027.", + "path": "types.json", + "jsonPointer": "/2/output/$ref", + "line": 34, + "column": 15, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 2, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/types.json new file mode 100644 index 00000000..09c4be07 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/types.json @@ -0,0 +1,37 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "functions": { + "compute": { + "type": { + "$ref": "#/2" + } + } + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + }, + { + "$type": "FunctionType", + "parameters": [ + { + "name": "arg", + "type": { + "$ref": "#/0" + } + } + ], + "output": { + "$ref": "#/0" + } + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/scenario.json new file mode 100644 index 00000000..4ac1d05b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "function-signature-target-kind", + "description": "Graph traversal descends into a resource-type FunctionType signature: both a parameter type and the output reference a ResourceType (not a value type), each reported as BCPVT021.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/expected/canonicalWriter.result.json new file mode 100644 index 00000000..6fa083e6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT017", + "severity": "error", + "message": "Reference at \u0027/0/body/$ref\u0027 in \u0027types.json\u0027 targets missing type file \u0027missing.json\u0027.", + "path": "types.json", + "jsonPointer": "/0/body/$ref", + "line": 6, + "column": 15 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/types.json new file mode 100644 index 00000000..e7d685a3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/types.json @@ -0,0 +1,11 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "missing.json#/0" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/scenario.json new file mode 100644 index 00000000..c51d534d --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "missing-referenced-type-file", + "description": "A reference targets a type file that does not exist in the package.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..6a50b767 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Namespace function [0] must reference a namespace function type (\u0027NamespaceFunctionType\u0027), but the target is \u0027ObjectType\u0027.", + "path": "index.json", + "jsonPointer": "/namespaceFunctions/0/$ref", + "line": 6, + "column": 15, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/index.json new file mode 100644 index 00000000..ffd3a027 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": {}, + "resourceFunctions": {}, + "namespaceFunctions": [ + { + "$ref": "types.json#/0" + } + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/types.json new file mode 100644 index 00000000..f656c082 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/types.json @@ -0,0 +1,7 @@ +[ + { + "$type": "ObjectType", + "name": "notAFunction", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/scenario.json new file mode 100644 index 00000000..aad523af --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "namespace-function-target-kind", + "description": "A namespaceFunctions index entry references a non-NamespaceFunctionType object.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/expected/canonicalWriter.result.json new file mode 100644 index 00000000..2f1b4642 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT021", + "severity": "error", + "message": "Reference at \u0027/0/properties/self/type/$ref\u0027 in \u0027types.json\u0027 for role \u0027object property type\u0027 must target a value type, but the target is \u0027ResourceType\u0027.", + "path": "types.json", + "jsonPointer": "/0/properties/self/type/$ref", + "line": 8, + "column": 19, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/1", + "line": 15, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/index.json new file mode 100644 index 00000000..f6b37d47 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/1" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/types.json new file mode 100644 index 00000000..1e540af2 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/types.json @@ -0,0 +1,24 @@ +[ + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "self": { + "type": { + "$ref": "#/1" + }, + "flags": 0, + "description": "A property that incorrectly targets a resource type." + } + } + }, + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/0" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/scenario.json new file mode 100644 index 00000000..49f8b4b6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "object-property-targets-declaration-type", + "description": "An object property type references a resource type, which is not a value type.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..ffdc6e0b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT021", + "severity": "error", + "message": "Reference at \u0027/0/body/$ref\u0027 in \u0027types.json\u0027 for role \u0027resource body\u0027 must target an object type (\u0027ObjectType\u0027 or \u0027DiscriminatedObjectType\u0027), but the target is \u0027StringType\u0027.", + "path": "types.json", + "jsonPointer": "/0/body/$ref", + "line": 6, + "column": 15, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/1", + "line": 11, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/types.json new file mode 100644 index 00000000..01d1266a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/types.json @@ -0,0 +1,14 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "StringType" + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/scenario.json new file mode 100644 index 00000000..80a8c21f --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "resource-body-target-kind", + "description": "A ResourceType body references a non-object-like type.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..28c12b70 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Resource function \u0027My.Rp/things@2026-01-01[0]\u0027 must reference a resource function type (\u0027ResourceFunctionType\u0027), but the target is \u0027ObjectType\u0027.", + "path": "index.json", + "jsonPointer": "/resourceFunctions/My.Rp~1things/2026-01-01/0/$ref", + "line": 7, + "column": 19, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/index.json new file mode 100644 index 00000000..043e3233 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/index.json @@ -0,0 +1,13 @@ +{ + "resources": {}, + "resourceFunctions": { + "My.Rp/things": { + "2026-01-01": [ + { + "$ref": "types.json#/0" + } + ] + } + }, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/types.json new file mode 100644 index 00000000..f656c082 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/types.json @@ -0,0 +1,7 @@ +[ + { + "$type": "ObjectType", + "name": "notAFunction", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/scenario.json new file mode 100644 index 00000000..0d754bcf --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "resource-function-target-kind", + "description": "A resourceFunctions index entry references a non-ResourceFunctionType object.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/expected/canonicalWriter.result.json new file mode 100644 index 00000000..03ff5147 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT019", + "severity": "error", + "message": "Reference at \u0027/0/body/$ref\u0027 in \u0027types.json\u0027 targets index 99 in \u0027types.json\u0027, but the file contains 1 type objects.", + "path": "types.json", + "jsonPointer": "/0/body/$ref", + "line": 6, + "column": 15 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/types.json new file mode 100644 index 00000000..d6dba4fa --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/types.json @@ -0,0 +1,11 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/99" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/scenario.json new file mode 100644 index 00000000..32e2e2ce --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "same-file-reference-out-of-range", + "description": "A same-file reference names a type-object index that is out of range.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/expected/canonicalWriter.result.json new file mode 100644 index 00000000..3a73af9f --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/expected/canonicalWriter.result.json @@ -0,0 +1,30 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Resource entry \u0027My.Rp/things@2026-01-01\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027ObjectType\u0027.", + "path": "index.json", + "jsonPointer": "/resources/My.Rp~1things@2026-01-01/$ref", + "line": 4, + "column": 15, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ] + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/types.json new file mode 100644 index 00000000..7c28dfc0 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/types.json @@ -0,0 +1,7 @@ +[ + { + "$type": "ObjectType", + "name": "notAResource", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/scenario.json new file mode 100644 index 00000000..214c16ef --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "wrong-resource-target-kind", + "description": "A resource index entry references a non-resource type object.", + "category": "invalid.graph", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/expected/canonicalWriter.result.json new file mode 100644 index 00000000..a5f1a5ca --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/expected/canonicalWriter.result.json @@ -0,0 +1,18 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT035", + "severity": "error", + "message": "Package file 'README.md' is not a supported Bicep Types package file.", + "path": "README.md" + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/README.md b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/README.md new file mode 100644 index 00000000..61947d6a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/README.md @@ -0,0 +1 @@ +Release notes for this package. diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/index.json new file mode 100644 index 00000000..baf22b21 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": {}, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/scenario.json new file mode 100644 index 00000000..5e735c15 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/scenario.json @@ -0,0 +1,11 @@ +{ + "name": "unexpected-package-file", + "description": "With strict hygiene enabled, a non-JSON regular file in the package reports BCPVT035.", + "category": "invalid.hygiene", + "options": { + "validateUnreachableFiles": true + }, + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/expected/canonicalWriter.result.json new file mode 100644 index 00000000..241363b6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/expected/canonicalWriter.result.json @@ -0,0 +1,18 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT034", + "severity": "error", + "message": "Package file 'orphan.json' is not reachable from 'index.json' roots.", + "path": "orphan.json" + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/index.json new file mode 100644 index 00000000..baf22b21 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": {}, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/orphan.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/orphan.json new file mode 100644 index 00000000..196130a3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/orphan.json @@ -0,0 +1,5 @@ +[ + { + "$type": "StringType" + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/scenario.json new file mode 100644 index 00000000..c8e239a0 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/scenario.json @@ -0,0 +1,11 @@ +{ + "name": "unreachable-type-file", + "description": "With strict hygiene enabled, a JSON type file that is not reachable from index.json roots reports BCPVT034.", + "category": "invalid.hygiene", + "options": { + "validateUnreachableFiles": true + }, + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/canonicalWriter.result.json new file mode 100644 index 00000000..c92e6674 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT025", + "severity": "error", + "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 must be one of 1..8, but got 9.", + "path": "types.json", + "jsonPointer": "/2/kind", + "line": 16, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/compatibleReader.result.json new file mode 100644 index 00000000..5a05a203 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT025", + "severity": "error", + "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 must be one of 1..8, but got 9.", + "path": "types.json", + "jsonPointer": "/2/kind", + "line": 16, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/types.json new file mode 100644 index 00000000..faef3ae6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/types.json @@ -0,0 +1,18 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + }, + { + "$type": "BuiltInType", + "kind": 9 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/scenario.json new file mode 100644 index 00000000..3e6560e1 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/scenario.json @@ -0,0 +1 @@ +{"name":"builtin-type-kind-invalid","description":"A BuiltInType.kind is outside the documented 1..8 range, which is an error in both modes (BCPVT025).","category":"invalid.policy","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/canonicalWriter.result.json new file mode 100644 index 00000000..0518b3ff --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT024", + "severity": "error", + "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027flags\u0027. Use either the canonical modern pair or a documented legacy form, not both.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/compatibleReader.result.json new file mode 100644 index 00000000..9026b91f --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT024", + "severity": "error", + "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027flags\u0027. Use either the canonical modern pair or a documented legacy form, not both.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/types.json new file mode 100644 index 00000000..dc69772c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/types.json @@ -0,0 +1,15 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "readableScopes": 8, + "writableScopes": 8, + "flags": 1 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/scenario.json new file mode 100644 index 00000000..9c99df1c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/scenario.json @@ -0,0 +1 @@ +{"name":"resource-scope-mixed-modern-flags-read-only","description":"A ResourceType mixes the modern scope pair with the effective legacy flags:1 field, which is an error in both modes (BCPVT024).","category":"invalid.policy","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/canonicalWriter.result.json new file mode 100644 index 00000000..0d31f252 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT024", + "severity": "error", + "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027scopeType\u0027. Use either the canonical modern pair or a documented legacy form, not both.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/compatibleReader.result.json new file mode 100644 index 00000000..51e4c091 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT024", + "severity": "error", + "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027scopeType\u0027. Use either the canonical modern pair or a documented legacy form, not both.", + "path": "types.json", + "jsonPointer": "/0", + "line": 2, + "column": 3 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/index.json new file mode 100644 index 00000000..ae218123 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/index.json @@ -0,0 +1,5 @@ +{ + "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/types.json new file mode 100644 index 00000000..dd3557b8 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/types.json @@ -0,0 +1,15 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/x@2026-01-01", + "body": { "$ref": "#/1" }, + "readableScopes": 8, + "writableScopes": 8, + "scopeType": 0 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/scenario.json new file mode 100644 index 00000000..c313a11b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/scenario.json @@ -0,0 +1 @@ +{"name":"resource-scope-mixed-modern-legacy","description":"A ResourceType mixes the modern scope pair with the legacy scopeType field, which is an error in both modes (BCPVT024).","category":"invalid.policy","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/expected/canonicalWriter.result.json new file mode 100644 index 00000000..d255500c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT026", + "severity": "error", + "message": "ArrayType at \u0027/2\u0027 in \u0027types.json\u0027 has minLength 10 greater than maxLength 5.", + "path": "types.json", + "jsonPointer": "/2", + "line": 22, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/types.json new file mode 100644 index 00000000..3c28ee74 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/types.json @@ -0,0 +1,24 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/1" + }, + "minLength": 10, + "maxLength": 5 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/scenario.json new file mode 100644 index 00000000..d5907bcd --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "array-length-range-order", + "description": "An ArrayType has minLength greater than maxLength (BCPVT026); mode-independent.", + "category": "invalid.semantic", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/expected/canonicalWriter.result.json new file mode 100644 index 00000000..149e1cc5 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT026", + "severity": "error", + "message": "IntegerType at \u0027/2\u0027 in \u0027types.json\u0027 has minValue 10 greater than maxValue 5.", + "path": "types.json", + "jsonPointer": "/2", + "line": 19, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/types.json new file mode 100644 index 00000000..533c7071 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/types.json @@ -0,0 +1,21 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + }, + { + "$type": "IntegerType", + "minValue": 10, + "maxValue": 5 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/scenario.json new file mode 100644 index 00000000..c171bc9e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "integer-range-order", + "description": "An IntegerType has minValue greater than maxValue (BCPVT026); mode-independent.", + "category": "invalid.semantic", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/canonicalWriter.result.json new file mode 100644 index 00000000..9afc3323 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT029", + "severity": "error", + "message": "ObjectType property flags at \u0027/1/properties/name/flags\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.", + "path": "types.json", + "jsonPointer": "/1/properties/name/flags", + "line": 19, + "column": 9 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/compatibleReader.result.json new file mode 100644 index 00000000..845e3a63 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT029", + "severity": "warning", + "message": "ObjectType property flags at \u0027/1/properties/name/flags\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.", + "path": "types.json", + "jsonPointer": "/1/properties/name/flags", + "line": 19, + "column": 9 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/types.json new file mode 100644 index 00000000..6127f971 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/types.json @@ -0,0 +1,26 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": { + "name": { + "type": { + "$ref": "#/2" + }, + "flags": 32 + } + } + }, + { + "$type": "StringType" + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/scenario.json new file mode 100644 index 00000000..c1e2e208 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "object-property-flags-invalid", + "description": "An ObjectType property has flags with bits outside the known mask (BCPVT029): error in canonicalWriter, warning in compatibleReader.", + "category": "invalid.semantic", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/canonicalWriter.result.json new file mode 100644 index 00000000..57be179a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT029", + "severity": "error", + "message": "ResourceType readableScopes at \u0027/0/readableScopes\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.", + "path": "types.json", + "jsonPointer": "/0/readableScopes", + "line": 8, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/compatibleReader.result.json new file mode 100644 index 00000000..3ca0cbe3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT029", + "severity": "warning", + "message": "ResourceType readableScopes at \u0027/0/readableScopes\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.", + "path": "types.json", + "jsonPointer": "/0/readableScopes", + "line": 8, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/types.json new file mode 100644 index 00000000..990db980 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/types.json @@ -0,0 +1,16 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 32, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/scenario.json new file mode 100644 index 00000000..49e4de9f --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "readable-scope-bits-invalid", + "description": "A ResourceType readableScopes value has bits outside the known ScopeType mask (BCPVT029): error in canonicalWriter, warning in compatibleReader.", + "category": "invalid.semantic", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/expected/canonicalWriter.result.json new file mode 100644 index 00000000..aef5d508 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT027", + "severity": "error", + "message": "StringType.minLength at \u0027/2/minLength\u0027 in \u0027types.json\u0027 must be non-negative, but got -1.", + "path": "types.json", + "jsonPointer": "/2/minLength", + "line": 18, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/index.json new file mode 100644 index 00000000..d78f180a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "My.Rp/things@2026-01-01": { + "$ref": "types.json#/0" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/types.json new file mode 100644 index 00000000..03d257f3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/types.json @@ -0,0 +1,20 @@ +[ + { + "$type": "ResourceType", + "name": "My.Rp/things@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "body", + "properties": {} + }, + { + "$type": "StringType", + "minLength": -1 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/scenario.json new file mode 100644 index 00000000..7030038f --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/scenario.json @@ -0,0 +1,8 @@ +{ + "name": "string-length-negative", + "description": "A StringType has a negative minLength (BCPVT027); mode-independent.", + "category": "invalid.semantic", + "modes": [ + "canonicalWriter" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/canonicalWriter.result.json new file mode 100644 index 00000000..36c7fd34 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT028", + "severity": "error", + "message": "NamespaceFunctionType.visibleInFileKind at \u0027/1/visibleInFileKind\u0027 in \u0027types.json\u0027 must be one of 1 or 2 for this validator version, but got 99.", + "path": "types.json", + "jsonPointer": "/1/visibleInFileKind", + "line": 12, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/compatibleReader.result.json new file mode 100644 index 00000000..2ad65a98 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT028", + "severity": "warning", + "message": "NamespaceFunctionType.visibleInFileKind at \u0027/1/visibleInFileKind\u0027 in \u0027types.json\u0027 must be one of 1 or 2 for this validator version, but got 99.", + "path": "types.json", + "jsonPointer": "/1/visibleInFileKind", + "line": 12, + "column": 5 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 1, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/index.json new file mode 100644 index 00000000..a056c7dc --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": {}, + "resourceFunctions": {}, + "namespaceFunctions": [ + { + "$ref": "types.json#/1" + } + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/types.json new file mode 100644 index 00000000..4f40675e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/types.json @@ -0,0 +1,14 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "NamespaceFunctionType", + "name": "lookup", + "parameters": [], + "outputType": { + "$ref": "#/0" + }, + "visibleInFileKind": 99 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/scenario.json new file mode 100644 index 00000000..535c9b53 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "visible-in-file-kind-invalid", + "description": "A NamespaceFunctionType visibleInFileKind is outside the known enum domain (BCPVT028): error in canonicalWriter, warning in compatibleReader.", + "category": "invalid.semantic", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/canonicalWriter.result.json new file mode 100644 index 00000000..62955ef4 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT012", + "severity": "error", + "message": "Property \u0027Sample/r@v1\u0027 at \u0027/resources/Sample~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): expected an object with a \u0027$ref\u0027 property, got string.", + "path": "index.json", + "jsonPointer": "/resources/Sample~1r@v1", + "line": 1, + "column": 29 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/compatibleReader.result.json new file mode 100644 index 00000000..5a906021 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT012", + "severity": "error", + "message": "Property \u0027Sample/r@v1\u0027 at \u0027/resources/Sample~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): expected an object with a \u0027$ref\u0027 property, got string.", + "path": "index.json", + "jsonPointer": "/resources/Sample~1r@v1", + "line": 1, + "column": 29 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/package/index.json new file mode 100644 index 00000000..411f59a6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/package/index.json @@ -0,0 +1 @@ +{"resources":{"Sample/r@v1":"not-a-ref"},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/scenario.json new file mode 100644 index 00000000..7768bdeb --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/scenario.json @@ -0,0 +1 @@ +{"name":"index-resource-entry-not-reference","description":"A resources entry value is not a reference object.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/canonicalWriter.result.json new file mode 100644 index 00000000..4232fd0a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/canonicalWriter.result.json @@ -0,0 +1,20 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT011", + "severity": "error", + "message": "Property \u0027resources\u0027 at \u0027\u0027 in \u0027index.json\u0027 must be a object, but got string.", + "path": "index.json", + "line": 1, + "column": 14 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/compatibleReader.result.json new file mode 100644 index 00000000..feff8562 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/compatibleReader.result.json @@ -0,0 +1,20 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT011", + "severity": "error", + "message": "Property \u0027resources\u0027 at \u0027\u0027 in \u0027index.json\u0027 must be a object, but got string.", + "path": "index.json", + "line": 1, + "column": 14 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/package/index.json new file mode 100644 index 00000000..ff042dcf --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/package/index.json @@ -0,0 +1 @@ +{"resources":"bad","resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/scenario.json new file mode 100644 index 00000000..371c39da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/scenario.json @@ -0,0 +1 @@ +{"name":"index-resources-not-object","description":"The resources field is not a JSON object.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/canonicalWriter.result.json new file mode 100644 index 00000000..be1a2113 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/canonicalWriter.result.json @@ -0,0 +1,20 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT004", + "severity": "error", + "message": "The root value of \u0027index.json\u0027 must be a JSON object.", + "path": "index.json", + "line": 1, + "column": 1 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/compatibleReader.result.json new file mode 100644 index 00000000..bdf700df --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/compatibleReader.result.json @@ -0,0 +1,20 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT004", + "severity": "error", + "message": "The root value of \u0027index.json\u0027 must be a JSON object.", + "path": "index.json", + "line": 1, + "column": 1 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/package/index.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/package/index.json @@ -0,0 +1 @@ +[] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/scenario.json new file mode 100644 index 00000000..e7170d37 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/scenario.json @@ -0,0 +1 @@ +{"name":"index-root-array","description":"index.json root is an array instead of an object.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/expected/canonicalWriter.result.json new file mode 100644 index 00000000..fbc2cd3b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/expected/canonicalWriter.result.json @@ -0,0 +1,20 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT014", + "severity": "error", + "message": "Unexpected property \u0027unknownField\u0027 at \u0027\u0027 in \u0027index.json\u0027.", + "path": "index.json", + "line": 1, + "column": 64 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/package/index.json new file mode 100644 index 00000000..d93568cd --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/package/index.json @@ -0,0 +1 @@ +{"resources":{},"resourceFunctions":{},"namespaceFunctions":[],"unknownField":1} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/scenario.json new file mode 100644 index 00000000..e4e65672 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/scenario.json @@ -0,0 +1 @@ +{"name":"index-unknown-top-level-field","description":"index.json contains an unrecognized top-level field.","category":"structural","modes":["canonicalWriter"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/canonicalWriter.result.json new file mode 100644 index 00000000..543232e5 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/canonicalWriter.result.json @@ -0,0 +1,20 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT003", + "severity": "error", + "message": "JSON syntax error in \u0027index.json\u0027: \u0027t\u0027 is an invalid start of a property name. Expected a \u0027\u0022\u0027. LineNumber: 0 | BytePositionInLine: 2.", + "path": "index.json", + "line": 1, + "column": 3 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/compatibleReader.result.json new file mode 100644 index 00000000..e973f0d8 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/compatibleReader.result.json @@ -0,0 +1,20 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT003", + "severity": "error", + "message": "JSON syntax error in \u0027index.json\u0027: \u0027t\u0027 is an invalid start of a property name. Expected a \u0027\u0022\u0027. LineNumber: 0 | BytePositionInLine: 2.", + "path": "index.json", + "line": 1, + "column": 3 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/package/index.json new file mode 100644 index 00000000..82bfb0bf --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/package/index.json @@ -0,0 +1 @@ +{ this is not valid JSON } diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/scenario.json new file mode 100644 index 00000000..97fe6efd --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/scenario.json @@ -0,0 +1 @@ +{"name":"malformed-index-json","description":"index.json contains a JSON syntax error.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/canonicalWriter.result.json new file mode 100644 index 00000000..c294cd11 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/canonicalWriter.result.json @@ -0,0 +1,17 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT002", + "severity": "error", + "message": "The package at \u0027\u003Csample-root\u003E/package\u0027 does not contain an \u0027index.json\u0027 file at the package root." + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/compatibleReader.result.json new file mode 100644 index 00000000..25e91474 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/compatibleReader.result.json @@ -0,0 +1,17 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT002", + "severity": "error", + "message": "The package at \u0027\u003Csample-root\u003E/package\u0027 does not contain an \u0027index.json\u0027 file at the package root." + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/package/.placeholder b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/package/.placeholder new file mode 100644 index 00000000..3ab0c48e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/package/.placeholder @@ -0,0 +1,2 @@ +This file exists only to ensure the package directory is materialized as an empty folder. +The validator will look for index.json, which is intentionally absent for this scenario. diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/scenario.json new file mode 100644 index 00000000..69d97268 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/scenario.json @@ -0,0 +1 @@ +{"name":"missing-index-file","description":"A package directory with no index.json file.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/expected/canonicalWriter.result.json new file mode 100644 index 00000000..e9f874d3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT014", + "severity": "error", + "message": "Unexpected property \u0027extra\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1", + "line": 1, + "column": 48 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/index.json new file mode 100644 index 00000000..d5730bc8 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"types.json#/0","extra":1}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/types.json new file mode 100644 index 00000000..5a45d228 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/types.json @@ -0,0 +1 @@ +[{"$type":"StringType"}] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/scenario.json new file mode 100644 index 00000000..147500fb --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/scenario.json @@ -0,0 +1 @@ +{"name":"reference-extra-property","description":"A reference object contains an extra property besides $ref (canonical mode only).","category":"structural","modes":["canonicalWriter"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/canonicalWriter.result.json new file mode 100644 index 00000000..6ae89a53 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT013", + "severity": "error", + "message": "Reference \u0027notavalidref\u0027 at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 has invalid syntax: the reference string must contain a \u0027#/\u0027 fragment separator.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/compatibleReader.result.json new file mode 100644 index 00000000..9d9bda76 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT013", + "severity": "error", + "message": "Reference \u0027notavalidref\u0027 at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 has invalid syntax: the reference string must contain a \u0027#/\u0027 fragment separator.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/package/index.json new file mode 100644 index 00000000..1dc8f586 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"notavalidref"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/scenario.json new file mode 100644 index 00000000..78928586 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/scenario.json @@ -0,0 +1 @@ +{"name":"reference-invalid-syntax","description":"A $ref string does not match the expected path#/index format.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/canonicalWriter.result.json new file mode 100644 index 00000000..ae903eec --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT012", + "severity": "error", + "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the object is missing the required \u0027$ref\u0027 property.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1", + "line": 1, + "column": 24 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/compatibleReader.result.json new file mode 100644 index 00000000..7609abaa --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT012", + "severity": "error", + "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the object is missing the required \u0027$ref\u0027 property.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1", + "line": 1, + "column": 24 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/package/index.json new file mode 100644 index 00000000..2b3252da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"notRef":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/scenario.json new file mode 100644 index 00000000..d0139d20 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/scenario.json @@ -0,0 +1 @@ +{"name":"reference-missing-ref","description":"A reference object is missing the required $ref property.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/canonicalWriter.result.json new file mode 100644 index 00000000..b1447287 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT012", + "severity": "error", + "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the \u0027$ref\u0027 property must be a string, got number.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1", + "line": 1, + "column": 32 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/compatibleReader.result.json new file mode 100644 index 00000000..1f2e72ec --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT012", + "severity": "error", + "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the \u0027$ref\u0027 property must be a string, got number.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1", + "line": 1, + "column": 32 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/package/index.json new file mode 100644 index 00000000..630d11c6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":42}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/scenario.json new file mode 100644 index 00000000..91a4bb33 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/scenario.json @@ -0,0 +1 @@ +{"name":"reference-non-string-ref","description":"A reference object has a $ref property that is not a string.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/canonicalWriter.result.json new file mode 100644 index 00000000..d165fe52 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT006", + "severity": "error", + "message": "Element \u0027/0\u0027 in type file \u0027types.json\u0027 must be a JSON object.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/compatibleReader.result.json new file mode 100644 index 00000000..4a928eb3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT006", + "severity": "error", + "message": "Element \u0027/0\u0027 in type file \u0027types.json\u0027 must be a JSON object.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/index.json new file mode 100644 index 00000000..77d942da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/types.json new file mode 100644 index 00000000..e44b4bf7 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/types.json @@ -0,0 +1 @@ +[42] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/scenario.json new file mode 100644 index 00000000..01683eed --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/scenario.json @@ -0,0 +1 @@ +{"name":"type-file-element-primitive","description":"A type-file array element is a primitive (number), not an object.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/canonicalWriter.result.json new file mode 100644 index 00000000..406e4d7b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/canonicalWriter.result.json @@ -0,0 +1,29 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT018", + "severity": "error", + "message": "Reference at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 targets type file \u0027types.json\u0027, which is not a usable type-file array.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32 + }, + { + "code": "BCPVT005", + "severity": "error", + "message": "The root value of type file \u0027types.json\u0027 must be a JSON array.", + "path": "types.json", + "line": 1, + "column": 1 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 2, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/compatibleReader.result.json new file mode 100644 index 00000000..1bb2d8ee --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/compatibleReader.result.json @@ -0,0 +1,29 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT018", + "severity": "error", + "message": "Reference at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 targets type file \u0027types.json\u0027, which is not a usable type-file array.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32 + }, + { + "code": "BCPVT005", + "severity": "error", + "message": "The root value of type file \u0027types.json\u0027 must be a JSON array.", + "path": "types.json", + "line": 1, + "column": 1 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 2, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/index.json new file mode 100644 index 00000000..77d942da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/types.json new file mode 100644 index 00000000..5b205a7a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/types.json @@ -0,0 +1 @@ +{"$type":"StringType"} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/scenario.json new file mode 100644 index 00000000..a1d9633c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/scenario.json @@ -0,0 +1 @@ +{"name":"type-file-root-object","description":"A type file root is a JSON object instead of an array.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/canonicalWriter.result.json new file mode 100644 index 00000000..2e3c6505 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT007", + "severity": "error", + "message": "Type object at \u0027/0\u0027 in \u0027types.json\u0027 is missing the required \u0027$type\u0027 discriminator field.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/compatibleReader.result.json new file mode 100644 index 00000000..f708f5c0 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT007", + "severity": "error", + "message": "Type object at \u0027/0\u0027 in \u0027types.json\u0027 is missing the required \u0027$type\u0027 discriminator field.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/index.json new file mode 100644 index 00000000..77d942da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/types.json new file mode 100644 index 00000000..1b81e0e1 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/types.json @@ -0,0 +1 @@ +[{"name":"x"}] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/scenario.json new file mode 100644 index 00000000..bed6e3ad --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/scenario.json @@ -0,0 +1 @@ +{"name":"type-object-missing-discriminator","description":"A type object is missing the required $type field.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/canonicalWriter.result.json new file mode 100644 index 00000000..a6cca28b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/canonicalWriter.result.json @@ -0,0 +1,39 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ] + }, + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027value\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 2, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/compatibleReader.result.json new file mode 100644 index 00000000..e5511144 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/compatibleReader.result.json @@ -0,0 +1,39 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ] + }, + { + "code": "BCPVT010", + "severity": "error", + "message": "Required property \u0027value\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 2, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/index.json new file mode 100644 index 00000000..77d942da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/types.json new file mode 100644 index 00000000..b32f763b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/types.json @@ -0,0 +1 @@ +[{"$type":"StringLiteralType"}] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/scenario.json new file mode 100644 index 00000000..078644f7 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/scenario.json @@ -0,0 +1 @@ +{"name":"type-object-missing-required-field","description":"A type object (StringLiteralType) is missing its required value field.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/canonicalWriter.result.json new file mode 100644 index 00000000..c5b4ea49 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/canonicalWriter.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT009", + "severity": "error", + "message": "The \u0027$type\u0027 value \u0027NonExistentKind\u0027 at \u0027/0/$type\u0027 in \u0027types.json\u0027 is not a supported type kind.", + "path": "types.json", + "jsonPointer": "/0/$type", + "line": 1, + "column": 11 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/compatibleReader.result.json new file mode 100644 index 00000000..48ac3680 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/compatibleReader.result.json @@ -0,0 +1,21 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT009", + "severity": "error", + "message": "The \u0027$type\u0027 value \u0027NonExistentKind\u0027 at \u0027/0/$type\u0027 in \u0027types.json\u0027 is not a supported type kind.", + "path": "types.json", + "jsonPointer": "/0/$type", + "line": 1, + "column": 11 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 1, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/index.json new file mode 100644 index 00000000..77d942da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/types.json new file mode 100644 index 00000000..ec0472aa --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/types.json @@ -0,0 +1 @@ +[{"$type":"NonExistentKind"}] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/scenario.json new file mode 100644 index 00000000..e7ebbdc9 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/scenario.json @@ -0,0 +1 @@ +{"name":"type-object-unsupported-discriminator","description":"A type object has a $type value that is not a supported type kind.","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/canonicalWriter.result.json new file mode 100644 index 00000000..bbd76948 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/canonicalWriter.result.json @@ -0,0 +1,39 @@ +{ + "isValid": false, + "mode": "canonicalWriter", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ] + }, + { + "code": "BCPVT011", + "severity": "error", + "message": "Property \u0027value\u0027 at \u0027/0/value\u0027 in \u0027types.json\u0027 must be a string, but got number.", + "path": "types.json", + "jsonPointer": "/0/value", + "line": 1, + "column": 39 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 2, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/compatibleReader.result.json new file mode 100644 index 00000000..598ed0a7 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/compatibleReader.result.json @@ -0,0 +1,39 @@ +{ + "isValid": false, + "mode": "compatibleReader", + "diagnostics": [ + { + "code": "BCPVT020", + "severity": "error", + "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.", + "path": "index.json", + "jsonPointer": "/resources/S~1r@v1/$ref", + "line": 1, + "column": 32, + "relatedLocations": [ + { + "message": "Target type is declared here.", + "path": "types.json", + "jsonPointer": "/0", + "line": 1, + "column": 2 + } + ] + }, + { + "code": "BCPVT011", + "severity": "error", + "message": "Property \u0027value\u0027 at \u0027/0/value\u0027 in \u0027types.json\u0027 must be a string, but got number.", + "path": "types.json", + "jsonPointer": "/0/value", + "line": 1, + "column": 39 + } + ], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 2, + "warningCount": 0, + "infoCount": 0 + } +} \ No newline at end of file diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/index.json new file mode 100644 index 00000000..77d942da --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/index.json @@ -0,0 +1 @@ +{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/types.json new file mode 100644 index 00000000..7cf2276e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/types.json @@ -0,0 +1 @@ +[{"$type":"StringLiteralType","value":42}] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/scenario.json new file mode 100644 index 00000000..3a187686 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/scenario.json @@ -0,0 +1 @@ +{"name":"type-object-wrong-primitive-field-shape","description":"A type object field has the wrong JSON type (number where string expected).","category":"structural","modes":["canonicalWriter","compatibleReader"]} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/index.json new file mode 100644 index 00000000..ee51f957 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/index.json @@ -0,0 +1,12 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/2" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [], + "fallbackResourceType": { + "$ref": "types.json#/4" + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/types.json new file mode 100644 index 00000000..b2b26e78 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/types.json @@ -0,0 +1,49 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The widget name." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + }, + { + "$type": "ObjectType", + "name": "fallbackBody", + "properties": { + "type": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The unrecognized resource type name." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/*@2026-01-01", + "body": { + "$ref": "#/3" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/scenario.json new file mode 100644 index 00000000..ac9d672c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "fallback-resource-type", + "description": "fallbackResourceType can reference ResourceType.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/index.json new file mode 100644 index 00000000..ac5c71f9 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/2" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/types.json new file mode 100644 index 00000000..5b6e4b63 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/types.json @@ -0,0 +1,27 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The widget name." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/scenario.json new file mode 100644 index 00000000..86db6464 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "minimal-resource", + "description": "A package with index.json, one type file, and one resource entry targeting ResourceType is valid.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/common/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/common/types.json new file mode 100644 index 00000000..94fcbf5a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/common/types.json @@ -0,0 +1,18 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "extensionConfiguration", + "properties": { + "endpoint": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The service endpoint used by the extension." + } + } + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/fallback/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/fallback/types.json new file mode 100644 index 00000000..09aa5a7b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/fallback/types.json @@ -0,0 +1,16 @@ +[ + { + "$type": "ObjectType", + "name": "fallbackBody", + "properties": {} + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/*@2026-01-01", + "body": { + "$ref": "#/0" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/index.json new file mode 100644 index 00000000..a80f0011 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/index.json @@ -0,0 +1,22 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "resources/types.json#/2" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [], + "settings": { + "name": "SampleMultiFile", + "isSingleton": true, + "isPreview": false, + "isDeprecated": false, + "version": "1.0.0", + "configurationType": { + "$ref": "common/types.json#/1" + } + }, + "fallbackResourceType": { + "$ref": "fallback/types.json#/1" + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/resources/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/resources/types.json new file mode 100644 index 00000000..5b6e4b63 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/resources/types.json @@ -0,0 +1,27 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The widget name." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/scenario.json new file mode 100644 index 00000000..f36c01e5 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "multi-file-package", + "description": "Cross-file references work for positions where they are allowed.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/index.json new file mode 100644 index 00000000..e8bbba23 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/index.json @@ -0,0 +1,13 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/4" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [ + { + "$ref": "types.json#/3" + } + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/types.json new file mode 100644 index 00000000..884a6aeb --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/types.json @@ -0,0 +1,50 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "AnyType" + }, + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The widget name." + } + } + }, + { + "$type": "NamespaceFunctionType", + "name": "lookupWidget", + "description": "Looks up a widget by name.", + "evaluatedLanguageExpression": "[externalInput('lookupWidget', parameters('name'))]", + "parameters": [ + { + "name": "name", + "type": { + "$ref": "#/0" + }, + "description": "The widget name.", + "flags": 1 + } + ], + "outputType": { + "$ref": "#/1" + }, + "visibleInFileKind": 1 + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/2" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/scenario.json new file mode 100644 index 00000000..a44aa44c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "namespace-function", + "description": "namespaceFunctions can target NamespaceFunctionType.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/index.json new file mode 100644 index 00000000..f5bb7ebf --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "Sample.Provider/trees@2026-01-01": { + "$ref": "types.json#/2" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/types.json new file mode 100644 index 00000000..459acd9a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/types.json @@ -0,0 +1,34 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "treeNode", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The node name." + }, + "child": { + "type": { + "$ref": "#/1" + }, + "flags": 0, + "description": "The optional child node." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/trees@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/scenario.json new file mode 100644 index 00000000..68020e24 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "recursive-object", + "description": "A valid cycle is accepted when all references resolve and target-role rules are satisfied.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/index.json new file mode 100644 index 00000000..d3b35d52 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/index.json @@ -0,0 +1,17 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/5" + } + }, + "resourceFunctions": { + "Sample.Provider/widgets": { + "2026-01-01": [ + { + "$ref": "types.json#/3" + } + ] + } + }, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/types.json new file mode 100644 index 00000000..4cfdb76b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/types.json @@ -0,0 +1,65 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "listSecretsInput", + "properties": { + "secretName": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The secret name." + } + } + }, + { + "$type": "ObjectType", + "name": "listSecretsOutput", + "properties": { + "value": { + "type": { + "$ref": "#/0" + }, + "flags": 2, + "description": "The resolved secret value." + } + } + }, + { + "$type": "ResourceFunctionType", + "name": "listSecrets", + "resourceType": "Sample.Provider/widgets", + "apiVersion": "2026-01-01", + "output": { + "$ref": "#/2" + }, + "input": { + "$ref": "#/1" + } + }, + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The widget name." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/4" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/scenario.json new file mode 100644 index 00000000..f6335443 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "resource-function", + "description": "resourceFunctions can target ResourceFunctionType.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/index.json new file mode 100644 index 00000000..db1cba53 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/4" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/types.json new file mode 100644 index 00000000..14af0785 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/types.json @@ -0,0 +1,49 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "IntegerType", + "minValue": 0, + "maxValue": 100 + }, + { + "$type": "BooleanType" + }, + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The widget name." + }, + "capacity": { + "type": { + "$ref": "#/1" + }, + "flags": 0, + "description": "The requested widget capacity." + }, + "enabled": { + "type": { + "$ref": "#/2" + }, + "flags": 0, + "description": "Whether the widget is enabled." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/3" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/scenario.json new file mode 100644 index 00000000..2b135626 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "resource-with-object-body", + "description": "A resource body can reference an object type with primitive properties.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/index.json new file mode 100644 index 00000000..9252ce3d --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/index.json @@ -0,0 +1,19 @@ +{ + "resources": { + "Sample.Provider/configuredWidgets@2026-01-01": { + "$ref": "types.json#/3" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [], + "settings": { + "name": "SampleConfiguredWidgets", + "isSingleton": true, + "isPreview": false, + "isDeprecated": false, + "version": "1.0.0", + "configurationType": { + "$ref": "types.json#/1" + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/types.json new file mode 100644 index 00000000..39d552b6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/types.json @@ -0,0 +1,40 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "extensionConfiguration", + "properties": { + "endpoint": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The service endpoint used by the extension." + } + } + }, + { + "$type": "ObjectType", + "name": "configuredWidgetBody", + "properties": { + "displayName": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The widget display name." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/configuredWidgets@2026-01-01", + "body": { + "$ref": "#/2" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/scenario.json new file mode 100644 index 00000000..622697ff --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/scenario.json @@ -0,0 +1,9 @@ +{ + "name": "settings-configuration-type", + "description": "settings.configurationType can reference an object-like type.", + "category": "valid.canonical", + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/canonicalWriter.result.json new file mode 100644 index 00000000..16371dac --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/canonicalWriter.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "canonicalWriter", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/compatibleReader.result.json new file mode 100644 index 00000000..24e80c2b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/compatibleReader.result.json @@ -0,0 +1,11 @@ +{ + "isValid": true, + "mode": "compatibleReader", + "diagnostics": [], + "diagnosticsTruncated": false, + "summary": { + "errorCount": 0, + "warningCount": 0, + "infoCount": 0 + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/index.json new file mode 100644 index 00000000..ac5c71f9 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/index.json @@ -0,0 +1,9 @@ +{ + "resources": { + "Sample.Provider/widgets@2026-01-01": { + "$ref": "types.json#/2" + } + }, + "resourceFunctions": {}, + "namespaceFunctions": [] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/types.json new file mode 100644 index 00000000..5b6e4b63 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/types.json @@ -0,0 +1,27 @@ +[ + { + "$type": "StringType" + }, + { + "$type": "ObjectType", + "name": "widgetBody", + "properties": { + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The widget name." + } + } + }, + { + "$type": "ResourceType", + "name": "Sample.Provider/widgets@2026-01-01", + "body": { + "$ref": "#/1" + }, + "readableScopes": 8, + "writableScopes": 8 + } +] diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/scenario.json new file mode 100644 index 00000000..0401027b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/scenario.json @@ -0,0 +1,15 @@ +{ + "name": "types-tgz-input", + "description": "A tiny valid types.tgz archive validates successfully through the full pipeline, exercising archive input wiring.", + "category": "valid.input-forms", + "inputs": [ + { + "kind": "archiveFile", + "path": "package.tgz" + } + ], + "modes": [ + "canonicalWriter", + "compatibleReader" + ] +} diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/GraphTestHelpers.cs b/src/Bicep.Types.Validation.UnitTests/Graph/GraphTestHelpers.cs new file mode 100644 index 00000000..a2ba5461 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Graph/GraphTestHelpers.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.UnitTests.Graph; + +/// +/// Shared helpers for graph-layer tests: parsing JSON into s and +/// an in-memory test double. +/// +internal static class GraphTestHelpers +{ + public static PackageDocument Document(string packageRelativePath, string json) + { + byte[] bytes = Encoding.UTF8.GetBytes(json); + SourceMap.TryParse(bytes, packageRelativePath, out var root, out var sourceMap, out _); + var kind = packageRelativePath == "index.json" + ? PackageDocumentKind.Index + : PackageDocumentKind.TypeFile; + return new PackageDocument(packageRelativePath, kind, root!, sourceMap); + } +} + +/// +/// An in-memory . A file mapped to null is treated as +/// existing but unreadable (to exercise read-failure paths). +/// +internal sealed class InMemoryPackageFileSystem : IPackageFileSystem +{ + private readonly Dictionary files = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public InMemoryPackageFileSystem AddText(string packageRelativePath, string content) + { + files[packageRelativePath] = Encoding.UTF8.GetBytes(content); + return this; + } + + public InMemoryPackageFileSystem AddUnreadable(string packageRelativePath) + { + files[packageRelativePath] = null; + return this; + } + + public bool FileExists(string packageRelativePath) => files.ContainsKey(packageRelativePath); + + public bool TryReadAllBytes(string packageRelativePath, out byte[] bytes, out string error) + { + if (files.TryGetValue(packageRelativePath, out var content) && content != null) + { + bytes = content; + error = string.Empty; + return true; + } + + bytes = Array.Empty(); + error = "simulated read failure"; + return false; + } + + public IEnumerable EnumerateFiles() => files.Keys; +} diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/SemanticGraphValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/SemanticGraphValidatorTests.cs new file mode 100644 index 00000000..6d323acf --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Graph/SemanticGraphValidatorTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Azure.Bicep.Types.Validation; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Graph; + +[TestClass] +public class SemanticGraphValidatorTests +{ + private static System.Collections.Generic.IReadOnlyList Validate( + string indexJson, InMemoryPackageFileSystem fs) + { + var index = GraphTestHelpers.Document("index.json", indexJson); + var provider = new PackageDocumentProvider(fs, index, new TypePackageValidationOptions()); + return SemanticGraphValidator.Validate(provider, index, new TypePackageValidationOptions()); + } + + private static string ResourceIndex(string refValue) => + "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"" + refValue + "\"}}," + + "\"resourceFunctions\":{},\"namespaceFunctions\":[]}"; + + [TestMethod] + public void Valid_graph_produces_no_diagnostics() + { + const string types = @"[ + { ""$type"": ""StringType"" }, + { ""$type"": ""ObjectType"", ""name"": ""o"", + ""properties"": { ""p"": { ""type"": { ""$ref"": ""#/0"" }, ""flags"": 0 } } }, + { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", + ""body"": { ""$ref"": ""#/1"" }, ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + Validate(ResourceIndex("types.json#/2"), fs).Should().BeEmpty(); + } + + [TestMethod] + public void Missing_referenced_file_reports_bcpvt017() + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"missing.json#/0\"},\"readableScopes\":8,\"writableScopes\":8}]"); + + var diagnostics = Validate(ResourceIndex("types.json#/0"), fs); + + diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferencedTypeFileMissing); + } + + [TestMethod] + public void Out_of_range_reference_reports_bcpvt019() + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/99\"},\"readableScopes\":8,\"writableScopes\":8}]"); + + Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceIndexOutOfRange); + } + + [TestMethod] + public void Wrong_top_level_target_kind_reports_bcpvt020_with_related_location() + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", + "[{\"$type\":\"ObjectType\",\"name\":\"notAResource\",\"properties\":{}}]"); + + var diagnostic = Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle().Subject; + + diagnostic.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch); + diagnostic.RelatedLocations.Should().ContainSingle() + .Which.Message.Should().Be("Target type is declared here."); + } + + [TestMethod] + public void Nested_wrong_target_kind_reports_bcpvt021() + { + const string types = @"[ + { ""$type"": ""ObjectType"", ""name"": ""o"", + ""properties"": { ""self"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } } }, + { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", + ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + Validate(ResourceIndex("types.json#/1"), fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.NestedTargetKindMismatch); + } + + [TestMethod] + public void Every_edge_to_a_shared_node_is_kind_checked_once_per_edge() + { + // Two properties target the same ResourceType node; the node is traversed once but + // both edges must produce a nested-kind-mismatch diagnostic. + const string types = @"[ + { ""$type"": ""ObjectType"", ""name"": ""o"", ""properties"": { + ""a"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 }, + ""b"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } } }, + { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", + ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + var diagnostics = Validate(ResourceIndex("types.json#/1"), fs); + + diagnostics.Should().HaveCount(2); + diagnostics.Should().OnlyContain(d => d.Code == TypeValidationDiagnosticCodes.NestedTargetKindMismatch); + } + + [TestMethod] + public void Cyclic_graph_terminates_without_overflow() + { + // Self-referential object property (ObjectType is a valid value type), plus a deep chain. + const string types = @"[ + { ""$type"": ""ObjectType"", ""name"": ""selfref"", + ""properties"": { ""self"": { ""type"": { ""$ref"": ""#/0"" }, ""flags"": 0 } } }, + { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", + ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + Validate(ResourceIndex("types.json#/1"), fs).Should().BeEmpty(); + } + + // ── Recovery: wrong-kind targets are not traversed ─────────────────────── + + [TestMethod] + public void Wrong_top_level_kind_does_not_traverse_into_target_edges() + { + // The resource root points at an ObjectType (wrong kind) whose property targets a + // ResourceType. Recovery must stop at the top-level mismatch and NOT descend into the + // ObjectType, so only the single BCPVT020 is reported. + const string types = @"[ + { ""$type"": ""ObjectType"", ""name"": ""wrongRoot"", + ""properties"": { ""p"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } } }, + { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", + ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch); + } + + // ── Wrong-kind roots per root type ─────────────────────────────────────── + + [TestMethod] + public void Wrong_resource_function_root_kind_reports_bcpvt020() + { + const string index = + "{\"resources\":{},\"resourceFunctions\":{\"My.Rp/x\":{\"2026-01-01\":[{\"$ref\":\"types.json#/0\"}]}}," + + "\"namespaceFunctions\":[]}"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", + "[{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"); + + Validate(index, fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch); + } + + [TestMethod] + public void Wrong_namespace_function_root_kind_reports_bcpvt020() + { + const string index = + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[{\"$ref\":\"types.json#/0\"}]}"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", + "[{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"); + + Validate(index, fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch); + } + + [TestMethod] + public void Wrong_configuration_type_kind_reports_bcpvt020() + { + const string index = + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]," + + "\"settings\":{\"name\":\"s\",\"version\":\"1\",\"isSingleton\":true,\"configurationType\":{\"$ref\":\"types.json#/0\"}}}"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", "[{\"$type\":\"StringType\"}]"); + + Validate(index, fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch); + } + + [TestMethod] + public void Wrong_fallback_resource_type_kind_reports_bcpvt020() + { + const string index = + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]," + + "\"fallbackResourceType\":{\"$ref\":\"types.json#/0\"}}"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", + "[{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"); + + Validate(index, fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch); + } + + // ── Nested wrong-kind targets per role ─────────────────────────────────── + + [TestMethod] + public void Resource_type_function_wrong_target_kind_reports_bcpvt021() + { + const string types = @"[ + { ""$type"": ""ObjectType"", ""name"": ""notAFunction"", ""properties"": {} }, + { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, + ""functions"": { ""list"": { ""type"": { ""$ref"": ""#/0"" } } }, + ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + Validate(ResourceIndex("types.json#/1"), fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.NestedTargetKindMismatch); + } + + [TestMethod] + public void Discriminated_object_base_property_wrong_target_kind_reports_bcpvt021() + { + const string types = @"[ + { ""$type"": ""DiscriminatedObjectType"", ""name"": ""d"", ""discriminator"": ""kind"", + ""baseProperties"": { ""bad"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } }, + ""elements"": {} }, + { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, + ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + Validate(ResourceIndex("types.json#/1"), fs).Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.NestedTargetKindMismatch); + } + + // ── Duplicate missing-file references ──────────────────────────────────── + + [TestMethod] + public void Duplicate_missing_file_references_report_one_diagnostic_per_source_ref() + { + const string index = + "{\"resources\":{" + + "\"My.Rp/a@2026-01-01\":{\"$ref\":\"gone.json#/0\"}," + + "\"My.Rp/b@2026-01-01\":{\"$ref\":\"gone.json#/0\"}}," + + "\"resourceFunctions\":{},\"namespaceFunctions\":[]}"; + var fs = new InMemoryPackageFileSystem(); + + var diagnostics = Validate(index, fs); + + diagnostics.Should().HaveCount(2); + diagnostics.Should().OnlyContain(d => d.Code == TypeValidationDiagnosticCodes.ReferencedTypeFileMissing); + } + + [TestMethod] + public void Graph_time_read_failure_reports_source_ref_location() + { + var fs = new InMemoryPackageFileSystem() + .AddText("types.json", + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"bad.json#/0\"},\"readableScopes\":8,\"writableScopes\":8}]") + .AddUnreadable("bad.json"); + + var diagnostic = Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle().Subject; + + diagnostic.Code.Should().Be(TypeValidationDiagnosticCodes.PackageFileReadFailed); + diagnostic.Path.Should().Be("types.json"); + diagnostic.JsonPointer.Should().Be("/0/body/$ref"); + diagnostic.Line.Should().NotBeNull(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/TypeGraphBuilderTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/TypeGraphBuilderTests.cs new file mode 100644 index 00000000..64c15031 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Graph/TypeGraphBuilderTests.cs @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Azure.Bicep.Types.Validation.Graph; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Graph; + +[TestClass] +public class TypeGraphBuilderTests +{ + // ── Roots ──────────────────────────────────────────────────────────────── + + [TestMethod] + public void ExtractRoots_reads_every_index_root_kind() + { + const string indexJson = @"{ + ""resources"": { ""My.Rp/things@2026-01-01"": { ""$ref"": ""types.json#/0"" } }, + ""resourceFunctions"": { ""My.Rp/things"": { ""2026-01-01"": [ { ""$ref"": ""types.json#/1"" } ] } }, + ""namespaceFunctions"": [ { ""$ref"": ""types.json#/2"" } ], + ""settings"": { ""configurationType"": { ""$ref"": ""types.json#/3"" } }, + ""fallbackResourceType"": { ""$ref"": ""types.json#/4"" } +}"; + var index = GraphTestHelpers.Document("index.json", indexJson); + + var roots = TypeGraphBuilder.ExtractRoots(index); + + roots.Select(r => r.Role).Should().BeEquivalentTo(new[] + { + TypeReferenceRole.ResourceRoot, + TypeReferenceRole.ResourceFunctionRoot, + TypeReferenceRole.NamespaceFunctionRoot, + TypeReferenceRole.ConfigurationType, + TypeReferenceRole.FallbackResourceType, + }); + roots.Single(r => r.Role == TypeReferenceRole.ResourceRoot).Description + .Should().Be("Resource entry 'My.Rp/things@2026-01-01'"); + roots.Single(r => r.Role == TypeReferenceRole.ResourceFunctionRoot).Description + .Should().Be("Resource function 'My.Rp/things@2026-01-01[0]'"); + } + + [TestMethod] + public void ExtractRoots_returns_empty_for_non_object_root() + { + var index = GraphTestHelpers.Document("index.json", "[]"); + TypeGraphBuilder.ExtractRoots(index).Should().BeEmpty(); + } + + [TestMethod] + public void ExtractRoots_skips_malformed_reference_objects() + { + const string indexJson = @"{ + ""resources"": { + ""a"": { ""$ref"": 5 }, + ""b"": { ""nope"": ""x"" }, + ""c"": { ""$ref"": ""../escape.json#/0"" } + }, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + var index = GraphTestHelpers.Document("index.json", indexJson); + TypeGraphBuilder.ExtractRoots(index).Should().BeEmpty(); + } + + // ── Nodes ──────────────────────────────────────────────────────────────── + + [TestMethod] + public void BuildNodes_returns_null_for_non_array_root() + { + var doc = GraphTestHelpers.Document("types.json", "{\"$type\":\"StringType\"}"); + TypeGraphBuilder.BuildNodes(doc).Should().BeNull(); + } + + [TestMethod] + public void BuildNodes_maps_index_and_marks_unusable_elements_null() + { + const string json = @"[ + { ""$type"": ""StringType"" }, + 42, + { ""name"": ""no-discriminator"" }, + { ""$type"": ""NotARealType"" }, + { ""$type"": ""ObjectType"", ""name"": ""o"", ""properties"": {} } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + + var nodes = TypeGraphBuilder.BuildNodes(doc); + + nodes.Should().NotBeNull(); + nodes!.Count.Should().Be(5); + nodes[0].Should().NotBeNull(); + nodes[0]!.Discriminator.Should().Be("StringType"); + nodes[1].Should().BeNull(); // primitive + nodes[2].Should().BeNull(); // no $type + nodes[3].Should().BeNull(); // unknown discriminator + nodes[4]!.Discriminator.Should().Be("ObjectType"); + nodes[4]!.Id.Index.Should().Be(4); + nodes[4]!.JsonPointer.Should().Be("/4"); + } + + // ── Edges ──────────────────────────────────────────────────────────────── + + [TestMethod] + public void ExtractEdges_reads_resource_body_and_functions() + { + const string json = @"[ + { ""$type"": ""ResourceType"", ""name"": ""r"", ""body"": { ""$ref"": ""#/1"" }, + ""functions"": { ""list"": { ""type"": { ""$ref"": ""#/2"" } } }, + ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var node = TypeGraphBuilder.BuildNodes(doc)![0]!; + + var edges = TypeGraphBuilder.ExtractEdges(node); + + edges.Select(e => e.Role).Should().BeEquivalentTo(new[] + { + TypeReferenceRole.ResourceBody, + TypeReferenceRole.ResourceTypeFunction, + }); + edges.Single(e => e.Role == TypeReferenceRole.ResourceBody).Reference.Index.Should().Be(1); + } + + [TestMethod] + public void ExtractEdges_reads_object_properties_and_additional_properties() + { + const string json = @"[ + { ""$type"": ""ObjectType"", ""name"": ""o"", + ""properties"": { ""p"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } }, + ""additionalProperties"": { ""$ref"": ""#/2"" } } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var node = TypeGraphBuilder.BuildNodes(doc)![0]!; + + var edges = TypeGraphBuilder.ExtractEdges(node); + + edges.Select(e => e.Role).Should().BeEquivalentTo(new[] + { + TypeReferenceRole.ObjectPropertyType, + TypeReferenceRole.AdditionalProperties, + }); + edges.Single(e => e.Role == TypeReferenceRole.ObjectPropertyType).MemberName.Should().Be("p"); + } + + [TestMethod] + public void ExtractEdges_reads_array_and_union_members() + { + const string json = @"[ + { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""#/1"" } }, + { ""$type"": ""UnionType"", ""elements"": [ { ""$ref"": ""#/0"" }, { ""$ref"": ""#/1"" } ] } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var nodes = TypeGraphBuilder.BuildNodes(doc)!; + + TypeGraphBuilder.ExtractEdges(nodes[0]!).Should().ContainSingle() + .Which.Role.Should().Be(TypeReferenceRole.ArrayItem); + TypeGraphBuilder.ExtractEdges(nodes[1]!).Select(e => e.Role) + .Should().OnlyContain(r => r == TypeReferenceRole.UnionMember); + } + + [TestMethod] + public void ExtractEdges_skips_malformed_references() + { + const string json = @"[ + { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""/rooted.json#/0"" } } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var node = TypeGraphBuilder.BuildNodes(doc)![0]!; + TypeGraphBuilder.ExtractEdges(node).Should().BeEmpty(); + } + + [TestMethod] + public void ExtractEdges_value_kinds_have_no_edges() + { + var doc = GraphTestHelpers.Document("types.json", "[{\"$type\":\"StringType\"}]"); + var node = TypeGraphBuilder.BuildNodes(doc)![0]!; + TypeGraphBuilder.ExtractEdges(node).Should().BeEmpty(); + } + + [TestMethod] + public void ExtractEdges_reads_discriminated_object_base_properties_and_elements() + { + const string json = @"[ + { ""$type"": ""ObjectType"", ""name"": ""a"", ""properties"": {} }, + { ""$type"": ""DiscriminatedObjectType"", ""name"": ""d"", ""discriminator"": ""kind"", + ""baseProperties"": { ""id"": { ""type"": { ""$ref"": ""#/0"" }, ""flags"": 0 } }, + ""elements"": { ""a"": { ""$ref"": ""#/0"" } } } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var node = TypeGraphBuilder.BuildNodes(doc)![1]!; + + var edges = TypeGraphBuilder.ExtractEdges(node); + + edges.Select(e => e.Role).Should().BeEquivalentTo(new[] + { + TypeReferenceRole.ObjectPropertyType, + TypeReferenceRole.DiscriminatedObjectElement, + }); + edges.Single(e => e.Role == TypeReferenceRole.ObjectPropertyType).MemberName.Should().Be("id"); + edges.Single(e => e.Role == TypeReferenceRole.DiscriminatedObjectElement).MemberName.Should().Be("a"); + } + + [TestMethod] + public void ExtractEdges_reads_function_type_parameters_and_output() + { + const string json = @"[ + { ""$type"": ""FunctionType"", + ""parameters"": [ + { ""name"": ""a"", ""type"": { ""$ref"": ""#/1"" } }, + { ""name"": ""b"", ""type"": { ""$ref"": ""#/2"" } } + ], + ""output"": { ""$ref"": ""#/3"" } } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var node = TypeGraphBuilder.BuildNodes(doc)![0]!; + + var edges = TypeGraphBuilder.ExtractEdges(node); + + edges.Select(e => e.Role).Should().BeEquivalentTo(new[] + { + TypeReferenceRole.FunctionParameter, + TypeReferenceRole.FunctionParameter, + TypeReferenceRole.FunctionOutput, + }); + edges.Where(e => e.Role == TypeReferenceRole.FunctionParameter) + .Select(e => e.Reference.Index).Should().BeEquivalentTo(new[] { 1, 2 }); + edges.Where(e => e.Role == TypeReferenceRole.FunctionParameter) + .Select(e => e.MemberName).Should().BeEquivalentTo(new[] { "[0]", "[1]" }); + edges.Single(e => e.Role == TypeReferenceRole.FunctionOutput).Reference.Index.Should().Be(3); + } + + [TestMethod] + public void ExtractEdges_function_type_skips_parameters_without_type_reference() + { + // A parameter that is not an object, or lacks a well-formed 'type' reference, yields no + // edge; a missing 'output' yields no output edge. The structural layer owns those shapes. + const string json = @"[ + { ""$type"": ""FunctionType"", + ""parameters"": [ + 42, + { ""name"": ""noType"" }, + { ""name"": ""bad"", ""type"": { ""$ref"": ""/rooted.json#/0"" } }, + { ""name"": ""ok"", ""type"": { ""$ref"": ""#/1"" } } + ] } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var node = TypeGraphBuilder.BuildNodes(doc)![0]!; + + var edges = TypeGraphBuilder.ExtractEdges(node); + + edges.Should().ContainSingle() + .Which.Should().Match(e => + e.Role == TypeReferenceRole.FunctionParameter && e.Reference.Index == 1 && e.MemberName == "[3]"); + } + + // ── Canonical reference-object shape ───────────────────────────────────── + + [TestMethod] + public void ExtractRoots_skips_reference_object_with_extra_property() + { + // The structural layer reports the extra property (BCPVT014); the graph layer must not + // follow such a non-canonical reference (avoids a follow-on graph diagnostic). + const string indexJson = @"{ + ""resources"": { ""a"": { ""$ref"": ""types.json#/0"", ""extra"": true } }, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + var index = GraphTestHelpers.Document("index.json", indexJson); + TypeGraphBuilder.ExtractRoots(index).Should().BeEmpty(); + } + + [TestMethod] + public void ExtractEdges_skips_reference_object_with_extra_property() + { + const string json = @"[ + { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""#/0"", ""extra"": 1 } } +]"; + var doc = GraphTestHelpers.Document("types.json", json); + var node = TypeGraphBuilder.BuildNodes(doc)![0]!; + TypeGraphBuilder.ExtractEdges(node).Should().BeEmpty(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/TypeReferenceResolverTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/TypeReferenceResolverTests.cs new file mode 100644 index 00000000..6e284dcd --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Graph/TypeReferenceResolverTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Bicep.Types.Validation; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Graph; + +[TestClass] +public class TypeReferenceResolverTests +{ + private const string IndexJson = + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]}"; + + private static TypeReferenceResolver CreateResolver(InMemoryPackageFileSystem fs) + { + var index = GraphTestHelpers.Document("index.json", IndexJson); + var provider = new PackageDocumentProvider(fs, index, new TypePackageValidationOptions()); + return new TypeReferenceResolver(provider); + } + + private static ParsedTypeReference Ref(string targetPath, int index, string sourcePath = "index.json") => + new ParsedTypeReference( + $"{targetPath}#/{index}", targetPath, index, sourcePath, "/x/$ref", new SourceLocation(1, 1)); + + [TestMethod] + public void Resolve_returns_resolved_for_usable_target() + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", "[{\"$type\":\"StringType\"}]"); + var resolution = CreateResolver(fs).Resolve(Ref("types.json", 0)); + + resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.Resolved); + resolution.TargetNode!.Discriminator.Should().Be("StringType"); + } + + [TestMethod] + public void Resolve_returns_resolved_for_same_file_reference() + { + var fs = new InMemoryPackageFileSystem() + .AddText("types.json", "[{\"$type\":\"StringType\"},{\"$type\":\"BooleanType\"}]"); + // Empty target path + source types.json => same-file reference. + var resolution = CreateResolver(fs).Resolve(Ref(string.Empty, 1, sourcePath: "types.json")); + + resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.Resolved); + resolution.TargetNode!.Discriminator.Should().Be("BooleanType"); + } + + [TestMethod] + public void Resolve_returns_missing_file_for_unknown_target() + { + var fs = new InMemoryPackageFileSystem(); + var resolution = CreateResolver(fs).Resolve(Ref("nope.json", 0)); + + resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.MissingFile); + resolution.TargetPath.Should().Be("nope.json"); + } + + [TestMethod] + public void Resolve_returns_read_failed_for_unreadable_target() + { + var fs = new InMemoryPackageFileSystem().AddUnreadable("bad.json"); + var resolution = CreateResolver(fs).Resolve(Ref("bad.json", 0)); + + resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.FileReadFailed); + resolution.ReadError.Should().NotBeNullOrEmpty(); + } + + [TestMethod] + public void Resolve_returns_unusable_for_non_array_target() + { + var fs = new InMemoryPackageFileSystem().AddText("obj.json", "{}"); + var resolution = CreateResolver(fs).Resolve(Ref("obj.json", 0)); + + resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.FileUnusable); + } + + [TestMethod] + public void Resolve_returns_out_of_range_for_index_past_end() + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", "[{\"$type\":\"StringType\"}]"); + var resolution = CreateResolver(fs).Resolve(Ref("types.json", 5)); + + resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.IndexOutOfRange); + resolution.TargetElementCount.Should().Be(1); + } + + [TestMethod] + public void Resolve_returns_not_type_object_for_unusable_element() + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", "[42]"); + var resolution = CreateResolver(fs).Resolve(Ref("types.json", 0)); + + resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.TargetNotTypeObject); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/TypeTargetKindValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/TypeTargetKindValidatorTests.cs new file mode 100644 index 00000000..c8cc0941 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Graph/TypeTargetKindValidatorTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Bicep.Types.Validation.Graph; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Graph; + +[TestClass] +public class TypeTargetKindValidatorTests +{ + // ── IsTopLevel ─────────────────────────────────────────────────────────── + + [TestMethod] + [DataRow("ResourceRoot", true)] + [DataRow("ResourceFunctionRoot", true)] + [DataRow("NamespaceFunctionRoot", true)] + [DataRow("FallbackResourceType", true)] + [DataRow("ConfigurationType", true)] + [DataRow("ResourceBody", false)] + [DataRow("ObjectPropertyType", false)] + [DataRow("ArrayItem", false)] + public void IsTopLevel_classifies_root_roles(string role, bool expected) + { + TypeTargetKindValidator.IsTopLevel(Role(role)).Should().Be(expected); + } + + // ── IsAllowed: container roles ─────────────────────────────────────────── + + [TestMethod] + [DataRow("ResourceRoot", "ResourceType", true)] + [DataRow("ResourceRoot", "ObjectType", false)] + [DataRow("FallbackResourceType", "ResourceType", true)] + [DataRow("ResourceFunctionRoot", "ResourceFunctionType", true)] + [DataRow("ResourceFunctionRoot", "FunctionType", false)] + [DataRow("NamespaceFunctionRoot", "NamespaceFunctionType", true)] + [DataRow("ResourceBody", "ObjectType", true)] + [DataRow("ResourceBody", "DiscriminatedObjectType", true)] + [DataRow("ResourceBody", "StringType", false)] + [DataRow("ConfigurationType", "ObjectType", true)] + [DataRow("ResourceTypeFunction", "FunctionType", true)] + [DataRow("ResourceTypeFunction", "ObjectType", false)] + [DataRow("DiscriminatedObjectElement", "ObjectType", true)] + [DataRow("DiscriminatedObjectElement", "DiscriminatedObjectType", false)] + public void IsAllowed_enforces_container_role_targets(string role, string discriminator, bool expected) + { + TypeTargetKindValidator.IsAllowed(Role(role), discriminator).Should().Be(expected); + } + + // ── IsAllowed: value-type roles ────────────────────────────────────────── + + [TestMethod] + [DataRow("StringType", true)] + [DataRow("IntegerType", true)] + [DataRow("ObjectType", true)] + [DataRow("UnionType", true)] + [DataRow("BuiltInType", true)] + [DataRow("ResourceType", false)] + [DataRow("ResourceFunctionType", false)] + [DataRow("NamespaceFunctionType", false)] + [DataRow("FunctionType", false)] + public void IsAllowed_value_role_accepts_only_value_types(string discriminator, bool expected) + { + TypeTargetKindValidator.IsAllowed(TypeReferenceRole.ObjectPropertyType, discriminator) + .Should().Be(expected); + } + + // ── ExpectedText ───────────────────────────────────────────────────────── + + [TestMethod] + public void ExpectedText_describes_expected_kinds() + { + TypeTargetKindValidator.ExpectedText(TypeReferenceRole.ResourceRoot) + .Should().Be("a resource type ('ResourceType')"); + TypeTargetKindValidator.ExpectedText(TypeReferenceRole.ResourceBody) + .Should().Be("an object type ('ObjectType' or 'DiscriminatedObjectType')"); + TypeTargetKindValidator.ExpectedText(TypeReferenceRole.ObjectPropertyType) + .Should().Be("a value type"); + } + + private static TypeReferenceRole Role(string name) => + (TypeReferenceRole)Enum.Parse(typeof(TypeReferenceRole), name); +} diff --git a/src/Bicep.Types.Validation.UnitTests/Hygiene/PackageHygieneTests.cs b/src/Bicep.Types.Validation.UnitTests/Hygiene/PackageHygieneTests.cs new file mode 100644 index 00000000..c73c595e --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Hygiene/PackageHygieneTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Hygiene; + +[TestClass] +public class PackageHygieneTests +{ + private const string MinimalIndexJson = @"{ + ""resources"": {}, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + + private static readonly TypePackageValidator Validator = new(); + + [TestMethod] + public void Default_validation_ignores_unreachable_type_file() + { + using var dir = new TempDir(); + WriteIndex(dir); + File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "[]"); + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(dir.Path)); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void ValidateUnreachableFiles_reports_unreachable_type_file() + { + using var dir = new TempDir(); + WriteIndex(dir); + File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "[]"); + + var result = Validate(dir, validateUnreachable: true); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnreachablePackageFile); + } + + [TestMethod] + public void ValidateUnreachableFiles_reports_unexpected_non_json_file() + { + using var dir = new TempDir(); + WriteIndex(dir); + File.WriteAllText(Path.Combine(dir.Path, "README.md"), "notes"); + + var result = Validate(dir, validateUnreachable: true); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnexpectedPackageFile); + } + + [TestMethod] + public void ValidateUnreachableFiles_validates_malformed_unreachable_type_file() + { + using var dir = new TempDir(); + WriteIndex(dir); + File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "this is not json"); + + var result = Validate(dir, validateUnreachable: true); + + result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.UnreachablePackageFile); + result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.JsonSyntaxInvalid); + } + + [TestMethod] + public void ValidateUnreachableFiles_validates_semantic_defects_in_unreachable_type_file() + { + using var dir = new TempDir(); + WriteIndex(dir); + File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), + @"[ { ""$type"": ""IntegerType"", ""minValue"": 10, ""maxValue"": 5 } ]"); + + var result = Validate(dir, validateUnreachable: true); + + result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.UnreachablePackageFile); + result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.NumericRangeInvalid); + } + + [TestMethod] + public void ValidateUnreachableFiles_validates_graph_edges_in_unreachable_type_file() + { + using var dir = new TempDir(); + WriteIndex(dir); + File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), + @"[ { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""#/9"" } } ]"); + + var result = Validate(dir, validateUnreachable: true); + + result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.UnreachablePackageFile); + result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.ReferenceIndexOutOfRange); + } + + [TestMethod] + public void ValidateUnreachableFiles_uses_deterministic_file_order() + { + using var dir = new TempDir(); + WriteIndex(dir); + File.WriteAllText(Path.Combine(dir.Path, "b-orphan.json"), "[]"); + File.WriteAllText(Path.Combine(dir.Path, "a-orphan.json"), "[]"); + + var result = Validate(dir, validateUnreachable: true); + + var unreachablePaths = result.Diagnostics + .Where(d => d.Code == TypeValidationDiagnosticCodes.UnreachablePackageFile) + .Select(d => d.Path) + .ToList(); + + unreachablePaths.Should().Equal("a-orphan.json", "b-orphan.json"); + } + + [TestMethod] + public void ValidateUnreachableFiles_does_not_duplicate_reachable_file_diagnostics() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "index.json"), @"{ + ""resources"": { ""Sample.Provider/widgets@2026-01-01"": { ""$ref"": ""types.json#/1"" } }, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"); + File.WriteAllText(Path.Combine(dir.Path, "types.json"), @"[ + { ""$type"": ""ObjectType"", ""name"": ""body"", ""properties"": { ""size"": { ""type"": { ""$ref"": ""#/2"" }, ""flags"": 0 } } }, + { ""$type"": ""ResourceType"", ""name"": ""Sample.Provider/widgets@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 }, + { ""$type"": ""IntegerType"", ""minValue"": 10, ""maxValue"": 5 } +]"); + File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "[]"); + + var result = Validate(dir, validateUnreachable: true); + + // The reachable defect is reported exactly once even with strict hygiene enabled. + result.Diagnostics.Count(d => d.Code == TypeValidationDiagnosticCodes.NumericRangeInvalid).Should().Be(1); + result.Diagnostics.Should().Contain(d => d.Code == TypeValidationDiagnosticCodes.UnreachablePackageFile); + } + + private static TypePackageValidationResult Validate(TempDir dir, bool validateUnreachable) + { + var options = new TypePackageValidationOptions { ValidateUnreachableFiles = validateUnreachable }; + return Validator.Validate(TypePackageValidationInput.ForDirectory(dir.Path), options); + } + + private static void WriteIndex(TempDir dir) => + File.WriteAllText(Path.Combine(dir.Path, "index.json"), MinimalIndexJson); + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-hyg-" + System.IO.Path.GetRandomFileName()); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/ArchiveInputTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/ArchiveInputTests.cs new file mode 100644 index 00000000..bd3de5f8 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/ArchiveInputTests.cs @@ -0,0 +1,316 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Azure.Bicep.Types.Validation.Diagnostics; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Packaging; + +[TestClass] +public class ArchiveInputTests +{ + private const string MinimalIndexJson = @"{ + ""resources"": {}, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + + private static readonly TypePackageValidator Validator = new(); + + [TestMethod] + public void Archive_file_input_validates_minimal_package() + { + var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson)); + using var file = new TempFile(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveFile(file.Path)); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void Archive_stream_input_validates_minimal_package() + { + var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson)); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.IsValid.Should().BeTrue(); + // The caller's stream is read but not disposed. + stream.CanRead.Should().BeTrue(); + } + + [TestMethod] + public void Archive_input_leading_dot_slash_prefix_is_accepted() + { + var archive = TarGzTestArchive.FromTextFiles(("./index.json", MinimalIndexJson)); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.IsValid.Should().BeTrue(); + } + + [TestMethod] + public void Archive_input_uses_member_paths_in_json_syntax_diagnostics() + { + var archive = TarGzTestArchive.FromTextFiles(("index.json", "this is not json")); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.JsonSyntaxInvalid) + .Which.Path.Should().Be("index.json"); + } + + [TestMethod] + public void Archive_input_missing_index_json_reports_bcpvt002() + { + var archive = TarGzTestArchive.FromTextFiles(("types.json", "[]")); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.IndexFileMissing); + } + + [TestMethod] + public void Archive_input_malformed_gzip_reports_bcpvt030() + { + using var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchivePackageInvalid); + } + + [TestMethod] + public void Archive_input_malformed_tar_reports_bcpvt030() + { + // Valid gzip container wrapping a header block with a corrupted ustar magic marker. + var tar = TarGzTestArchive.BuildTar(new[] { TarGzTestEntry.File("index.json", MinimalIndexJson) }); + tar[257] = (byte)'X'; + var archive = TarGzTestArchive.GzipCompress(tar); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchivePackageInvalid); + } + + [TestMethod] + public void Archive_input_duplicate_member_reports_bcpvt032() + { + var archive = TarGzTestArchive.Build(new[] + { + TarGzTestEntry.File("index.json", MinimalIndexJson), + TarGzTestEntry.File("types.json", "[]"), + TarGzTestEntry.File("types.json", "[]"), + }); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberDuplicate); + } + + [TestMethod] + public void Archive_input_normalized_path_collision_reports_bcpvt033() + { + var archive = TarGzTestArchive.Build(new[] + { + TarGzTestEntry.File("index.json", MinimalIndexJson), + TarGzTestEntry.File("a/types.json", "[]"), + TarGzTestEntry.File("./a/types.json", "[]"), + }); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathCollision); + } + + [TestMethod] + public void Archive_input_case_only_path_collision_reports_bcpvt033() + { + var archive = TarGzTestArchive.Build(new[] + { + TarGzTestEntry.File("index.json", MinimalIndexJson), + TarGzTestEntry.File("A/types.json", "[]"), + TarGzTestEntry.File("a/types.json", "[]"), + }); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathCollision); + } + + [TestMethod] + public void Archive_input_absolute_member_path_reports_bcpvt031() + { + var archive = TarGzTestArchive.Build(new[] + { + TarGzTestEntry.File("index.json", MinimalIndexJson), + TarGzTestEntry.File("/etc/types.json", "[]"), + }); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid); + } + + [TestMethod] + public void Archive_input_dotdot_member_path_reports_bcpvt031() + { + var archive = TarGzTestArchive.Build(new[] + { + TarGzTestEntry.File("index.json", MinimalIndexJson), + TarGzTestEntry.File("../types.json", "[]"), + }); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid); + } + + [TestMethod] + public void Archive_input_backslash_member_path_reports_bcpvt031() + { + var archive = TarGzTestArchive.Build(new[] + { + TarGzTestEntry.File("index.json", MinimalIndexJson), + TarGzTestEntry.File("a\\types.json", "[]"), + }); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid); + } + + [TestMethod] + public void Archive_input_symlink_member_reports_bcpvt031() + { + var archive = TarGzTestArchive.Build(new[] + { + TarGzTestEntry.File("index.json", MinimalIndexJson), + TarGzTestEntry.Symlink("link.json"), + }); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid); + } + + [TestMethod] + public void Archive_input_with_resource_function_only_type_file_validates_when_member_present() + { + const string indexJson = @"{ + ""resources"": { ""Sample.Provider/widgets@2026-01-01"": { ""$ref"": ""types.json#/1"" } }, + ""resourceFunctions"": { ""Sample.Provider/widgets"": { ""2026-01-01"": [ { ""$ref"": ""functions.json#/0"" } ] } }, + ""namespaceFunctions"": [] +}"; + const string typesJson = @"[ + { ""$type"": ""ObjectType"", ""name"": ""body"", ""properties"": {} }, + { ""$type"": ""ResourceType"", ""name"": ""Sample.Provider/widgets@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + const string functionsJson = @"[ + { ""$type"": ""ResourceFunctionType"", ""name"": ""listSecrets"", ""resourceType"": ""Sample.Provider/widgets"", ""apiVersion"": ""2026-01-01"", ""output"": { ""$ref"": ""#/1"" }, ""input"": { ""$ref"": ""#/2"" } }, + { ""$type"": ""ObjectType"", ""name"": ""listSecretsOutput"", ""properties"": {} }, + { ""$type"": ""ObjectType"", ""name"": ""listSecretsInput"", ""properties"": {} } +]"; + var archive = TarGzTestArchive.FromTextFiles( + ("index.json", indexJson), + ("types.json", typesJson), + ("functions.json", functionsJson)); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void Archive_input_missing_resource_function_only_type_file_reports_bcpvt017() + { + const string indexJson = @"{ + ""resources"": { ""Sample.Provider/widgets@2026-01-01"": { ""$ref"": ""types.json#/1"" } }, + ""resourceFunctions"": { ""Sample.Provider/widgets"": { ""2026-01-01"": [ { ""$ref"": ""functions.json#/0"" } ] } }, + ""namespaceFunctions"": [] +}"; + const string typesJson = @"[ + { ""$type"": ""ObjectType"", ""name"": ""body"", ""properties"": {} }, + { ""$type"": ""ResourceType"", ""name"": ""Sample.Provider/widgets@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 } +]"; + var archive = TarGzTestArchive.FromTextFiles( + ("index.json", indexJson), + ("types.json", typesJson)); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferencedTypeFileMissing); + } + + [TestMethod] + public void Archive_and_equivalent_directory_produce_same_result() + { + var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson)); + using var file = new TempFile(archive); + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "index.json"), MinimalIndexJson); + + var archiveResult = Validator.Validate(TypePackageValidationInput.ForArchiveFile(file.Path)); + var directoryResult = Validator.Validate(TypePackageValidationInput.ForDirectory(dir.Path)); + + archiveResult.IsValid.Should().Be(directoryResult.IsValid); + archiveResult.Diagnostics.Count.Should().Be(directoryResult.Diagnostics.Count); + } + + private sealed class TempFile : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-arc-" + System.IO.Path.GetRandomFileName() + ".tgz"); + + public TempFile(byte[] content) => File.WriteAllBytes(Path, content); + + public void Dispose() + { + try { File.Delete(Path); } catch { /* best-effort */ } + } + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-arc-" + System.IO.Path.GetRandomFileName()); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/DirectoryPackageFileSystemTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/DirectoryPackageFileSystemTests.cs new file mode 100644 index 00000000..410bb026 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/DirectoryPackageFileSystemTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Azure.Bicep.Types.Validation.Packaging; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Packaging; + +[TestClass] +public class DirectoryPackageFileSystemTests +{ + // ── Nonexistent root ──────────────────────────────────────────────────── + + [TestMethod] + public void Nonexistent_root_FileExists_returns_false() + { + var fs = new DirectoryPackageFileSystem("/path/does-not-exist-xyz"); + fs.FileExists("index.json").Should().BeFalse(); + } + + [TestMethod] + public void Nonexistent_root_TryReadAllBytes_returns_false_with_error() + { + var fs = new DirectoryPackageFileSystem("/path/does-not-exist-xyz"); + var ok = fs.TryReadAllBytes("index.json", out _, out string error); + ok.Should().BeFalse(); + error.Should().NotBeNullOrEmpty(); + } + + // ── Absolute package-relative path rejection ──────────────────────────── + + [TestMethod] + public void Absolute_package_relative_path_FileExists_returns_false() + { + using var dir = new TempDir(); + var fs = new DirectoryPackageFileSystem(dir.Path); + + // platform-absolute paths are rejected before touching the file system + fs.FileExists("/etc/passwd").Should().BeFalse(); + fs.FileExists("C:\\Windows\\System32\\cmd.exe").Should().BeFalse(); + } + + [TestMethod] + public void Absolute_package_relative_path_TryReadAllBytes_returns_false() + { + using var dir = new TempDir(); + var fs = new DirectoryPackageFileSystem(dir.Path); + var ok = fs.TryReadAllBytes("/etc/passwd", out _, out _); + ok.Should().BeFalse(); + } + + // ── Path traversal rejection ──────────────────────────────────────────── + + [TestMethod] + public void DotDot_traversal_FileExists_returns_false() + { + using var dir = new TempDir(); + var fs = new DirectoryPackageFileSystem(dir.Path); + fs.FileExists("../outside-root.txt").Should().BeFalse(); + } + + [TestMethod] + public void DotDot_traversal_TryReadAllBytes_returns_false() + { + using var dir = new TempDir(); + var fs = new DirectoryPackageFileSystem(dir.Path); + var ok = fs.TryReadAllBytes("../../secret.txt", out _, out _); + ok.Should().BeFalse(); + } + + // ── Separator normalization ───────────────────────────────────────────── + + [TestMethod] + public void Backslash_and_slash_separators_normalize_for_package_identity() + { + using var dir = new TempDir(); + Directory.CreateDirectory(Path.Combine(dir.Path, "sub")); + File.WriteAllText(Path.Combine(dir.Path, "sub", "types.json"), "[]"); + + var fs = new DirectoryPackageFileSystem(dir.Path); + + // Both slash variants should resolve to the same file + fs.FileExists("sub/types.json").Should().BeTrue(); + fs.FileExists(@"sub\types.json").Should().BeTrue(); + } + + // ── Normal reads ──────────────────────────────────────────────────────── + + [TestMethod] + public void Existing_file_is_read_with_deterministic_utf8_bytes() + { + using var dir = new TempDir(); + const string content = "{\"hello\":\"wörld\"}"; + byte[] expected = System.Text.Encoding.UTF8.GetBytes(content); + File.WriteAllBytes(Path.Combine(dir.Path, "data.json"), expected); + + var fs = new DirectoryPackageFileSystem(dir.Path); + var ok = fs.TryReadAllBytes("data.json", out byte[] actual, out _); + + ok.Should().BeTrue(); + actual.Should().Equal(expected); + } + + // ── Trailing separator on root ────────────────────────────────────────── + + [TestMethod] + public void Root_with_trailing_separator_still_resolves_child_files() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "index.json"), "{}"); + + // Supply the root with a trailing directory separator. + var rootWithSlash = dir.Path + Path.DirectorySeparatorChar; + var fs = new DirectoryPackageFileSystem(rootWithSlash); + + fs.FileExists("index.json").Should().BeTrue(); + fs.TryReadAllBytes("index.json", out _, out _).Should().BeTrue(); + } + + // ── Helper ────────────────────────────────────────────────────────────── + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-fstest-" + System.IO.Path.GetRandomFileName()); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/PackageArchiveReaderTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageArchiveReaderTests.cs new file mode 100644 index 00000000..d07ad315 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageArchiveReaderTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Packaging; + +[TestClass] +public class PackageArchiveReaderTests +{ + private const string MinimalIndexJson = @"{ + ""resources"": {}, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + + [TestMethod] + public void PackageInputResolver_archive_file_preserves_display_path_and_physical_path() + { + var resolution = PackageInputResolver.Resolve( + TypePackageValidationInput.ForArchiveFile("some/dir/types.tgz")); + + resolution.Kind.Should().Be(PackageInputKind.ArchiveFile); + resolution.ArchiveFilePath.Should().Be("some/dir/types.tgz"); + resolution.DisplayPath.Should().Contain("types.tgz"); + resolution.ArchiveBytes.Should().BeNull(); + } + + [TestMethod] + public void PackageInputResolver_archive_stream_preserves_display_path_and_stream() + { + var payload = new byte[] { 1, 2, 3, 4 }; + using var stream = new MemoryStream(payload); + + var resolution = PackageInputResolver.Resolve( + TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + resolution.Kind.Should().Be(PackageInputKind.ArchiveStream); + resolution.DisplayPath.Should().Be("types.tgz"); + resolution.ArchiveBytes.Should().Equal(payload); + // The caller's stream is read fully but not disposed. + stream.CanRead.Should().BeTrue(); + } + + [TestMethod] + public void PackageReader_archive_input_returns_archive_file_system() + { + var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson)); + using var stream = new MemoryStream(archive); + var resolution = PackageInputResolver.Resolve( + TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + var result = PackageReader.Read(resolution, new TypePackageValidationOptions()); + + result.HasFatalReadFailure.Should().BeFalse(); + result.FileSystem.Should().BeOfType(); + result.Documents.IndexDocument.Should().NotBeNull(); + } + + [TestMethod] + public void PackageReader_archive_read_failure_is_fatal() + { + using var stream = new MemoryStream(new byte[] { 9, 9, 9, 9 }); + var resolution = PackageInputResolver.Resolve( + TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + var result = PackageReader.Read(resolution, new TypePackageValidationOptions()); + + result.HasFatalReadFailure.Should().BeTrue(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchivePackageInvalid); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/PackageReaderTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageReaderTests.cs new file mode 100644 index 00000000..4ed39c97 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageReaderTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Azure.Bicep.Types.Validation; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Packaging; + +[TestClass] +public class PackageReaderTests +{ + private static readonly TypePackageValidationOptions DefaultOptions = new(); + + // ── Directory input ───────────────────────────────────────────────────── + + [TestMethod] + public void Directory_input_reads_package_root_index_json() + { + using var pkg = CreateMinimalPackage(); + var resolution = Resolve(pkg.Path, isDirectory: true); + var result = PackageReader.Read(resolution, DefaultOptions); + + result.HasFatalReadFailure.Should().BeFalse(); + result.Documents.IndexDocument.Should().NotBeNull(); + result.Documents.IndexDocument!.PackageRelativePath.Should().Be("index.json"); + } + + [TestMethod] + public void Index_file_input_treats_containing_directory_as_package_root() + { + using var pkg = CreateMinimalPackage(); + string indexPath = Path.Combine(pkg.Path, "index.json"); + var resolution = Resolve(indexPath, isDirectory: false); + var result = PackageReader.Read(resolution, DefaultOptions); + + result.HasFatalReadFailure.Should().BeFalse(); + result.Documents.IndexDocument.Should().NotBeNull(); + } + + [TestMethod] + public void Nonexistent_directory_input_returns_package_path_invalid() + { + var resolution = Resolve("C:\\does-not-exist-xyz-abc", isDirectory: true); + var result = PackageReader.Read(resolution, DefaultOptions); + + result.HasFatalReadFailure.Should().BeTrue(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PackagePathInvalid); + } + + [TestMethod] + public void Directory_input_with_missing_index_json_returns_index_file_missing() + { + using var emptyDir = new TempDir(); + var resolution = Resolve(emptyDir.Path, isDirectory: true); + var result = PackageReader.Read(resolution, DefaultOptions); + + result.HasFatalReadFailure.Should().BeTrue(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.IndexFileMissing); + } + + // ── Type files are not loaded by the reader (owned by the graph provider) ─ + + [TestMethod] + public void Reader_does_not_load_type_files() + { + // Type-file loading and transitive closure are owned by the graph layer's + // package document provider; the reader materializes only index.json. + using var pkg = CreatePackageWithTypes(); + var resolution = Resolve(pkg.Path, isDirectory: true); + var result = PackageReader.Read(resolution, DefaultOptions); + + result.HasFatalReadFailure.Should().BeFalse(); + result.Documents.TypeFiles.Should().BeEmpty(); + result.FileSystem.Should().NotBeNull(); + } + + [TestMethod] + public void Missing_referenced_type_file_is_not_a_reader_read_failure() + { + // index.json references types.json but that file doesn't exist. The reader only + // parses index.json, so it reports no read failure; the missing file is reported + // later by graph validation (BCPVT017). + const string indexJson = @"{ + ""resources"": { ""S/r@2026-01-01"": { ""$ref"": ""types.json#/0"" } }, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + using var pkg = new TempDir(); + File.WriteAllText(Path.Combine(pkg.Path, "index.json"), indexJson); + // types.json intentionally NOT created + + var resolution = Resolve(pkg.Path, isDirectory: true); + var result = PackageReader.Read(resolution, DefaultOptions); + + result.HasFatalReadFailure.Should().BeFalse(); // index read succeeded + result.Diagnostics.Should().BeEmpty(); + } + + // ── Archive input pass-through (handled upstream) ──────────────────────── + + [TestMethod] + public void Null_package_root_path_returns_package_path_invalid() + { + // A resolution with null PackageRootPath shouldn't crash the reader + var resolution = new PackageInputResolution( + PackageInputKind.Directory, "display", + packageRootPath: null, indexFilePath: null, + diagnostics: new TypeValidationDiagnostic[0]); + + var result = PackageReader.Read(resolution, DefaultOptions); + + result.HasFatalReadFailure.Should().BeTrue(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PackagePathInvalid); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static PackageInputResolution Resolve(string path, bool isDirectory) + { + var input = isDirectory + ? TypePackageValidationInput.ForDirectory(path) + : TypePackageValidationInput.ForIndexFile(path); + return PackageInputResolver.Resolve(input); + } + + private static TempDir CreateMinimalPackage() + { + var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "index.json"), + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + return dir; + } + + private static TempDir CreatePackageWithTypes() + { + var dir = new TempDir(); + const string indexJson = @"{ + ""resources"": { ""S/r@2026-01-01"": { ""$ref"": ""types.json#/0"" } }, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + File.WriteAllText(Path.Combine(dir.Path, "index.json"), indexJson); + File.WriteAllText(Path.Combine(dir.Path, "types.json"), "[{\"$type\":\"StringType\"}]"); + return dir; + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-rdr-" + System.IO.Path.GetRandomFileName()); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/PaxArchiveInteropTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/PaxArchiveInteropTests.cs new file mode 100644 index 00000000..720b27d4 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/PaxArchiveInteropTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Packaging; + +/// +/// Interop coverage proving that gzip-compressed tar archives produced the way Bicep's own tgz writer +/// produces them — via in using +/// — validate successfully. .NET emits a PAX extended-header entry (typeflag +/// 'x') before every file entry; earlier the reader forwarded those headers as unsupported members +/// and rejected real Bicep archives. These tests use the framework's own tar writer (available on +/// net8.0) rather than the hand-rolled ustar helper so a regression is caught against the real +/// producer format. +/// +[TestClass] +public class PaxArchiveInteropTests +{ + private const string MinimalIndexJson = @"{ + ""resources"": {}, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + + private const string ResourceIndexJson = @"{ + ""resources"": { + ""Sample.Provider/widgets@2026-01-01"": { + ""$ref"": ""types.json#/2"" + } + }, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + + private const string ResourceTypesJson = @"[ + { ""$type"": ""StringType"" }, + { + ""$type"": ""ObjectType"", + ""name"": ""widgetBody"", + ""properties"": { + ""name"": { + ""type"": { ""$ref"": ""#/0"" }, + ""flags"": 1, + ""description"": ""The widget name."" + } + } + }, + { + ""$type"": ""ResourceType"", + ""name"": ""Sample.Provider/widgets@2026-01-01"", + ""body"": { ""$ref"": ""#/1"" }, + ""readableScopes"": 8, + ""writableScopes"": 8 + } +]"; + + private static readonly TypePackageValidator Validator = new(); + + [TestMethod] + public void Pax_archive_stream_validates_minimal_package() + { + var archive = BuildPaxArchive(("index.json", MinimalIndexJson)); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void Pax_archive_stream_validates_multi_file_resource_package() + { + var archive = BuildPaxArchive( + ("index.json", ResourceIndexJson), + ("types.json", ResourceTypesJson)); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void Pax_archive_does_not_report_extended_header_as_unsupported_member() + { + var archive = BuildPaxArchive(("index.json", MinimalIndexJson)); + using var stream = new MemoryStream(archive); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.Diagnostics.Should().NotContain(d => + d.Code == TypeValidationDiagnosticCodes.ArchivePackageInvalid || + d.Code == TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid); + } + + [TestMethod] + public void Pax_archive_reader_consumes_extended_headers_and_returns_only_file_members() + { + var archive = BuildPaxArchive( + ("index.json", MinimalIndexJson), + ("types.json", "[]")); + + var readResult = TarGzArchiveReader.Read(archive); + + readResult.Success.Should().BeTrue(); + readResult.Entries.Should().OnlyContain(e => e.TypeFlag == (byte)'0' || e.TypeFlag == 0); + readResult.Entries.Select(e => e.RawName).Should().BeEquivalentTo(new[] { "index.json", "types.json" }); + } + + [TestMethod] + public void Pax_archive_with_long_member_name_uses_the_extended_path() + { + // Names longer than the 100-byte ustar name field force .NET to emit the real path only in the + // PAX "path" extended attribute, exercising the reader's override handling. + var longName = "deeply/nested/" + new string('a', 120) + "/types.json"; + var archive = BuildPaxArchive( + ("index.json", MinimalIndexJson), + (longName, "[]")); + + var readResult = TarGzArchiveReader.Read(archive); + + readResult.Success.Should().BeTrue(); + readResult.Entries.Select(e => e.RawName).Should().Contain(longName); + } + + private static byte[] BuildPaxArchive(params (string Name, string Text)[] files) + { + using var outer = new MemoryStream(); + using (var gzip = new GZipStream(outer, CompressionLevel.Optimal, leaveOpen: true)) + using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true)) + { + foreach (var (name, text) in files) + { + var entry = new PaxTarEntry(TarEntryType.RegularFile, name) + { + DataStream = new MemoryStream(Encoding.UTF8.GetBytes(text)), + }; + tar.WriteEntry(entry); + } + } + + return outer.ToArray(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/SourceMapTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/SourceMapTests.cs new file mode 100644 index 00000000..c514f16f --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/SourceMapTests.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Text; +using Azure.Bicep.Types.Validation.Packaging; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Packaging; + +[TestClass] +public class SourceMapTests +{ + // ── Root value location ───────────────────────────────────────────────── + + [TestMethod] + public void Root_value_location_is_1_based() + { + byte[] json = Encoding.UTF8.GetBytes("{\"x\":1}"); + var sm = SourceMap.Create(json); + var loc = sm.GetLocation(0); + loc.Line.Should().Be(1); + loc.Column.Should().Be(1); + } + + // ── Object property locations ─────────────────────────────────────────── + + [TestMethod] + public void Object_property_value_location_is_available_via_byte_offset() + { + // {"x":42} + // offset 0 = { (line 1 col 1) + // offset 1 = " (start of "x") + // offset 5 = 4 (start of 42) + byte[] json = Encoding.UTF8.GetBytes("{\"x\":42}"); + SourceMap.TryParse(json, "test.json", out var root, out var sm, out _); + root.Should().NotBeNull(); + root!.TryGetProperty("x", out var xNode).Should().BeTrue(); + var loc = sm.GetLocation(xNode.ByteOffset); + loc.Line.Should().Be(1); + loc.Column.Should().BePositive(); + } + + // ── Array element location ────────────────────────────────────────────── + + [TestMethod] + public void Array_element_location_is_available() + { + byte[] json = Encoding.UTF8.GetBytes("[1, 2, 3]"); + SourceMap.TryParse(json, "test.json", out var root, out var sm, out _); + root!.Elements.Count.Should().Be(3); + var elem1Loc = sm.GetLocation(root.Elements[0].ByteOffset); + var elem2Loc = sm.GetLocation(root.Elements[1].ByteOffset); + elem1Loc.Column.Should().BeLessThan(elem2Loc.Column); + } + + // ── Newline handling ──────────────────────────────────────────────────── + + [TestMethod] + public void LF_newline_resets_column_to_1_on_next_line() + { + byte[] json = Encoding.UTF8.GetBytes("{\n\"x\":1\n}"); + SourceMap.TryParse(json, "test.json", out var root, out var sm, out _); + root!.TryGetProperty("x", out var xNode).Should().BeTrue(); + // "x" key is on line 2 + var keyProp = root.Properties[0]; + var keyLoc = sm.GetLocation(keyProp.NameByteOffset); + keyLoc.Line.Should().Be(2); + keyLoc.Column.Should().Be(1); + } + + [TestMethod] + public void CRLF_newline_treated_as_single_line_break() + { + byte[] json = Encoding.UTF8.GetBytes("{\r\n\"x\":1\r\n}"); + SourceMap.TryParse(json, "test.json", out var root, out var sm, out _); + root!.TryGetProperty("x", out var xNode).Should().BeTrue(); + var keyLoc = sm.GetLocation(root.Properties[0].NameByteOffset); + keyLoc.Line.Should().Be(2); + keyLoc.Column.Should().Be(1); + } + + // ── UTF-16 column counting ────────────────────────────────────────────── + + [TestMethod] + public void Columns_are_1_based_utf16_code_units_including_non_ascii() + { + // Line with a 3-byte UTF-8 character (€ = U+20AC) before the property + // "€": 1 → key starts at UTF-16 column 2 (after the "{") + string line = "{\"" + "\u20AC" + "\":1}"; + byte[] json = Encoding.UTF8.GetBytes(line); + SourceMap.TryParse(json, "test.json", out var root, out var sm, out _); + root!.Properties.Count.Should().Be(1); + // Key byte offset is 1 (the quote after {) + var keyLoc = sm.GetLocation(root.Properties[0].NameByteOffset); + keyLoc.Line.Should().Be(1); + // Column should reflect UTF-16 units: "{" takes column 1, the quote is column 2 + keyLoc.Column.Should().Be(2); + } + + // ── Single-pass build ─────────────────────────────────────────────────── + + [TestMethod] + public void TryParse_builds_value_nodes_and_source_map_in_one_call() + { + byte[] json = Encoding.UTF8.GetBytes("{\"a\":1,\"b\":\"hello\"}"); + var success = SourceMap.TryParse(json, "test.json", out var root, out var sm, out var err); + success.Should().BeTrue(); + err.Should().BeNull(); + root.Should().NotBeNull(); + sm.Should().NotBeNull(); + root!.Properties.Count.Should().Be(2); + } + + // ── Malformed JSON ────────────────────────────────────────────────────── + + [TestMethod] + public void Malformed_json_returns_false_with_line_column() + { + byte[] json = Encoding.UTF8.GetBytes("{ invalid }"); + var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err); + success.Should().BeFalse(); + root.Should().BeNull(); + err.Should().NotBeNull(); + err!.Value.line.Should().BeGreaterThan(0); + err.Value.column.Should().BeGreaterThan(0); + } + + // ── Trailing content after root value ─────────────────────────────────── + + [TestMethod] + public void Trailing_garbage_after_object_root_is_rejected() + { + byte[] json = Encoding.UTF8.GetBytes("{\"x\":1} garbage"); + var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err); + success.Should().BeFalse(); + root.Should().BeNull(); + err.Should().NotBeNull(); + } + + [TestMethod] + public void Second_top_level_value_after_array_root_is_rejected() + { + byte[] json = Encoding.UTF8.GetBytes("[1,2] [3,4]"); + var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err); + success.Should().BeFalse(); + root.Should().BeNull(); + err.Should().NotBeNull(); + } + + [TestMethod] + public void Trailing_whitespace_after_root_value_is_accepted() + { + byte[] json = Encoding.UTF8.GetBytes("{\"x\":1}\r\n \n"); + var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err); + success.Should().BeTrue(); + err.Should().BeNull(); + root.Should().NotBeNull(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/TarGzTestArchive.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/TarGzTestArchive.cs new file mode 100644 index 00000000..fb60aaba --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/TarGzTestArchive.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Azure.Bicep.Types.Validation.UnitTests.Packaging; + +/// +/// A single entry to place into a test tar archive. +/// +internal readonly struct TarGzTestEntry +{ + public TarGzTestEntry(string name, byte[] content, byte typeFlag = (byte)'0') + { + Name = name; + Content = content; + TypeFlag = typeFlag; + } + + public string Name { get; } + + public byte[] Content { get; } + + public byte TypeFlag { get; } + + public static TarGzTestEntry File(string name, string content) => + new TarGzTestEntry(name, Encoding.UTF8.GetBytes(content)); + + public static TarGzTestEntry Directory(string name) => + new TarGzTestEntry(name, Array.Empty(), (byte)'5'); + + public static TarGzTestEntry Symlink(string name) => + new TarGzTestEntry(name, Array.Empty(), (byte)'2'); +} + +/// +/// Builds tiny gzip-compressed ustar archives for archive-input tests, producing correct headers, +/// octal size fields, and checksums so the product reader accepts valid archives. +/// +internal static class TarGzTestArchive +{ + private const int BlockSize = 512; + + /// Builds a gzip-compressed tar from text file entries. + public static byte[] FromTextFiles(params (string name, string content)[] files) + { + var entries = new List(); + foreach (var (name, content) in files) + { + entries.Add(TarGzTestEntry.File(name, content)); + } + return Build(entries); + } + + /// Builds a gzip-compressed tar from arbitrary entries. + public static byte[] Build(IEnumerable entries) + { + byte[] tar = BuildTar(entries); + return GzipCompress(tar); + } + + /// Builds an uncompressed tar (for producing malformed-container fixtures). + public static byte[] BuildTar(IEnumerable entries) + { + using var stream = new MemoryStream(); + foreach (var entry in entries) + { + WriteEntry(stream, entry); + } + + // Two zero blocks terminate the archive. + stream.Write(new byte[BlockSize * 2], 0, BlockSize * 2); + return stream.ToArray(); + } + + /// Gzip-compresses raw bytes. + public static byte[] GzipCompress(byte[] bytes) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + gzip.Write(bytes, 0, bytes.Length); + } + return output.ToArray(); + } + + private static void WriteEntry(Stream stream, TarGzTestEntry entry) + { + var header = new byte[BlockSize]; + byte[] nameBytes = Encoding.ASCII.GetBytes(entry.Name); + Array.Copy(nameBytes, 0, header, 0, Math.Min(nameBytes.Length, 100)); + + WriteOctal(header, 100, 8, 0b_110_100_100); // mode 0644 + WriteOctal(header, 108, 8, 0); // uid + WriteOctal(header, 116, 8, 0); // gid + WriteOctal(header, 124, 12, entry.Content.Length); // size + WriteOctal(header, 136, 12, 0); // mtime + + header[156] = entry.TypeFlag; + + // ustar magic + version. + byte[] magic = Encoding.ASCII.GetBytes("ustar"); + Array.Copy(magic, 0, header, 257, magic.Length); + header[263] = (byte)'0'; + header[264] = (byte)'0'; + + WriteChecksum(header); + + stream.Write(header, 0, BlockSize); + + if (entry.Content.Length > 0) + { + stream.Write(entry.Content, 0, entry.Content.Length); + int remainder = entry.Content.Length % BlockSize; + if (remainder != 0) + { + stream.Write(new byte[BlockSize - remainder], 0, BlockSize - remainder); + } + } + } + + private static void WriteOctal(byte[] header, int offset, int length, long value) + { + // length-1 octal digits, zero-padded, followed by a NUL terminator. + string text = Convert.ToString(value, 8).PadLeft(length - 1, '0'); + byte[] bytes = Encoding.ASCII.GetBytes(text); + Array.Copy(bytes, 0, header, offset, length - 1); + header[offset + length - 1] = 0; + } + + private static void WriteChecksum(byte[] header) + { + // Checksum is computed with the checksum field treated as 8 spaces. + for (int i = 148; i < 156; i++) + { + header[i] = (byte)' '; + } + + long sum = 0; + for (int i = 0; i < BlockSize; i++) + { + sum += header[i]; + } + + // Six octal digits, NUL, then a space. + string text = Convert.ToString(sum, 8).PadLeft(6, '0'); + byte[] bytes = Encoding.ASCII.GetBytes(text); + Array.Copy(bytes, 0, header, 148, 6); + header[154] = 0; + header[155] = (byte)' '; + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/BuiltInTypePolicyValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Policy/BuiltInTypePolicyValidatorTests.cs new file mode 100644 index 00000000..df1912a3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Policy/BuiltInTypePolicyValidatorTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.UnitTests.Graph; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Policy; + +[TestClass] +public class BuiltInTypePolicyValidatorTests +{ + // A reached type file: /0 ResourceType (modern scopes only) whose body reaches the + // BuiltInType under test at /1. + private static string Package(string builtInKindJson) => + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," + + "{\"$type\":\"BuiltInType\"," + builtInKindJson + "}]"; + + private static System.Collections.Generic.IReadOnlyList Run( + string builtInKindJson, TypePackageValidationMode mode) + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", Package(builtInKindJson)); + return PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs, mode); + } + + [TestMethod] + public void Documented_builtin_kind_reports_bcpvt022_in_canonical() + { + Run("\"kind\":5", TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation) + .Which.JsonPointer.Should().Be("/1/kind"); + } + + [TestMethod] + public void Documented_builtin_kind_reports_bcpvt023_in_compatible() + { + var diagnostics = Run("\"kind\":5", TypePackageValidationMode.CompatibleReader); + + diagnostics.Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CompatibilityFormUsed) + .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning); + } + + [TestMethod] + public void Reserved_builtin_kind_8_reports_bcpvt022_in_canonical() + { + // Kind 8 (ResourceRef) has no canonical replacement but is still rejected in canonical. + Run("\"kind\":8", TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.CanonicalFormViolation); + } + + [TestMethod] + [DataRow(0)] + [DataRow(9)] + [DataRow(-1)] + public void Out_of_range_builtin_kind_reports_bcpvt025_in_canonical(int kind) + { + Run("\"kind\":" + kind, TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.BuiltInTypeKindInvalid); + } + + [TestMethod] + public void Out_of_range_builtin_kind_reports_bcpvt025_in_compatible() + { + Run("\"kind\":42", TypePackageValidationMode.CompatibleReader) + .Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.BuiltInTypeKindInvalid); + } + + [TestMethod] + public void Missing_builtin_kind_is_owned_by_structural_layer() + { + // No 'kind' property: the structural layer owns the required-property diagnostic; policy is silent. + Run("\"name\":\"unused\"", TypePackageValidationMode.CanonicalWriter) + .Should().BeEmpty(); + } + + [TestMethod] + public void Non_integer_builtin_kind_is_owned_by_structural_layer() + { + Run("\"kind\":\"five\"", TypePackageValidationMode.CanonicalWriter) + .Should().BeEmpty(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/PolicyTestHelpers.cs b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyTestHelpers.cs new file mode 100644 index 00000000..51b0086c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyTestHelpers.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Policy; +using Azure.Bicep.Types.Validation.UnitTests.Graph; + +namespace Azure.Bicep.Types.Validation.UnitTests.Policy; + +/// +/// Shared helpers for policy-layer tests. Mirrors the driver: the graph traversal populates the +/// provider cache with reached type files, then the mode-policy layer classifies them. +/// +internal static class PolicyTestHelpers +{ + public static IReadOnlyList RunPolicy( + string indexJson, InMemoryPackageFileSystem fs, TypePackageValidationMode mode) + { + var options = new TypePackageValidationOptions { Mode = mode }; + var index = GraphTestHelpers.Document("index.json", indexJson); + var provider = new PackageDocumentProvider(fs, index, options); + + // Trigger graph traversal so the provider loads and caches the reached type files. + SemanticGraphValidator.Validate(provider, index, options); + + return PolicyValidator.Validate(provider.GetReachedUsableTypeFiles(), options); + } + + /// An index that routes a single resource type to . + public static string ResourceIndex(string refValue) => + "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"" + refValue + "\"}}," + + "\"resourceFunctions\":{},\"namespaceFunctions\":[]}"; +} diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/PolicyValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyValidatorTests.cs new file mode 100644 index 00000000..b18b4b84 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyValidatorTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.UnitTests.Graph; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Policy; + +[TestClass] +public class PolicyValidatorTests +{ + private static readonly TypePackageValidator Validator = new(); + + [TestMethod] + public void Policy_scans_unreferenced_nodes_in_reached_type_files() + { + // The index reaches /0; /1 is a BuiltInType that no reference points to. Policy inspects + // every structurally usable element of a reached file, so /1 is still classified. + const string types = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/2\"},\"readableScopes\":8,\"writableScopes\":8}," + + "{\"$type\":\"BuiltInType\",\"kind\":5}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs, + TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation) + .Which.JsonPointer.Should().Be("/1/kind"); + } + + [TestMethod] + public void Unreached_type_files_are_not_scanned_by_policy() + { + // types.json is reached and clean; other.json contains a legacy field but is never + // referenced, so policy must not scan it. + const string types = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"; + const string other = + "[{\"$type\":\"ResourceType\",\"name\":\"Other/y@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"scopeType\":0}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"; + var fs = new InMemoryPackageFileSystem() + .AddText("types.json", types) + .AddText("other.json", other); + + PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs, + TypePackageValidationMode.CanonicalWriter) + .Should().BeEmpty(); + } + + // ── Result-shaping lock-in tests (through the full validator) ───────────── + + [TestMethod] + public void Compatibility_legacy_field_produces_single_warning_and_valid_result() + { + using var pkg = LegacyScopePackage(); + var options = new TypePackageValidationOptions { Mode = TypePackageValidationMode.CompatibleReader }; + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.CompatibilityFormUsed); + result.Summary.WarningCount.Should().Be(1); + result.Summary.ErrorCount.Should().Be(0); + } + + [TestMethod] + public void Compatibility_warning_excluded_when_include_warnings_false_but_result_stays_valid() + { + using var pkg = LegacyScopePackage(); + var options = new TypePackageValidationOptions + { + Mode = TypePackageValidationMode.CompatibleReader, + IncludeWarnings = false, + }; + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + // Summary still counts the suppressed warning. + result.Summary.WarningCount.Should().Be(1); + } + + [TestMethod] + public void Canonical_legacy_field_produces_policy_error() + { + using var pkg = LegacyScopePackage(); + var options = new TypePackageValidationOptions { Mode = TypePackageValidationMode.CanonicalWriter }; + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().Contain(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation); + } + + /// + /// A package whose single resource type uses only the legacy scopeType scope field. + /// In CompatibleReader the modern pair is not required, yielding a clean single-warning result. + /// + private static TempDir LegacyScopePackage() + { + var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "index.json"), + "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"types.json#/0\"}}," + + "\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + File.WriteAllText(Path.Combine(dir.Path, "types.json"), + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"scopeType\":0}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"); + return dir; + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-policy-test-" + System.IO.Path.GetRandomFileName()); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/ResourceScopePolicyValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Policy/ResourceScopePolicyValidatorTests.cs new file mode 100644 index 00000000..c09d4bfe --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Policy/ResourceScopePolicyValidatorTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.UnitTests.Graph; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Policy; + +[TestClass] +public class ResourceScopePolicyValidatorTests +{ + // A reached type file whose single ResourceType at /0 carries the given scope fields and a + // body that resolves to the StringType at /1. + private static string Package(string scopeFieldsJson) => + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"}," + scopeFieldsJson + "}," + + "{\"$type\":\"StringType\"}]"; + + private static System.Collections.Generic.IReadOnlyList Run( + string scopeFieldsJson, TypePackageValidationMode mode) + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", Package(scopeFieldsJson)); + return PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs, mode); + } + + [TestMethod] + public void Legacy_scope_type_reports_bcpvt022_in_canonical() + { + Run("\"scopeType\":0", TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation) + .Which.JsonPointer.Should().Be("/0/scopeType"); + } + + [TestMethod] + public void Legacy_scope_type_reports_bcpvt023_in_compatible() + { + Run("\"scopeType\":0", TypePackageValidationMode.CompatibleReader) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CompatibilityFormUsed) + .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning); + } + + [TestMethod] + public void Legacy_read_only_scopes_reports_bcpvt022_in_canonical() + { + Run("\"readOnlyScopes\":4", TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation) + .Which.JsonPointer.Should().Be("/0/readOnlyScopes"); + } + + [TestMethod] + public void Legacy_nonzero_flags_reports_bcpvt023_in_compatible() + { + Run("\"flags\":1", TypePackageValidationMode.CompatibleReader) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CompatibilityFormUsed) + .Which.JsonPointer.Should().Be("/0/flags"); + } + + [TestMethod] + public void Flags_zero_with_modern_pair_reports_per_field_policy_not_mixing() + { + // flags:0 is not an effective legacy value, so pairing it with the modern scopes is not a + // mix: the field is still classified per-field (BCPVT022), not BCPVT024. + Run("\"readableScopes\":8,\"writableScopes\":8,\"flags\":0", TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation) + .Which.JsonPointer.Should().Be("/0/flags"); + } + + [TestMethod] + public void Mixed_modern_and_legacy_scope_type_reports_single_bcpvt024_in_canonical() + { + Run("\"readableScopes\":8,\"writableScopes\":8,\"scopeType\":0", TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ResourceScopeFormMixed); + } + + [TestMethod] + public void Mixed_modern_and_nonzero_flags_reports_single_bcpvt024_in_compatible() + { + // BCPVT024 is emitted in both modes; mixing is never merely a compatibility warning. + Run("\"readableScopes\":8,\"writableScopes\":8,\"flags\":2", TypePackageValidationMode.CompatibleReader) + .Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ResourceScopeFormMixed); + } + + [TestMethod] + public void Mixed_scope_form_reports_single_bcpvt024_and_suppresses_per_field_policy_diagnostics() + { + var diagnostics = Run( + "\"readableScopes\":8,\"writableScopes\":8,\"scopeType\":0,\"readOnlyScopes\":4", + TypePackageValidationMode.CanonicalWriter); + + diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ResourceScopeFormMixed); + // The single diagnostic names the first effective legacy field. + diagnostics.Single().Message.Should().Contain("scopeType"); + } + + [TestMethod] + public void Resource_scope_policy_reads_only_resource_type_root_flags() + { + // The ResourceType has no root-level legacy scope fields. A property-level 'flags' on a + // reached ObjectType must not be read by the ResourceType scope policy. + const string types = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\"," + + "\"properties\":{\"p\":{\"type\":{\"$ref\":\"#/2\"},\"flags\":1}}}," + + "{\"$type\":\"StringType\"}]"; + var fs = new InMemoryPackageFileSystem().AddText("types.json", types); + + PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs, + TypePackageValidationMode.CanonicalWriter) + .Should().BeEmpty(); + } + + [TestMethod] + public void Wrong_shape_legacy_field_is_owned_by_structural_layer() + { + // A non-integer legacy field is a structural primitive-shape error; policy stays silent. + Run("\"scopeType\":\"all\"", TypePackageValidationMode.CanonicalWriter) + .Should().BeEmpty(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineTests.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineTests.cs new file mode 100644 index 00000000..a3f0f673 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +/// +/// Opt-in baseline maintenance. These tests never write source files during a normal run; the +/// update test only writes when SetBaseLine=true is passed as a VSTest run parameter or the +/// environment variable is set. +/// +/// +/// To update baselines locally: +/// +/// dotnet test src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj ` +/// --filter "TestCategory=Baseline" ` +/// -- 'TestRunParameters.Parameter(name="SetBaseLine", value="true")' +/// +/// or set BICEP_TYPES_VALIDATION_SET_BASELINE=true. The samples source root is discovered by +/// walking up to the test project, or can be overridden with +/// BICEP_TYPES_VALIDATION_SAMPLES_ROOT. A stale/relocated build must be rebuilt after an +/// update so the embedded baselines are refreshed before the comparison tests run. +/// +[TestClass] +public class ValidationSampleBaselineTests +{ + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + [TestCategory("Baseline")] + public void Update_baselines_when_requested() + { + var runParameter = TestContext.Properties.Contains("SetBaseLine") + ? TestContext.Properties["SetBaseLine"] as string + : null; + + if (!ValidationSampleBaselineUpdater.IsUpdateRequested(runParameter)) + { + Assert.Inconclusive( + "Baseline update is opt-in. Pass -- 'TestRunParameters.Parameter(name=\"SetBaseLine\", value=\"true\")' " + + $"or set {ValidationSampleBaselineUpdater.SetBaselineEnvVar}=true to update baselines."); + return; + } + + var samplesRoot = ValidationSampleBaselineUpdater.ResolveSamplesRoot(); + var summary = ValidationSampleBaselineUpdater.UpdateCorpus(samplesRoot); + + foreach (var written in summary.Written) + { + TestContext.WriteLine($"UPDATED {written}"); + } + + foreach (var mismatch in summary.Mismatches) + { + TestContext.WriteLine($"MISMATCH {mismatch}"); + } + + summary.Mismatches.Should().BeEmpty( + "multi-input scenarios must produce identical normalized results before a baseline is written."); + } + + [TestMethod] + public void Coverage_report_is_emitted_to_the_test_output_directory() + { + var markdown = ValidationSampleCoverageReport.ToMarkdown(ValidationSampleCoverageReport.Build()); + + var reportPath = Path.Combine(AppContext.BaseDirectory, "validation-sample-coverage.md"); + File.WriteAllText(reportPath, markdown); + + TestContext.WriteLine($"Coverage report written to {reportPath}"); + File.Exists(reportPath).Should().BeTrue(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineUpdater.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineUpdater.cs new file mode 100644 index 00000000..a41979f2 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineUpdater.cs @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +/// +/// Explicit, opt-in baseline update workflow for the validation sample corpus. Normal test runs +/// never write source files. Update mode writes normalized results back to the source +/// expected/<mode>.result.json files, grouped per (scenario, mode) so a +/// multi-input scenario cannot silently overwrite one baseline with differing input results. +/// +public static class ValidationSampleBaselineUpdater +{ + /// Environment variable that enables baseline update mode when set to true. + public const string SetBaselineEnvVar = "BICEP_TYPES_VALIDATION_SET_BASELINE"; + + /// Environment variable that overrides the samples source root used for writes. + public const string SamplesRootEnvVar = "BICEP_TYPES_VALIDATION_SAMPLES_ROOT"; + + private const string ProjectFileName = "Bicep.Types.Validation.UnitTests.csproj"; + + /// + /// Whether a baseline update was explicitly requested, via the + /// environment variable or the supplied VSTest SetBaseLine run-parameter value. + /// + public static bool IsUpdateRequested(string? runParameterValue) + => IsTrue(Environment.GetEnvironmentVariable(SetBaselineEnvVar)) || IsTrue(runParameterValue); + + private static bool IsTrue(string? value) + => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + + /// + /// Resolves the samples source root deterministically without depending on absolute paths + /// embedded in the build. Order: an explicit root, the + /// environment variable, then a walk upward from the test binaries to the project directory. + /// Throws a clear, actionable error when the source tree cannot be located. + /// + public static string ResolveSamplesRoot(string? explicitRoot = null) + { + var candidate = !string.IsNullOrEmpty(explicitRoot) + ? explicitRoot + : Environment.GetEnvironmentVariable(SamplesRootEnvVar); + + if (!string.IsNullOrEmpty(candidate)) + { + var full = Path.GetFullPath(candidate!); + if (!Directory.Exists(full)) + { + throw new DirectoryNotFoundException( + $"Samples root '{full}' (from an explicit value or {SamplesRootEnvVar}) does not exist."); + } + + return full; + } + + var projectDir = FindProjectDirectory(AppContext.BaseDirectory); + if (projectDir is null) + { + throw new InvalidOperationException( + $"Could not locate the samples source tree. Set the {SamplesRootEnvVar} environment variable to " + + $"'/src/Bicep.Types.Validation.UnitTests/Files/validation-samples' and retry the baseline update."); + } + + return Path.GetFullPath(Path.Combine(projectDir, "Files", "validation-samples")); + } + + private static string? FindProjectDirectory(string startDirectory) + { + var dir = new DirectoryInfo(startDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, ProjectFileName))) + { + return dir.FullName; + } + + dir = dir.Parent; + } + + return null; + } + + /// + /// Computes the source-tree write target for a scenario/mode baseline and verifies it stays + /// under the canonical samples root. Rejects any resource prefix that escapes the root. + /// + public static string ComputeWriteTarget(string samplesRoot, string resourcePrefix, string mode) + { + var rootFull = Path.GetFullPath(samplesRoot); + var relative = ToScenarioRelativePath(resourcePrefix); + + var target = Path.GetFullPath(Path.Combine( + rootFull, + relative.Replace('/', Path.DirectorySeparatorChar), + "expected", + $"{mode}.result.json")); + + var rootWithSeparator = rootFull.EndsWith(Path.DirectorySeparatorChar) + ? rootFull + : rootFull + Path.DirectorySeparatorChar; + + if (!target.StartsWith(rootWithSeparator, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Refusing to write baseline outside the samples root. Target '{target}' is not under '{rootFull}'."); + } + + return target; + } + + private static string ToScenarioRelativePath(string resourcePrefix) + { + if (resourcePrefix.StartsWith(ValidationSampleData.SampleRootResourcePrefix, StringComparison.Ordinal)) + { + return resourcePrefix.Substring(ValidationSampleData.SampleRootResourcePrefix.Length); + } + + return resourcePrefix; + } + + /// + /// Reduces per-input normalized results to a single agreed baseline. Returns true and the + /// shared content when every input agrees; returns false and the index of the first + /// divergent input otherwise. Throws when the input list is empty. + /// + public static bool TryReconcileInputs( + IReadOnlyList normalizedResults, out string agreed, out int firstDivergentIndex) + { + if (normalizedResults.Count == 0) + { + throw new ArgumentException("At least one input result is required.", nameof(normalizedResults)); + } + + agreed = normalizedResults[0]; + for (var i = 1; i < normalizedResults.Count; i++) + { + if (!string.Equals(normalizedResults[i], agreed, StringComparison.Ordinal)) + { + firstDivergentIndex = i; + return false; + } + } + + firstDivergentIndex = -1; + return true; + } + + /// + /// Writes to only when it differs from + /// the existing file (comparing canonicalized JSON), creating the directory if needed. Returns + /// true when a write occurred. + /// + public static bool WriteBaselineIfChanged(string fullPath, string content) + { + if (File.Exists(fullPath)) + { + var existing = ValidationSampleResultNormalizer.Canonicalize(File.ReadAllText(fullPath)); + if (string.Equals(existing, content, StringComparison.Ordinal)) + { + return false; + } + } + + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + return true; + } + + /// + /// Runs the full corpus update against . Each declared + /// (scenario, mode) runs every input, requires identical normalized output, and writes + /// the baseline exactly once. Returns a deterministic, human-readable summary of changes and + /// mismatches. + /// + public static BaselineUpdateSummary UpdateCorpus(string samplesRoot) + { + var written = new List(); + var unchanged = new List(); + var mismatches = new List(); + + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + foreach (var mode in scenario.Modes) + { + var parsedMode = ValidationSampleData.ParseMode(mode); + var outcome = UpdateGroup( + samplesRoot, + scenario.ResourcePrefix, + scenario.Name, + mode, + scenario.Inputs, + input => ValidationSampleData.RunScenarioNormalized( + scenario.ResourcePrefix, input.Kind, input.Path, parsedMode, scenario.ValidateUnreachableFiles), + WriteBaselineIfChanged); + + switch (outcome.Kind) + { + case GroupUpdateKind.Written: + written.Add(outcome.RelativeTarget!); + break; + case GroupUpdateKind.Unchanged: + unchanged.Add(outcome.RelativeTarget!); + break; + case GroupUpdateKind.Mismatch: + mismatches.Add(outcome.MismatchMessage!); + break; + } + } + } + + written.Sort(StringComparer.Ordinal); + unchanged.Sort(StringComparer.Ordinal); + mismatches.Sort(StringComparer.Ordinal); + + return new BaselineUpdateSummary(written, unchanged, mismatches); + } + + /// + /// Updates a single (scenario, mode) group. Runs for every + /// input, requires all normalized results to be identical, and writes the baseline exactly once + /// through . Execution and writing are injected so the write-once, + /// no-write-on-mismatch, and mismatch-propagation guarantees can be tested without the real corpus. + /// + /// Produces the normalized result for one input. + /// Persists (fullPath, content); returns whether a write occurred. + public static GroupUpdateResult UpdateGroup( + string samplesRoot, + string resourcePrefix, + string scenarioName, + string mode, + IReadOnlyList inputs, + Func runInput, + Func writeIfChanged) + { + if (inputs.Count == 0) + { + throw new ArgumentException("At least one input is required.", nameof(inputs)); + } + + var relativeTarget = $"{ToScenarioRelativePath(resourcePrefix)}/expected/{mode}.result.json"; + var results = inputs.Select(runInput).ToList(); + + if (!TryReconcileInputs(results, out var agreed, out var divergentIndex)) + { + var first = inputs[0]; + var divergent = inputs[divergentIndex]; + return GroupUpdateResult.Mismatch( + $"{scenarioName} [{mode}]: input '{divergent.Kind}:{divergent.Path}' produced a different " + + $"normalized result than first input '{first.Kind}:{first.Path}'; baseline not written."); + } + + var target = ComputeWriteTarget(samplesRoot, resourcePrefix, mode); + return writeIfChanged(target, agreed) + ? GroupUpdateResult.Written(relativeTarget) + : GroupUpdateResult.Unchanged(relativeTarget); + } + + /// Kind of outcome produced by . + public enum GroupUpdateKind + { + /// The baseline content changed and was rewritten. + Written, + + /// The baseline was already up to date. + Unchanged, + + /// Inputs disagreed; nothing was written. + Mismatch, + } + + /// Outcome of updating a single (scenario, mode) group. + public sealed class GroupUpdateResult + { + private GroupUpdateResult(GroupUpdateKind kind, string? relativeTarget, string? mismatchMessage) + { + Kind = kind; + RelativeTarget = relativeTarget; + MismatchMessage = mismatchMessage; + } + + /// The outcome kind. + public GroupUpdateKind Kind { get; } + + /// Package-relative baseline path, for /. + public string? RelativeTarget { get; } + + /// Mismatch description, for . + public string? MismatchMessage { get; } + + internal static GroupUpdateResult Written(string relativeTarget) + => new(GroupUpdateKind.Written, relativeTarget, null); + + internal static GroupUpdateResult Unchanged(string relativeTarget) + => new(GroupUpdateKind.Unchanged, relativeTarget, null); + + internal static GroupUpdateResult Mismatch(string message) + => new(GroupUpdateKind.Mismatch, null, message); + } + + /// Deterministic outcome of a corpus baseline update. + public sealed class BaselineUpdateSummary + { + public BaselineUpdateSummary( + IReadOnlyList written, + IReadOnlyList unchanged, + IReadOnlyList mismatches) + { + Written = written; + Unchanged = unchanged; + Mismatches = mismatches; + } + + /// Baselines that were rewritten because their content changed. + public IReadOnlyList Written { get; } + + /// Baselines that were already up to date. + public IReadOnlyList Unchanged { get; } + + /// Scenario/mode combinations skipped because their inputs disagreed. + public IReadOnlyList Mismatches { get; } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleCoverageReport.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleCoverageReport.cs new file mode 100644 index 00000000..258175b4 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleCoverageReport.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +/// +/// Builds a deterministic coverage report over the validation sample corpus: which diagnostic +/// codes each baseline produces (keyed by scenario, mode, and severity), and counts by category, +/// mode, and input kind, plus simple corpus gaps. The report is derived only from observable +/// scenario metadata and checked-in expected result baselines; it does not run the validator. +/// +public static class ValidationSampleCoverageReport +{ + /// Categories the corpus is expected to cover (see phase-8 plan §6.1). + public static readonly IReadOnlyList KnownCategories = new[] + { + "valid.canonical", + "valid.input-forms", + "structural", + "invalid.graph", + "invalid.semantic", + "invalid.policy", + "invalid.archive", + "invalid.hygiene", + "compatibility", + "diagnostic-quality", + }; + + /// One diagnostic-code occurrence in a baseline, with its mode and severity context. + public readonly struct DiagnosticCoverageRow + { + public DiagnosticCoverageRow(string code, string scenario, string mode, string severity) + { + Code = code; + Scenario = scenario; + Mode = mode; + Severity = severity; + } + + public string Code { get; } + public string Scenario { get; } + public string Mode { get; } + public string Severity { get; } + } + + /// The full coverage model, with deterministically ordered rows and counts. + public sealed class CoverageModel + { + public CoverageModel( + IReadOnlyList diagnosticRows, + IReadOnlyList> categoryCounts, + IReadOnlyList> modeCounts, + IReadOnlyList> inputKindCounts, + IReadOnlyList gaps) + { + DiagnosticRows = diagnosticRows; + CategoryCounts = categoryCounts; + ModeCounts = modeCounts; + InputKindCounts = inputKindCounts; + Gaps = gaps; + } + + public IReadOnlyList DiagnosticRows { get; } + public IReadOnlyList> CategoryCounts { get; } + public IReadOnlyList> ModeCounts { get; } + public IReadOnlyList> InputKindCounts { get; } + public IReadOnlyList Gaps { get; } + } + + /// Builds the coverage model from discovered scenarios and their baselines. + public static CoverageModel Build() + { + var scenarios = ValidationSampleData.EnumerateScenarios().ToList(); + + var diagnosticRows = new List(); + var categoryCounts = new SortedDictionary(StringComparer.Ordinal); + var modeCounts = new SortedDictionary(StringComparer.Ordinal); + var inputKindCounts = new SortedDictionary(StringComparer.Ordinal); + var categoriesSeen = new HashSet(StringComparer.Ordinal); + var gaps = new List(); + + foreach (var scenario in scenarios) + { + var category = scenario.Category ?? "(none)"; + categoriesSeen.Add(category); + Increment(categoryCounts, category); + + foreach (var input in scenario.Inputs) + { + Increment(inputKindCounts, input.Kind.ToString()); + } + + foreach (var mode in scenario.Modes) + { + Increment(modeCounts, mode); + + var expectedResource = ValidationSampleData.GetExpectedResultResourceName(scenario.ResourcePrefix, mode); + if (!ValidationSampleData.ResourceExists(expectedResource)) + { + continue; + } + + diagnosticRows.AddRange(ReadBaselineDiagnostics(expectedResource, scenario.Name, mode)); + } + } + + // Corpus gap: a known category with no scenarios at all. This is a coverage-presence check, + // not a validity assertion: it does not infer expected diagnostics from a scenario's + // category. Per-scenario validity is enforced by the baseline-internal invariants in the + // sample health tests, not here. + foreach (var known in KnownCategories) + { + if (!categoriesSeen.Contains(known)) + { + gaps.Add($"category '{known}' has no scenarios."); + } + } + + diagnosticRows.Sort(CompareDiagnosticRows); + gaps.Sort(StringComparer.Ordinal); + + return new CoverageModel( + diagnosticRows, + categoryCounts.ToList(), + modeCounts.ToList(), + inputKindCounts.ToList(), + gaps); + } + + /// Renders the coverage model as deterministic Markdown. + public static string ToMarkdown(CoverageModel model) + { + var sb = new StringBuilder(); + sb.AppendLine("# Validation Sample Coverage"); + sb.AppendLine(); + + sb.AppendLine("## By Diagnostic Code"); + sb.AppendLine(); + sb.AppendLine("| Code | Scenario | Mode | Severity |"); + sb.AppendLine("| --- | --- | --- | --- |"); + foreach (var row in model.DiagnosticRows) + { + sb.AppendLine(FormattableString.Invariant($"| {row.Code} | {row.Scenario} | {row.Mode} | {row.Severity} |")); + } + sb.AppendLine(); + + AppendCountSection(sb, "By Category", "Category", model.CategoryCounts); + AppendCountSection(sb, "By Mode", "Mode", model.ModeCounts); + AppendCountSection(sb, "By Input Kind", "Input Kind", model.InputKindCounts); + + sb.AppendLine("## Corpus Gaps"); + sb.AppendLine(); + if (model.Gaps.Count == 0) + { + sb.AppendLine("None."); + } + else + { + sb.AppendLine("| Gap |"); + sb.AppendLine("| --- |"); + foreach (var gap in model.Gaps) + { + sb.AppendLine(FormattableString.Invariant($"| {gap} |")); + } + } + sb.AppendLine(); + + return sb.ToString(); + } + + private static void AppendCountSection( + StringBuilder sb, string title, string header, IReadOnlyList> counts) + { + sb.AppendLine(FormattableString.Invariant($"## {title}")); + sb.AppendLine(); + sb.AppendLine(FormattableString.Invariant($"| {header} | Count |")); + sb.AppendLine("| --- | --- |"); + foreach (var pair in counts) + { + sb.AppendLine(FormattableString.Invariant($"| {pair.Key} | {pair.Value} |")); + } + sb.AppendLine(); + } + + private static IReadOnlyList ReadBaselineDiagnostics( + string expectedResource, string scenarioName, string mode) + { + using var document = JsonDocument.Parse(ValidationSampleData.ReadResource(expectedResource)); + var rows = new List(); + + if (document.RootElement.TryGetProperty("diagnostics", out var diagnostics) + && diagnostics.ValueKind == JsonValueKind.Array) + { + foreach (var diagnostic in diagnostics.EnumerateArray()) + { + var code = diagnostic.TryGetProperty("code", out var codeElement) + ? codeElement.GetString() ?? "(none)" + : "(none)"; + var severity = diagnostic.TryGetProperty("severity", out var severityElement) + ? severityElement.GetString() ?? "(none)" + : "(none)"; + + rows.Add(new DiagnosticCoverageRow(code, scenarioName, mode, severity)); + } + } + + return rows; + } + + private static int CompareDiagnosticRows(DiagnosticCoverageRow x, DiagnosticCoverageRow y) + { + var byCode = string.CompareOrdinal(x.Code, y.Code); + if (byCode != 0) { return byCode; } + + var byScenario = string.CompareOrdinal(x.Scenario, y.Scenario); + if (byScenario != 0) { return byScenario; } + + var byMode = string.CompareOrdinal(x.Mode, y.Mode); + if (byMode != 0) { return byMode; } + + return string.CompareOrdinal(x.Severity, y.Severity); + } + + private static void Increment(IDictionary counts, string key) + { + counts[key] = counts.TryGetValue(key, out var current) ? current + 1 : 1; + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleData.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleData.cs new file mode 100644 index 00000000..36e7b658 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleData.cs @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using Azure.Bicep.Types.Validation; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +/// +/// Discovers validation sample scenarios from embedded resources and provides IO helpers +/// for the sample baseline harness. +/// +public static class ValidationSampleData +{ + private const string SampleRoot = "Files/validation-samples/"; + private const string ScenarioFileSuffix = "/scenario.json"; + private const string ExpectedResultSuffix = ".result.json"; + + /// Embedded-resource prefix (with trailing slash) under which all samples live. + public const string SampleRootResourcePrefix = SampleRoot; + + private static Assembly SampleAssembly => typeof(ValidationSampleData).Assembly; + + /// Enumerates every discovered scenario, ordered deterministically. + public static IEnumerable EnumerateScenarios() + { + var scenarioResources = SampleAssembly.GetManifestResourceNames() + .Where(n => n.StartsWith(SampleRoot, StringComparison.Ordinal) + && n.EndsWith(ScenarioFileSuffix, StringComparison.Ordinal)) + .OrderBy(n => n, StringComparer.Ordinal); + + foreach (var resourceName in scenarioResources) + { + var prefix = resourceName.Substring(0, resourceName.Length - ScenarioFileSuffix.Length); + var folderName = prefix.Substring(prefix.LastIndexOf('/') + 1); + + yield return ParseScenario(prefix, folderName, ReadResource(resourceName)); + } + } + + /// + /// Parses a single scenario.json document into a . + /// When no explicit inputs are declared, the scenario defaults to a single + /// directory input pointing at package/. + /// + public static ValidationSampleScenario ParseScenario(string resourcePrefix, string folderName, string scenarioJson) + { + using var document = JsonDocument.Parse(scenarioJson); + var root = document.RootElement; + + var name = root.TryGetProperty("name", out var nameElement) + ? nameElement.GetString() ?? folderName + : folderName; + var description = root.TryGetProperty("description", out var descriptionElement) + ? descriptionElement.GetString() + : null; + var category = root.TryGetProperty("category", out var categoryElement) + ? categoryElement.GetString() + : null; + + var inputs = ParseInputs(folderName, root); + + var modes = new List(); + if (root.TryGetProperty("modes", out var modesElement) + && modesElement.ValueKind == JsonValueKind.Array) + { + foreach (var modeElement in modesElement.EnumerateArray()) + { + var mode = modeElement.GetString(); + if (!string.IsNullOrEmpty(mode)) + { + modes.Add(mode!); + } + } + } + + return new ValidationSampleScenario( + resourcePrefix, folderName, name, description, category, inputs, modes, + ParseValidateUnreachableFiles(root)); + } + + private static bool ParseValidateUnreachableFiles(JsonElement root) + { + return root.TryGetProperty("options", out var optionsElement) + && optionsElement.ValueKind == JsonValueKind.Object + && optionsElement.TryGetProperty("validateUnreachableFiles", out var flagElement) + && flagElement.ValueKind == JsonValueKind.True; + } + + private static IReadOnlyList ParseInputs(string folderName, JsonElement root) + { + var inputs = new List(); + + if (root.TryGetProperty("inputs", out var inputsElement) + && inputsElement.ValueKind == JsonValueKind.Array) + { + foreach (var inputElement in inputsElement.EnumerateArray()) + { + var kind = inputElement.TryGetProperty("kind", out var kindElement) + ? kindElement.GetString() + : null; + var path = inputElement.TryGetProperty("path", out var pathElement) + ? pathElement.GetString() + : null; + + if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(path)) + { + throw new InvalidOperationException( + $"Scenario '{folderName}' declares an input missing 'kind' or 'path'."); + } + + inputs.Add(new ValidationSampleInput(ParseInputKind(folderName, kind!), path!)); + } + } + + if (inputs.Count == 0) + { + inputs.Add(ValidationSampleInput.DefaultDirectory); + } + + return inputs; + } + + private static ValidationSampleInputKind ParseInputKind(string folderName, string kind) => kind switch + { + "directory" => ValidationSampleInputKind.Directory, + "indexFile" => ValidationSampleInputKind.IndexFile, + "archiveFile" => ValidationSampleInputKind.ArchiveFile, + _ => throw new InvalidOperationException( + $"Scenario '{folderName}' declares an unsupported input kind '{kind}'."), + }; + + /// + /// Builds a for a sample input, resolving its + /// declared path relative to the materialized scenario . + /// + public static TypePackageValidationInput CreateValidationInput( + ValidationSampleInputKind kind, + string inputPath, + string materializedRoot) + { + var resolvedPath = Path.Combine( + materializedRoot, + inputPath.Replace('/', Path.DirectorySeparatorChar)); + + return kind switch + { + ValidationSampleInputKind.Directory => TypePackageValidationInput.ForDirectory(resolvedPath), + ValidationSampleInputKind.IndexFile => TypePackageValidationInput.ForIndexFile(resolvedPath), + ValidationSampleInputKind.ArchiveFile => TypePackageValidationInput.ForArchiveFile(resolvedPath), + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported sample input kind."), + }; + } + + /// Parses a sample mode string into a . + public static TypePackageValidationMode ParseMode(string mode) => mode switch + { + "canonicalWriter" => TypePackageValidationMode.CanonicalWriter, + "compatibleReader" => TypePackageValidationMode.CompatibleReader, + _ => throw new InvalidOperationException($"Unknown sample mode '{mode}'."), + }; + + /// + /// Runs a single sample case through the full pipeline (materialize package, build the input, + /// validate, normalize) and returns the normalized baseline JSON. This is the shared path used + /// by both baseline comparison and baseline update so they can never diverge. + /// + public static string RunScenarioNormalized( + string resourcePrefix, + ValidationSampleInputKind inputKind, + string inputPath, + TypePackageValidationMode mode, + bool validateUnreachableFiles) + { + var temporaryRoot = Path.Combine( + Path.GetTempPath(), + "bicep-types-validation-samples", + Guid.NewGuid().ToString("N")); + + try + { + var packageRoot = MaterializePackage( + resourcePrefix, Path.Combine(temporaryRoot, "package")); + + if (inputKind == ValidationSampleInputKind.ArchiveFile) + { + var archivePath = Path.Combine( + temporaryRoot, inputPath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(archivePath)!); + MaterializeArchive(packageRoot, archivePath); + } + + var input = CreateValidationInput(inputKind, inputPath, temporaryRoot); + var options = new TypePackageValidationOptions + { + Mode = mode, + ValidateUnreachableFiles = validateUnreachableFiles, + }; + + var result = new TypePackageValidator().Validate(input, options); + return ValidationSampleResultNormalizer.Normalize(result, temporaryRoot); + } + finally + { + if (Directory.Exists(temporaryRoot)) + { + Directory.Delete(temporaryRoot, recursive: true); + } + } + } + + /// DynamicData source: one case per (scenario, input, mode). + public static IEnumerable GetSampleCases() + { + foreach (var scenario in EnumerateScenarios()) + { + foreach (var input in scenario.Inputs) + { + foreach (var mode in scenario.Modes) + { + yield return new object[] + { + scenario.ResourcePrefix, + scenario.Name, + input.Kind.ToString(), + input.Path, + mode, + scenario.ValidateUnreachableFiles, + }; + } + } + } + } + + /// DynamicData display name formatter. + public static string GetSampleCaseDisplayName(MethodInfo methodInfo, object[] data) + => $"{data[1]} [{data[2]} -> {data[4]}]"; + + /// Reads an embedded resource as text. + public static string ReadResource(string resourceName) + { + using var stream = SampleAssembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Missing embedded resource '{resourceName}'."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// Whether an embedded resource with the given name exists. + public static bool ResourceExists(string resourceName) + => SampleAssembly.GetManifestResourceNames().Contains(resourceName, StringComparer.Ordinal); + + /// Builds the expected-result resource name for a scenario and mode. + public static string GetExpectedResultResourceName(string resourcePrefix, string mode) + => $"{resourcePrefix}/expected/{mode}{ExpectedResultSuffix}"; + + /// Enumerates the package resources for a scenario. + public static IReadOnlyList EnumeratePackageResources(string resourcePrefix) + { + var packagePrefix = $"{resourcePrefix}/package/"; + return SampleAssembly.GetManifestResourceNames() + .Where(n => n.StartsWith(packagePrefix, StringComparison.Ordinal)) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + } + + /// Enumerates the modes for which an expected-result file exists on disk. + public static IEnumerable EnumerateExpectedModeResources(string resourcePrefix) + { + var expectedPrefix = $"{resourcePrefix}/expected/"; + foreach (var resourceName in SampleAssembly.GetManifestResourceNames() + .Where(n => n.StartsWith(expectedPrefix, StringComparison.Ordinal) + && n.EndsWith(ExpectedResultSuffix, StringComparison.Ordinal)) + .OrderBy(n => n, StringComparer.Ordinal)) + { + var fileName = resourceName.Substring(expectedPrefix.Length); + yield return fileName.Substring(0, fileName.Length - ExpectedResultSuffix.Length); + } + } + + /// + /// Materializes a scenario's package/ resources under + /// and returns the package root path. + /// + public static string MaterializePackage(string resourcePrefix, string packageRoot) + { + // Always create the package directory so scenarios with an intentionally empty + // package dir (e.g. missing-index-file) still receive an existing directory. + Directory.CreateDirectory(packageRoot); + + var packagePrefix = $"{resourcePrefix}/package/"; + foreach (var resourceName in EnumeratePackageResources(resourcePrefix)) + { + var relativePath = resourceName.Substring(packagePrefix.Length); + var destinationPath = Path.Combine( + packageRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar)); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + using var stream = SampleAssembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Missing embedded resource '{resourceName}'."); + using var file = File.Create(destinationPath); + stream.CopyTo(file); + } + + return packageRoot; + } + + /// + /// Builds a gzip-compressed tar archive at from every file + /// under . Member names are package-relative and use / + /// separators so archive inputs exercise the same package layout as directory inputs. + /// + public static void MaterializeArchive(string packageRoot, string archivePath) + { + var entries = new List(); + foreach (var filePath in Directory.EnumerateFiles(packageRoot, "*", SearchOption.AllDirectories) + .OrderBy(p => p, StringComparer.Ordinal)) + { + var relativePath = filePath.Substring(packageRoot.Length) + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Replace(Path.DirectorySeparatorChar, '/'); + entries.Add(Packaging.TarGzTestEntry.File(relativePath, File.ReadAllText(filePath))); + } + + File.WriteAllBytes(archivePath, Packaging.TarGzTestArchive.Build(entries)); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleDataTests.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleDataTests.cs new file mode 100644 index 00000000..991b18ff --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleDataTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +[TestClass] +public class ValidationSampleDataTests +{ + [TestMethod] + public void Scenario_without_inputs_defaults_to_directory_package() + { + var scenario = ValidationSampleData.ParseScenario( + "prefix/sample", + "sample", + /*lang=json,strict*/ """{ "name": "sample", "modes": ["canonicalWriter"] }"""); + + scenario.Inputs.Should().ContainSingle(); + scenario.Inputs[0].Kind.Should().Be(ValidationSampleInputKind.Directory); + scenario.Inputs[0].Path.Should().Be("package"); + } + + [TestMethod] + public void Explicit_input_override_is_honored() + { + var json = /*lang=json,strict*/ """ + { + "name": "sample", + "modes": ["canonicalWriter"], + "inputs": [ { "kind": "indexFile", "path": "package/index.json" } ] + } + """; + + var scenario = ValidationSampleData.ParseScenario("prefix/sample", "sample", json); + + scenario.Inputs.Should().ContainSingle(); + scenario.Inputs[0].Kind.Should().Be(ValidationSampleInputKind.IndexFile); + scenario.Inputs[0].Path.Should().Be("package/index.json"); + } + + [TestMethod] + public void Multiple_explicit_inputs_are_all_parsed_in_order() + { + var json = /*lang=json,strict*/ """ + { + "name": "sample", + "modes": ["canonicalWriter"], + "inputs": [ + { "kind": "directory", "path": "package" }, + { "kind": "archiveFile", "path": "types.tgz" } + ] + } + """; + + var scenario = ValidationSampleData.ParseScenario("prefix/sample", "sample", json); + + scenario.Inputs.Select(i => i.Kind).Should().Equal( + ValidationSampleInputKind.Directory, + ValidationSampleInputKind.ArchiveFile); + scenario.Inputs.Select(i => i.Path).Should().Equal("package", "types.tgz"); + } + + [TestMethod] + public void Unsupported_input_kind_throws() + { + var json = /*lang=json,strict*/ """ + { "name": "sample", "modes": ["canonicalWriter"], "inputs": [ { "kind": "weird", "path": "p" } ] } + """; + + Action act = () => ValidationSampleData.ParseScenario("prefix/sample", "sample", json); + + act.Should().Throw(); + } + + [TestMethod] + public void Input_missing_kind_or_path_throws() + { + var json = /*lang=json,strict*/ """ + { "name": "sample", "modes": ["canonicalWriter"], "inputs": [ { "kind": "directory" } ] } + """; + + Action act = () => ValidationSampleData.ParseScenario("prefix/sample", "sample", json); + + act.Should().Throw(); + } + + [TestMethod] + public void Create_validation_input_maps_directory_kind_and_resolves_path() + { + var root = Path.Combine(Path.GetTempPath(), "bicep-types-validation-input-test"); + + var input = ValidationSampleData.CreateValidationInput( + ValidationSampleInputKind.Directory, + "package", + root); + + input.DisplayPath.Should().Be(Path.Combine(root, "package")); + } + + [TestMethod] + public void Create_validation_input_maps_index_file_kind_and_resolves_nested_path() + { + var root = Path.Combine(Path.GetTempPath(), "bicep-types-validation-input-test"); + + var input = ValidationSampleData.CreateValidationInput( + ValidationSampleInputKind.IndexFile, + "package/index.json", + root); + + input.DisplayPath.Should().Be(Path.Combine(root, "package", "index.json")); + } + + [TestMethod] + public void Sample_cases_expand_per_declared_input_and_mode() + { + // Every emitted case must map back to a real scenario input and declared mode. + var scenarios = ValidationSampleData.EnumerateScenarios().ToList(); + var expectedCaseCount = scenarios.Sum(s => s.Inputs.Count * s.Modes.Count); + + var cases = ValidationSampleData.GetSampleCases().ToList(); + + cases.Should().HaveCount(expectedCaseCount); + cases.Should().OnlyContain(c => c.Length == 6); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleHealthTests.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleHealthTests.cs new file mode 100644 index 00000000..8e6824ed --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleHealthTests.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +[TestClass] +public class ValidationSampleHealthTests +{ + [TestMethod] + public void Every_scenario_folder_name_matches_scenario_name() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + scenario.FolderName.Should().Be( + scenario.Name, + $"scenario folder '{scenario.ResourcePrefix}' should match its declared name."); + } + } + + [TestMethod] + public void Every_declared_mode_has_an_expected_result_file() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + scenario.Modes.Should().NotBeEmpty($"scenario '{scenario.Name}' must declare at least one mode."); + + foreach (var mode in scenario.Modes) + { + var expected = ValidationSampleData.GetExpectedResultResourceName(scenario.ResourcePrefix, mode); + ValidationSampleData.ResourceExists(expected).Should().BeTrue( + $"scenario '{scenario.Name}' declares mode '{mode}' but is missing '{expected}'."); + } + } + } + + [TestMethod] + public void No_expected_result_file_exists_for_an_undeclared_mode() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + foreach (var mode in ValidationSampleData.EnumerateExpectedModeResources(scenario.ResourcePrefix)) + { + scenario.Modes.Should().Contain( + mode, + $"scenario '{scenario.Name}' has an expected result for mode '{mode}' that it does not declare."); + } + } + } + + [TestMethod] + public void Every_scenario_has_at_least_one_input_or_a_default_package_folder() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + scenario.Inputs.Should().NotBeEmpty( + $"scenario '{scenario.Name}' should always resolve to at least one input (defaulting to 'package/')."); + + ValidationSampleData.EnumeratePackageResources(scenario.ResourcePrefix).Should().NotBeEmpty( + $"scenario '{scenario.Name}' should provide a default 'package/' folder."); + } + } + + [TestMethod] + public void Every_scenario_json_and_expected_result_json_parses() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + var scenarioResource = $"{scenario.ResourcePrefix}/scenario.json"; + AssertParses(scenarioResource, $"scenario.json for '{scenario.Name}' should be valid JSON."); + + foreach (var mode in scenario.Modes) + { + var expected = ValidationSampleData.GetExpectedResultResourceName(scenario.ResourcePrefix, mode); + AssertParses(expected, $"expected result for '{scenario.Name}' mode '{mode}' should be valid JSON."); + } + } + } + + private static void AssertParses(string resourceName, string because) + { + Action parse = () => + { + using var document = JsonDocument.Parse(ValidationSampleData.ReadResource(resourceName)); + }; + + parse.Should().NotThrow(because); + } + + [TestMethod] + public void SampleData_discovers_archive_input_scenarios() + { + var scenarios = System.Linq.Enumerable.ToList(ValidationSampleData.EnumerateScenarios()); + + scenarios.Should().Contain( + s => System.Linq.Enumerable.Any(s.Inputs, i => i.Kind == ValidationSampleInputKind.ArchiveFile), + "phase-6 samples include at least one archiveFile input scenario."); + } + + [TestMethod] + public void SampleData_materializes_archive_resources() + { + var scenario = System.Linq.Enumerable.First( + ValidationSampleData.EnumerateScenarios(), + s => System.Linq.Enumerable.Any(s.Inputs, i => i.Kind == ValidationSampleInputKind.ArchiveFile)); + + var temporaryRoot = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bicep-types-validation-samples", Guid.NewGuid().ToString("N")); + + try + { + var packageRoot = ValidationSampleData.MaterializePackage( + scenario.ResourcePrefix, System.IO.Path.Combine(temporaryRoot, "package")); + var archivePath = System.IO.Path.Combine(temporaryRoot, "package.tgz"); + ValidationSampleData.MaterializeArchive(packageRoot, archivePath); + + System.IO.File.Exists(archivePath).Should().BeTrue(); + new System.IO.FileInfo(archivePath).Length.Should().BeGreaterThan(0); + + var result = new TypePackageValidator().Validate( + TypePackageValidationInput.ForArchiveFile(archivePath)); + result.Diagnostics.Should().NotContain( + d => d.Code == Azure.Bicep.Types.Validation.Diagnostics.TypeValidationDiagnosticCodes.ArchivePackageInvalid); + } + finally + { + if (System.IO.Directory.Exists(temporaryRoot)) + { + System.IO.Directory.Delete(temporaryRoot, recursive: true); + } + } + } + + // ── Phase-8 corpus health checks ───────────────────────────────────────── + + private static readonly HashSet KnownModes = + new(new[] { "canonicalWriter", "compatibleReader" }, StringComparer.Ordinal); + + [TestMethod] + public void Every_scenario_has_non_empty_description_and_category() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + scenario.Description.Should().NotBeNullOrWhiteSpace( + $"scenario '{scenario.Name}' must have a non-empty description."); + scenario.Category.Should().NotBeNullOrWhiteSpace( + $"scenario '{scenario.Name}' must have a non-empty category."); + } + } + + [TestMethod] + public void Every_scenario_category_is_known() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + ValidationSampleCoverageReport.KnownCategories.Should().Contain( + scenario.Category!, + $"scenario '{scenario.Name}' uses category '{scenario.Category}', which is not in the known set."); + } + } + + [TestMethod] + public void Every_scenario_mode_is_known() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + foreach (var mode in scenario.Modes) + { + KnownModes.Should().Contain( + mode, + $"scenario '{scenario.Name}' declares unknown mode '{mode}'."); + } + } + } + + [TestMethod] + public void Scenario_names_are_unique() + { + var duplicates = ValidationSampleData.EnumerateScenarios() + .GroupBy(s => s.Name, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + duplicates.Should().BeEmpty( + $"scenario names must be unique; duplicates: {string.Join(", ", duplicates)}."); + } + + [TestMethod] + public void Every_baseline_declares_the_matching_mode() + { + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + foreach (var mode in scenario.Modes) + { + var expected = ValidationSampleData.GetExpectedResultResourceName(scenario.ResourcePrefix, mode); + using var document = JsonDocument.Parse(ValidationSampleData.ReadResource(expected)); + + document.RootElement.TryGetProperty("mode", out var modeElement).Should().BeTrue( + $"baseline '{expected}' must declare a 'mode' property."); + modeElement.GetString().Should().Be( + mode, + $"baseline '{expected}' must declare mode '{mode}' matching its file name."); + } + } + } + + [TestMethod] + public void Every_baseline_satisfies_internal_invariants() + { + // These invariants assume default validation options (warnings included, no truncation), + // which every current scenario uses. If a future scenario opts into warning filtering or + // MaxDiagnostics, the summary would legitimately diverge from the returned diagnostics and + // this check would need to account for that. + foreach (var scenario in ValidationSampleData.EnumerateScenarios()) + { + foreach (var mode in scenario.Modes) + { + var expected = ValidationSampleData.GetExpectedResultResourceName(scenario.ResourcePrefix, mode); + using var document = JsonDocument.Parse(ValidationSampleData.ReadResource(expected)); + var root = document.RootElement; + + AssertProperty(root, "isValid", JsonValueKind.True, JsonValueKind.False, expected); + AssertProperty(root, "mode", JsonValueKind.String, JsonValueKind.String, expected); + AssertProperty(root, "diagnostics", JsonValueKind.Array, JsonValueKind.Array, expected); + AssertProperty(root, "diagnosticsTruncated", JsonValueKind.True, JsonValueKind.False, expected); + AssertProperty(root, "summary", JsonValueKind.Object, JsonValueKind.Object, expected); + + var summary = root.GetProperty("summary"); + var errorCount = summary.GetProperty("errorCount").GetInt32(); + var warningCount = summary.GetProperty("warningCount").GetInt32(); + var infoCount = summary.GetProperty("infoCount").GetInt32(); + + var isValid = root.GetProperty("isValid").GetBoolean(); + isValid.Should().Be( + errorCount == 0, + $"baseline '{expected}' must have isValid == (errorCount == 0)."); + + var severities = root.GetProperty("diagnostics").EnumerateArray() + .Select(d => d.GetProperty("severity").GetString()) + .ToList(); + + severities.Count(s => s == "error").Should().Be( + errorCount, $"baseline '{expected}' error count must match its diagnostics."); + severities.Count(s => s == "warning").Should().Be( + warningCount, $"baseline '{expected}' warning count must match its diagnostics."); + severities.Count(s => s == "info").Should().Be( + infoCount, $"baseline '{expected}' info count must match its diagnostics."); + } + } + } + + private static void AssertProperty( + JsonElement root, string name, JsonValueKind allowed1, JsonValueKind allowed2, string resource) + { + root.TryGetProperty(name, out var element).Should().BeTrue( + $"baseline '{resource}' must declare a '{name}' property."); + (element.ValueKind == allowed1 || element.ValueKind == allowed2).Should().BeTrue( + $"baseline '{resource}' property '{name}' has unexpected JSON kind '{element.ValueKind}'."); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleInput.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleInput.cs new file mode 100644 index 00000000..4fbc0b8c --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleInput.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +/// The input form declared by a sample scenario entry in scenario.json#/inputs. +public enum ValidationSampleInputKind +{ + /// An extracted package directory. + Directory, + + /// A raw index.json file. + IndexFile, + + /// A types.tgz archive file. + ArchiveFile, +} + +/// +/// In-memory representation of a single entry from scenario.json#/inputs. +/// +public sealed class ValidationSampleInput +{ + /// The default input used when a scenario declares no explicit inputs. + public static readonly ValidationSampleInput DefaultDirectory = + new(ValidationSampleInputKind.Directory, "package"); + + public ValidationSampleInput(ValidationSampleInputKind kind, string path) + { + Kind = kind; + Path = path; + } + + /// The declared input form. + public ValidationSampleInputKind Kind { get; } + + /// The input path, relative to the scenario's materialized root. + public string Path { get; } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleResultNormalizer.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleResultNormalizer.cs new file mode 100644 index 00000000..06ad4226 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleResultNormalizer.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Azure.Bicep.Types.Validation.Diagnostics; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +/// +/// Converts a into the stable JSON baseline shape, +/// and canonicalizes expected baseline text through the same serializer for comparison. +/// +public static class ValidationSampleResultNormalizer +{ + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true }; + + /// Serializes a result into the deterministic baseline JSON shape. + /// The validation result to normalize. + /// + /// When provided, occurrences of this path (or its forward-slash equivalent) in diagnostic + /// messages are replaced with the stable placeholder <sample-root> so that + /// baselines are reproducible across machines and temp-directory locations. + /// + public static string Normalize(TypePackageValidationResult result, string? temporaryRoot = null) + { + var root = new JsonObject + { + ["isValid"] = result.IsValid, + ["mode"] = ModeToString(result.Mode), + ["diagnostics"] = BuildDiagnostics(result.Diagnostics, temporaryRoot), + ["diagnosticsTruncated"] = result.DiagnosticsTruncated, + ["summary"] = new JsonObject + { + ["errorCount"] = result.Summary.ErrorCount, + ["warningCount"] = result.Summary.WarningCount, + ["infoCount"] = result.Summary.InfoCount, + }, + }; + + return root.ToJsonString(SerializerOptions); + } + + /// Re-serializes expected baseline text through the same serializer to normalize formatting. + public static string Canonicalize(string json) + { + var node = JsonNode.Parse(json) + ?? throw new InvalidOperationException("Expected baseline JSON parsed to null."); + return node.ToJsonString(SerializerOptions); + } + + private static JsonArray BuildDiagnostics(IReadOnlyList diagnostics, string? temporaryRoot) + { + var array = new JsonArray(); + foreach (var diagnostic in diagnostics) + { + var obj = new JsonObject + { + ["code"] = diagnostic.Code, + ["severity"] = SeverityToString(diagnostic.Severity), + ["message"] = RedactPath(diagnostic.Message, temporaryRoot), + }; + + if (!string.IsNullOrEmpty(diagnostic.Path)) + { + obj["path"] = diagnostic.Path; + } + + if (!string.IsNullOrEmpty(diagnostic.JsonPointer)) + { + obj["jsonPointer"] = diagnostic.JsonPointer; + } + + if (diagnostic.Line.HasValue) + { + obj["line"] = diagnostic.Line.Value; + } + + if (diagnostic.Column.HasValue) + { + obj["column"] = diagnostic.Column.Value; + } + + if (diagnostic.RelatedLocations.Count > 0) + { + obj["relatedLocations"] = BuildRelatedLocations(diagnostic.RelatedLocations); + } + + array.Add(obj); + } + + return array; + } + + private static JsonArray BuildRelatedLocations(IReadOnlyList related) + { + var array = new JsonArray(); + foreach (var location in related) + { + var obj = new JsonObject + { + ["message"] = location.Message, + }; + + if (!string.IsNullOrEmpty(location.Path)) + { + obj["path"] = location.Path; + } + + if (!string.IsNullOrEmpty(location.JsonPointer)) + { + obj["jsonPointer"] = location.JsonPointer; + } + + if (location.Line.HasValue) + { + obj["line"] = location.Line.Value; + } + + if (location.Column.HasValue) + { + obj["column"] = location.Column.Value; + } + + array.Add(obj); + } + + return array; + } + + private static string ModeToString(TypePackageValidationMode mode) => mode switch + { + TypePackageValidationMode.CanonicalWriter => "canonicalWriter", + TypePackageValidationMode.CompatibleReader => "compatibleReader", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown validation mode."), + }; + + private static string SeverityToString(TypeValidationDiagnosticSeverity severity) => severity switch + { + TypeValidationDiagnosticSeverity.Error => "error", + TypeValidationDiagnosticSeverity.Warning => "warning", + TypeValidationDiagnosticSeverity.Info => "info", + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, "Unknown severity."), + }; + + private static string RedactPath(string message, string? temporaryRoot) + { + if (string.IsNullOrEmpty(temporaryRoot) || string.IsNullOrEmpty(message)) + { + return message; + } + + // Replace both backslash and forward-slash variants of the temp root + var withForwardSlash = temporaryRoot.Replace('\\', '/'); + var result = message + .Replace(temporaryRoot, "", StringComparison.Ordinal) + .Replace(withForwardSlash, "", StringComparison.Ordinal); + + // Normalize any remaining backslash path separators that follow the placeholder + // so baselines are cross-platform stable. + return result.Replace("\\", "/", StringComparison.Ordinal); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleScenario.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleScenario.cs new file mode 100644 index 00000000..d3c99f39 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleScenario.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +/// +/// In-memory representation of a single scenario.json validation sample. +/// +public sealed class ValidationSampleScenario +{ + public ValidationSampleScenario( + string resourcePrefix, + string folderName, + string name, + string? description, + string? category, + IReadOnlyList inputs, + IReadOnlyList modes, + bool validateUnreachableFiles = false) + { + ResourcePrefix = resourcePrefix; + FolderName = folderName; + Name = name; + Description = description; + Category = category; + Inputs = inputs; + Modes = modes; + ValidateUnreachableFiles = validateUnreachableFiles; + } + + /// Embedded-resource prefix up to (but excluding) /scenario.json. + public string ResourcePrefix { get; } + + /// The scenario's containing folder name. + public string FolderName { get; } + + /// Declared scenario name. + public string Name { get; } + + /// Optional description. + public string? Description { get; } + + /// Optional category label. + public string? Category { get; } + + /// + /// Declared input forms. Always contains at least one entry: when the scenario declares + /// no explicit inputs, this defaults to a single . + /// + public IReadOnlyList Inputs { get; } + + /// Declared validation modes. + public IReadOnlyList Modes { get; } + + /// + /// Whether the scenario opts into strict package hygiene validation via + /// . + /// + public bool ValidateUnreachableFiles { get; } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleTests.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleTests.cs new file mode 100644 index 00000000..067ce132 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +[TestClass] +public class ValidationSampleTests +{ + [TestMethod] + public void SampleData_discovers_valid_canonical_scenarios() + { + var scenarios = System.Linq.Enumerable.ToList(ValidationSampleData.EnumerateScenarios()); + + scenarios.Should().NotBeEmpty(); + scenarios.Should().Contain(s => s.Name == "minimal-resource"); + } + + [TestMethod] + [DynamicData( + nameof(ValidationSampleData.GetSampleCases), + typeof(ValidationSampleData), + DynamicDataSourceType.Method, + DynamicDataDisplayName = nameof(ValidationSampleData.GetSampleCaseDisplayName), + DynamicDataDisplayNameDeclaringType = typeof(ValidationSampleData))] + public void Sample_matches_expected_baseline(string resourcePrefix, string name, string inputKind, string inputPath, string mode, bool validateUnreachableFiles) + { + var expectedResourceName = ValidationSampleData.GetExpectedResultResourceName(resourcePrefix, mode); + ValidationSampleData.ResourceExists(expectedResourceName).Should().BeTrue( + $"scenario '{name}' declares mode '{mode}' and must have an expected result file at '{expectedResourceName}'."); + + var actual = ValidationSampleData.RunScenarioNormalized( + resourcePrefix, + ParseInputKind(inputKind), + inputPath, + ValidationSampleData.ParseMode(mode), + validateUnreachableFiles); + + var expected = ValidationSampleResultNormalizer.Canonicalize( + ValidationSampleData.ReadResource(expectedResourceName)); + + actual.Should().Be( + expected, + $"normalized result for scenario '{name}' via '{inputKind}' in mode '{mode}' should match baseline '{expectedResourceName}'.{Environment.NewLine}Actual:{Environment.NewLine}{actual}"); + } + + private static ValidationSampleInputKind ParseInputKind(string kind) => kind switch + { + nameof(ValidationSampleInputKind.Directory) => ValidationSampleInputKind.Directory, + nameof(ValidationSampleInputKind.IndexFile) => ValidationSampleInputKind.IndexFile, + nameof(ValidationSampleInputKind.ArchiveFile) => ValidationSampleInputKind.ArchiveFile, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown sample input kind."), + }; +} diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleWorkflowTests.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleWorkflowTests.cs new file mode 100644 index 00000000..2eca7568 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleWorkflowTests.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Samples; + +[TestClass] +public class ValidationSampleBaselineUpdaterTests +{ + [TestMethod] + public void IsUpdateRequested_is_false_without_flag_or_env_var() + { + // The env var is not expected to be set during normal test runs. + Environment.GetEnvironmentVariable(ValidationSampleBaselineUpdater.SetBaselineEnvVar) + .Should().BeNullOrEmpty("normal test runs must not enable baseline updates"); + ValidationSampleBaselineUpdater.IsUpdateRequested(null).Should().BeFalse(); + ValidationSampleBaselineUpdater.IsUpdateRequested("false").Should().BeFalse(); + } + + [TestMethod] + public void IsUpdateRequested_is_true_with_run_parameter() + => ValidationSampleBaselineUpdater.IsUpdateRequested("true").Should().BeTrue(); + + [TestMethod] + public void ComputeWriteTarget_stays_under_samples_root() + { + using var root = new TempDir(); + + var target = ValidationSampleBaselineUpdater.ComputeWriteTarget( + root.Path, "Files/validation-samples/invalid/graph/x", "canonicalWriter"); + + target.Should().StartWith(Path.GetFullPath(root.Path)); + target.Replace('\\', '/').Should().EndWith("invalid/graph/x/expected/canonicalWriter.result.json"); + } + + [TestMethod] + public void ComputeWriteTarget_rejects_paths_that_escape_the_root() + { + using var root = new TempDir(); + + Action act = () => ValidationSampleBaselineUpdater.ComputeWriteTarget( + root.Path, "Files/validation-samples/../../evil", "canonicalWriter"); + + act.Should().Throw(); + } + + [TestMethod] + public void WriteBaselineIfChanged_creates_missing_file() + { + using var root = new TempDir(); + var path = Path.Combine(root.Path, "expected", "canonicalWriter.result.json"); + var content = ValidationSampleResultNormalizer.Canonicalize("{\"isValid\":true}"); + + var written = ValidationSampleBaselineUpdater.WriteBaselineIfChanged(path, content); + + written.Should().BeTrue(); + File.Exists(path).Should().BeTrue(); + } + + [TestMethod] + public void WriteBaselineIfChanged_does_not_rewrite_unchanged_content() + { + using var root = new TempDir(); + var path = Path.Combine(root.Path, "b.result.json"); + var content = ValidationSampleResultNormalizer.Canonicalize("{\"isValid\":true}"); + + ValidationSampleBaselineUpdater.WriteBaselineIfChanged(path, content).Should().BeTrue(); + ValidationSampleBaselineUpdater.WriteBaselineIfChanged(path, content).Should().BeFalse(); + } + + [TestMethod] + public void WriteBaselineIfChanged_rewrites_changed_content() + { + using var root = new TempDir(); + var path = Path.Combine(root.Path, "b.result.json"); + + ValidationSampleBaselineUpdater.WriteBaselineIfChanged( + path, ValidationSampleResultNormalizer.Canonicalize("{\"isValid\":true}")).Should().BeTrue(); + ValidationSampleBaselineUpdater.WriteBaselineIfChanged( + path, ValidationSampleResultNormalizer.Canonicalize("{\"isValid\":false}")).Should().BeTrue(); + } + + [TestMethod] + public void TryReconcileInputs_agrees_when_all_identical() + { + var results = new List { "same", "same", "same" }; + + ValidationSampleBaselineUpdater.TryReconcileInputs(results, out var agreed, out var index) + .Should().BeTrue(); + agreed.Should().Be("same"); + index.Should().Be(-1); + } + + [TestMethod] + public void TryReconcileInputs_detects_first_divergent_input() + { + var results = new List { "a", "a", "b" }; + + ValidationSampleBaselineUpdater.TryReconcileInputs(results, out _, out var index) + .Should().BeFalse(); + index.Should().Be(2); + } + + [TestMethod] + public void TryReconcileInputs_throws_on_empty() + { + Action act = () => ValidationSampleBaselineUpdater.TryReconcileInputs( + new List(), out _, out _); + + act.Should().Throw(); + } + + [TestMethod] + public void UpdateGroup_writes_exactly_once_when_all_inputs_agree() + { + using var root = new TempDir(); + var inputs = new List + { + new(ValidationSampleInputKind.Directory, "package"), + new(ValidationSampleInputKind.ArchiveFile, "package.tgz"), + }; + var writeCount = 0; + + var outcome = ValidationSampleBaselineUpdater.UpdateGroup( + root.Path, + "Files/validation-samples/invalid/graph/x", + "x", + "canonicalWriter", + inputs, + _ => "IDENTICAL", + (_, _) => { writeCount++; return true; }); + + outcome.Kind.Should().Be(ValidationSampleBaselineUpdater.GroupUpdateKind.Written); + outcome.RelativeTarget.Should().Be("invalid/graph/x/expected/canonicalWriter.result.json"); + writeCount.Should().Be(1, "an agreeing multi-input group must write exactly one baseline"); + } + + [TestMethod] + public void UpdateGroup_reports_unchanged_when_writer_reports_no_change() + { + using var root = new TempDir(); + var inputs = new List { new(ValidationSampleInputKind.Directory, "package") }; + + var outcome = ValidationSampleBaselineUpdater.UpdateGroup( + root.Path, + "Files/validation-samples/invalid/graph/x", + "x", + "canonicalWriter", + inputs, + _ => "IDENTICAL", + (_, _) => false); + + outcome.Kind.Should().Be(ValidationSampleBaselineUpdater.GroupUpdateKind.Unchanged); + outcome.RelativeTarget.Should().Be("invalid/graph/x/expected/canonicalWriter.result.json"); + } + + [TestMethod] + public void UpdateGroup_does_not_write_and_reports_mismatch_when_inputs_differ() + { + using var root = new TempDir(); + var inputs = new List + { + new(ValidationSampleInputKind.Directory, "package"), + new(ValidationSampleInputKind.ArchiveFile, "package.tgz"), + }; + var writeCount = 0; + + var outcome = ValidationSampleBaselineUpdater.UpdateGroup( + root.Path, + "Files/validation-samples/invalid/graph/x", + "x", + "canonicalWriter", + inputs, + input => input.Path, // distinct content per input + (_, _) => { writeCount++; return true; }); + + outcome.Kind.Should().Be(ValidationSampleBaselineUpdater.GroupUpdateKind.Mismatch); + writeCount.Should().Be(0, "a divergent multi-input group must not write any baseline"); + // The mismatch message must identify both the first and divergent input paths. + outcome.MismatchMessage.Should().Contain("package.tgz"); + outcome.MismatchMessage.Should().Contain("first input 'Directory:package'"); + } + + [TestMethod] + public void UpdateGroup_writes_one_file_through_real_writer_and_is_idempotent() + { + using var root = new TempDir(); + var inputs = new List + { + new(ValidationSampleInputKind.Directory, "package"), + new(ValidationSampleInputKind.ArchiveFile, "package.tgz"), + }; + var content = ValidationSampleResultNormalizer.Canonicalize("{\"isValid\":true}"); + + var first = ValidationSampleBaselineUpdater.UpdateGroup( + root.Path, "Files/validation-samples/invalid/graph/x", "x", "canonicalWriter", + inputs, _ => content, ValidationSampleBaselineUpdater.WriteBaselineIfChanged); + + var writtenPath = Path.Combine(root.Path, "invalid", "graph", "x", "expected", "canonicalWriter.result.json"); + first.Kind.Should().Be(ValidationSampleBaselineUpdater.GroupUpdateKind.Written); + File.Exists(writtenPath).Should().BeTrue(); + + // Second run with identical content must be a no-op. + var second = ValidationSampleBaselineUpdater.UpdateGroup( + root.Path, "Files/validation-samples/invalid/graph/x", "x", "canonicalWriter", + inputs, _ => content, ValidationSampleBaselineUpdater.WriteBaselineIfChanged); + + second.Kind.Should().Be(ValidationSampleBaselineUpdater.GroupUpdateKind.Unchanged); + } + + [TestMethod] + public void ResolveSamplesRoot_prefers_explicit_existing_root() + { + using var root = new TempDir(); + + ValidationSampleBaselineUpdater.ResolveSamplesRoot(root.Path) + .Should().Be(Path.GetFullPath(root.Path)); + } + + [TestMethod] + public void ResolveSamplesRoot_throws_for_missing_explicit_root() + { + Action act = () => ValidationSampleBaselineUpdater.ResolveSamplesRoot( + Path.Combine(Path.GetTempPath(), "definitely-missing-" + Guid.NewGuid().ToString("N"))); + + act.Should().Throw(); + } + + [TestMethod] + public void ResolveSamplesRoot_locates_source_tree_by_walking_up() + { + var root = ValidationSampleBaselineUpdater.ResolveSamplesRoot(); + + Directory.Exists(root).Should().BeTrue(); + root.Replace('\\', '/').Should().EndWith("Files/validation-samples"); + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-baseline-test-" + System.IO.Path.GetRandomFileName()); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } +} + +[TestClass] +public class ValidationSampleCoverageReportTests +{ + [TestMethod] + public void Report_is_deterministic() + { + var first = ValidationSampleCoverageReport.ToMarkdown(ValidationSampleCoverageReport.Build()); + var second = ValidationSampleCoverageReport.ToMarkdown(ValidationSampleCoverageReport.Build()); + + second.Should().Be(first); + } + + [TestMethod] + public void Report_contains_expected_sections() + { + var markdown = ValidationSampleCoverageReport.ToMarkdown(ValidationSampleCoverageReport.Build()); + + markdown.Should().Contain("# Validation Sample Coverage"); + markdown.Should().Contain("## By Diagnostic Code"); + markdown.Should().Contain("## By Category"); + markdown.Should().Contain("## By Mode"); + markdown.Should().Contain("## By Input Kind"); + markdown.Should().Contain("## Corpus Gaps"); + } + + [TestMethod] + public void Report_covers_known_diagnostic_codes_from_baselines() + { + var model = ValidationSampleCoverageReport.Build(); + + model.DiagnosticRows.Should().NotBeEmpty(); + // Every known category should appear (no missing-category gap for the current corpus). + model.Gaps.Should().NotContain(g => g.Contains("has no scenarios")); + } + + [TestMethod] + public void Report_does_not_apply_category_based_validity_gaps() + { + var model = ValidationSampleCoverageReport.Build(); + + // The report must not infer expected diagnostics from a scenario's category. Gaps are limited + // to coverage-presence checks (a known category with no scenarios); per-scenario validity is + // owned by the baseline-internal invariants in the health tests. For the current corpus every + // known category is present, so there are no gaps at all. + model.Gaps.Should().NotContain(g => g.Contains("error diagnostic")); + foreach (var gap in model.Gaps) + { + gap.Should().Contain("has no scenarios", "the only coverage gaps are missing-category presence checks"); + } + + // Intentional compatibility scenarios (error in canonicalWriter, warning in compatibleReader) + // must never be reported as gaps. + foreach (var name in new[] + { + "object-property-flags-invalid", + "readable-scope-bits-invalid", + "visible-in-file-kind-invalid", + }) + { + model.Gaps.Should().NotContain(g => g.Contains(name)); + } + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Semantic/ScalarSemanticValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Semantic/ScalarSemanticValidatorTests.cs new file mode 100644 index 00000000..f27efcee --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Semantic/ScalarSemanticValidatorTests.cs @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Semantic; +using Azure.Bicep.Types.Validation.UnitTests.Graph; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Semantic; + +[TestClass] +public class ScalarSemanticValidatorTests +{ + // A resource whose body targets /1, with in-mask modern scopes, followed by an empty object + // at /1. The reached type file is scanned in full, so a test type placed at /2 is validated + // even though nothing references it. + private const string ResourcePrefix = + "{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8},"; + + private const string EmptyObject = + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}"; + + private static string TypesWithTail(string tailType) => + "[" + ResourcePrefix + EmptyObject + "," + tailType + "]"; + + private static IReadOnlyList Run(string typesJson, TypePackageValidationMode mode) + { + var fs = new InMemoryPackageFileSystem().AddText("types.json", typesJson); + var options = new TypePackageValidationOptions { Mode = mode }; + var index = GraphTestHelpers.Document("index.json", ResourceIndex("types.json#/0")); + var provider = new PackageDocumentProvider(fs, index, options); + + // Trigger graph traversal so the provider caches the reached type file. + SemanticGraphValidator.Validate(provider, index, options); + + return ScalarSemanticValidator.Validate(provider.GetReachedUsableTypeFiles(), options); + } + + private static string ResourceIndex(string refValue) => + "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"" + refValue + "\"}}," + + "\"resourceFunctions\":{},\"namespaceFunctions\":[]}"; + + // ── BCPVT026: numeric range ordering ───────────────────────────────────── + + [TestMethod] + public void Integer_min_greater_than_max_reports_bcpvt026() + { + var types = TypesWithTail("{\"$type\":\"IntegerType\",\"minValue\":10,\"maxValue\":5}"); + + var diagnostic = Run(types, TypePackageValidationMode.CanonicalWriter).Should().ContainSingle().Subject; + + diagnostic.Code.Should().Be(TypeValidationDiagnosticCodes.NumericRangeInvalid); + diagnostic.Severity.Should().Be(TypeValidationDiagnosticSeverity.Error); + diagnostic.JsonPointer.Should().Be("/2"); + diagnostic.Message.Should().Contain("minValue 10 greater than maxValue 5"); + } + + [TestMethod] + public void String_min_length_greater_than_max_length_reports_bcpvt026() + { + var types = TypesWithTail("{\"$type\":\"StringType\",\"minLength\":10,\"maxLength\":5}"); + + Run(types, TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.NumericRangeInvalid) + .Which.Message.Should().Contain("minLength 10 greater than maxLength 5"); + } + + [TestMethod] + public void Array_min_length_greater_than_max_length_reports_bcpvt026() + { + var types = TypesWithTail( + "{\"$type\":\"ArrayType\",\"itemType\":{\"$ref\":\"#/1\"},\"minLength\":10,\"maxLength\":5}"); + + Run(types, TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.NumericRangeInvalid) + .Which.JsonPointer.Should().Be("/2"); + } + + // ── BCPVT027: non-negative length ──────────────────────────────────────── + + [TestMethod] + public void Negative_string_length_reports_bcpvt027() + { + var types = TypesWithTail("{\"$type\":\"StringType\",\"minLength\":-1}"); + + var diagnostic = Run(types, TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle().Subject; + + diagnostic.Code.Should().Be(TypeValidationDiagnosticCodes.LengthConstraintNegative); + diagnostic.JsonPointer.Should().Be("/2/minLength"); + diagnostic.Message.Should().Contain("must be non-negative, but got -1"); + } + + [TestMethod] + public void Negative_array_length_reports_bcpvt027() + { + var types = TypesWithTail( + "{\"$type\":\"ArrayType\",\"itemType\":{\"$ref\":\"#/1\"},\"maxLength\":-1}"); + + Run(types, TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.LengthConstraintNegative) + .Which.JsonPointer.Should().Be("/2/maxLength"); + } + + // ── BCPVT029: scope flag domains ───────────────────────────────────────── + + [TestMethod] + public void Readable_scope_with_unknown_bits_reports_bcpvt029_error_in_canonical_and_warning_in_compatible() + { + const string types = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":32,\"writableScopes\":8}," + + EmptyObject + "]"; + + var canonical = Run(types, TypePackageValidationMode.CanonicalWriter).Should().ContainSingle().Subject; + canonical.Code.Should().Be(TypeValidationDiagnosticCodes.FlagsValueInvalid); + canonical.Severity.Should().Be(TypeValidationDiagnosticSeverity.Error); + canonical.JsonPointer.Should().Be("/0/readableScopes"); + canonical.Message.Should().Contain("unknown bits 32"); + canonical.Message.Should().Contain("Known mask is 31"); + + Run(types, TypePackageValidationMode.CompatibleReader).Should().ContainSingle() + .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning); + } + + [TestMethod] + public void Writable_scope_with_unknown_bits_reports_bcpvt029_error_in_canonical_and_warning_in_compatible() + { + const string types = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":64}," + + EmptyObject + "]"; + + Run(types, TypePackageValidationMode.CanonicalWriter).Should().ContainSingle() + .Which.JsonPointer.Should().Be("/0/writableScopes"); + + Run(types, TypePackageValidationMode.CompatibleReader).Should().ContainSingle() + .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning); + } + + [TestMethod] + public void Legacy_resource_scope_fields_are_skipped_by_scalar_domain_validation() + { + // scopeType is a legacy direct-root field owned by phase-4 policy; scalar domain + // validation must never read it, so no BCPVT029 is produced even for out-of-mask bits. + const string types = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"scopeType\":64}," + + EmptyObject + "]"; + + Run(types, TypePackageValidationMode.CanonicalWriter).Should().BeEmpty(); + Run(types, TypePackageValidationMode.CompatibleReader).Should().BeEmpty(); + } + + // ── BCPVT029: object/parameter flag domains ────────────────────────────── + + [TestMethod] + public void Object_property_flags_with_unknown_bits_reports_bcpvt029_error_in_canonical_and_warning_in_compatible() + { + const string types = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{" + + "\"name\":{\"type\":{\"$ref\":\"#/1\"},\"flags\":32}}}]"; + + var canonical = Run(types, TypePackageValidationMode.CanonicalWriter).Should().ContainSingle().Subject; + canonical.Code.Should().Be(TypeValidationDiagnosticCodes.FlagsValueInvalid); + canonical.Severity.Should().Be(TypeValidationDiagnosticSeverity.Error); + canonical.JsonPointer.Should().Be("/1/properties/name/flags"); + + Run(types, TypePackageValidationMode.CompatibleReader).Should().ContainSingle() + .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning); + } + + [TestMethod] + public void Namespace_function_parameter_flags_with_unknown_bits_reports_bcpvt029_error_in_canonical_and_warning_in_compatible() + { + var types = TypesWithTail( + "{\"$type\":\"NamespaceFunctionType\",\"name\":\"f\"," + + "\"parameters\":[{\"name\":\"p\",\"type\":{\"$ref\":\"#/1\"},\"flags\":8}]," + + "\"outputType\":{\"$ref\":\"#/1\"},\"visibleInFileKind\":1}"); + + var canonical = Run(types, TypePackageValidationMode.CanonicalWriter).Should().ContainSingle().Subject; + canonical.Code.Should().Be(TypeValidationDiagnosticCodes.FlagsValueInvalid); + canonical.JsonPointer.Should().Be("/2/parameters/0/flags"); + + Run(types, TypePackageValidationMode.CompatibleReader).Should().ContainSingle() + .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning); + } + + // ── BCPVT028: enum membership ──────────────────────────────────────────── + + [TestMethod] + public void Visible_in_file_kind_unknown_value_reports_bcpvt028_error_in_canonical_and_warning_in_compatible() + { + var types = TypesWithTail( + "{\"$type\":\"NamespaceFunctionType\",\"name\":\"f\",\"parameters\":[]," + + "\"outputType\":{\"$ref\":\"#/1\"},\"visibleInFileKind\":99}"); + + var canonical = Run(types, TypePackageValidationMode.CanonicalWriter).Should().ContainSingle().Subject; + canonical.Code.Should().Be(TypeValidationDiagnosticCodes.EnumValueInvalid); + canonical.Severity.Should().Be(TypeValidationDiagnosticSeverity.Error); + canonical.JsonPointer.Should().Be("/2/visibleInFileKind"); + canonical.Message.Should().Contain("must be one of 1 or 2"); + + Run(types, TypePackageValidationMode.CompatibleReader).Should().ContainSingle() + .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning); + } + + [TestMethod] + public void Enum_domain_validation_uses_exact_membership_not_flags_mask() + { + // 3 == 1 | 2 as bits, but BicepSourceFileKind is a non-flags enum: only exact members + // 1 and 2 are valid, so 3 must still be reported. + var types = TypesWithTail( + "{\"$type\":\"NamespaceFunctionType\",\"name\":\"f\",\"parameters\":[]," + + "\"outputType\":{\"$ref\":\"#/1\"},\"visibleInFileKind\":3}"); + + Run(types, TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.EnumValueInvalid); + } + + [TestMethod] + public void Flags_domain_validation_uses_mask_containment() + { + // 31 is the full ObjectTypePropertyFlags mask: every bit is known, so no diagnostic. + const string inMask = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{" + + "\"name\":{\"type\":{\"$ref\":\"#/1\"},\"flags\":31}}}]"; + + Run(inMask, TypePackageValidationMode.CanonicalWriter).Should().BeEmpty(); + + // Adding a single out-of-mask bit (32) is reported. + const string outOfMask = + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{" + + "\"name\":{\"type\":{\"$ref\":\"#/1\"},\"flags\":63}}}]"; + + Run(outOfMask, TypePackageValidationMode.CanonicalWriter) + .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.FlagsValueInvalid) + .Which.Message.Should().Contain("unknown bits 32"); + } + + // ── Structural ownership ───────────────────────────────────────────────── + + [TestMethod] + public void Scalar_semantic_validator_skips_wrong_shape_fields_owned_by_structural_layer() + { + // minValue has the wrong primitive shape (string). The structural layer owns that + // diagnostic; the scalar layer must not read the field or emit a range diagnostic. + var types = TypesWithTail("{\"$type\":\"IntegerType\",\"minValue\":\"10\",\"maxValue\":5}"); + + Run(types, TypePackageValidationMode.CanonicalWriter) + .Should().NotContain(d => d.Code == TypeValidationDiagnosticCodes.NumericRangeInvalid); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Structural/IndexDocumentValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Structural/IndexDocumentValidatorTests.cs new file mode 100644 index 00000000..b962e07b --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Structural/IndexDocumentValidatorTests.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Structural; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Structural; + +[TestClass] +public class IndexDocumentValidatorTests +{ + private static (StructuralValidationContext ctx, JsonShapeReader reader) CreateContext( + string indexJson, + TypePackageValidationMode mode = TypePackageValidationMode.CanonicalWriter) + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(indexJson); + SourceMap.TryParse(bytes, "index.json", out var root, out var sm, out _); + var doc = new PackageDocument("index.json", PackageDocumentKind.Index, root!, sm); + var options = new TypePackageValidationOptions { Mode = mode }; + var ctx = new StructuralValidationContext(options); + ctx.SetCurrentDocument(doc); + var reader = new JsonShapeReader(ctx); + return (ctx, reader); + } + + // ── Root shape ─────────────────────────────────────────────────────────── + + [TestMethod] + public void Non_object_root_reports_index_root_must_be_object() + { + var (ctx, reader) = CreateContext("[1,2,3]"); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.IndexRootMustBeObject); + } + + // ── Required top-level fields ──────────────────────────────────────────── + + [TestMethod] + public void Missing_resources_reports_required_property_missing() + { + var (ctx, reader) = CreateContext("{\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.RequiredPropertyMissing && d.Message.Contains("resources")); + } + + [TestMethod] + public void Missing_resource_functions_reports_required_property_missing() + { + var (ctx, reader) = CreateContext("{\"resources\":{},\"namespaceFunctions\":[]}"); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.RequiredPropertyMissing && d.Message.Contains("resourceFunctions")); + } + + [TestMethod] + public void Missing_namespace_functions_reports_required_property_missing() + { + var (ctx, reader) = CreateContext("{\"resources\":{},\"resourceFunctions\":{}}"); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.RequiredPropertyMissing && d.Message.Contains("namespaceFunctions")); + } + + // ── resources map ──────────────────────────────────────────────────────── + + [TestMethod] + public void Non_object_resources_reports_property_type_mismatch() + { + var (ctx, reader) = CreateContext("{\"resources\":\"bad\",\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PropertyTypeMismatch); + } + + [TestMethod] + public void Resource_entry_with_invalid_ref_reports_reference_object_invalid() + { + var json = @"{ + ""resources"": { ""S/r@2026"": ""not-a-ref"" }, + ""resourceFunctions"": {}, ""namespaceFunctions"": [] +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceObjectInvalid); + } + + // ── resourceFunctions ──────────────────────────────────────────────────── + + [TestMethod] + public void Resource_functions_must_be_nested_object_map() + { + var json = @"{ + ""resources"": {}, + ""resourceFunctions"": { + ""S/r@2026"": { + ""2026-01-01"": [{""$ref"":""types.json#/0""}] + } + }, + ""namespaceFunctions"": [] +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + // ── namespaceFunctions ─────────────────────────────────────────────────── + + [TestMethod] + public void Namespace_functions_must_be_array_of_refs() + { + var json = @"{ + ""resources"": {}, ""resourceFunctions"": {}, + ""namespaceFunctions"": [{""$ref"":""types.json#/1""}] +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + // ── settings ──────────────────────────────────────────────────────────── + + [TestMethod] + public void Settings_object_with_required_fields_is_valid() + { + var json = @"{ + ""resources"":{},""resourceFunctions"":{},""namespaceFunctions"":[], + ""settings"":{""name"":""Prov"",""version"":""1.0"",""isSingleton"":true} +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + [TestMethod] + public void Settings_missing_name_reports_required_property_missing() + { + var json = @"{ + ""resources"":{},""resourceFunctions"":{},""namespaceFunctions"":[], + ""settings"":{""version"":""1.0"",""isSingleton"":true} +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.RequiredPropertyMissing && d.Message.Contains("name")); + } + + [TestMethod] + public void Settings_missing_isSingleton_reports_required_property_missing() + { + var json = @"{ + ""resources"":{},""resourceFunctions"":{},""namespaceFunctions"":[], + ""settings"":{""name"":""P"",""version"":""1.0""} +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle(d => d.Message.Contains("isSingleton")); + } + + [TestMethod] + public void Settings_is_preview_and_is_deprecated_accept_null() + { + var json = @"{ + ""resources"":{},""resourceFunctions"":{},""namespaceFunctions"":[], + ""settings"":{""name"":""P"",""version"":""1.0"",""isSingleton"":false, + ""isPreview"":null,""isDeprecated"":null} +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + // ── fallbackResourceType ───────────────────────────────────────────────── + + [TestMethod] + public void Valid_fallback_resource_type_ref_is_accepted() + { + var json = @"{ + ""resources"":{},""resourceFunctions"":{},""namespaceFunctions"":[], + ""fallbackResourceType"":{""$ref"":""types.json#/0""} +}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + // ── Unknown top-level fields ───────────────────────────────────────────── + + [TestMethod] + public void Unknown_top_level_field_reports_unknown_property_in_canonical_mode() + { + var json = @"{""resources"":{},""resourceFunctions"":{},""namespaceFunctions"":[],""extra"":1}"; + var (ctx, reader) = CreateContext(json, TypePackageValidationMode.CanonicalWriter); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnknownProperty); + } + + [TestMethod] + public void Unknown_top_level_field_reports_unknown_property_in_compatible_reader_mode() + { + // index.json has no documented legacy top-level fields, so arbitrary unknown + // fields are rejected in CompatibleReader as well as CanonicalWriter. + var json = @"{""resources"":{},""resourceFunctions"":{},""namespaceFunctions"":[],""extra"":1}"; + var (ctx, reader) = CreateContext(json, TypePackageValidationMode.CompatibleReader); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnknownProperty); + } + + // ── JSON pointer escaping (RFC 6901) ───────────────────────────────────── + + [TestMethod] + public void Resource_key_with_special_characters_is_rfc6901_escaped_in_pointer() + { + // A resource type key containing '/' and '~' must be escaped as '~1' and '~0' + // (in that order) in the emitted JSON pointer. + var json = @"{""resources"":{""a/b~c"":""not-a-ref""},""resourceFunctions"":{},""namespaceFunctions"":[]}"; + var (ctx, reader) = CreateContext(json); + IndexDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.JsonPointer.Should().Be("/resources/a~1b~0c"); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Structural/ReferenceSyntaxTests.cs b/src/Bicep.Types.Validation.UnitTests/Structural/ReferenceSyntaxTests.cs new file mode 100644 index 00000000..fb8743e3 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Structural/ReferenceSyntaxTests.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Structural; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Structural; + +[TestClass] +public class ReferenceSyntaxTests +{ + // Helper: build a fake context pointing at a package document made from in-memory JSON + private static (StructuralValidationContext ctx, Azure.Bicep.Types.Validation.Packaging.JsonValueNode root) ParseJson(string json) + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(json); + Azure.Bicep.Types.Validation.Packaging.SourceMap.TryParse(bytes, "test.json", out var root, out var sm, out _); + var doc = new Azure.Bicep.Types.Validation.Packaging.PackageDocument("test.json", + Azure.Bicep.Types.Validation.Packaging.PackageDocumentKind.Index, root!, sm); + var ctx = new StructuralValidationContext(new TypePackageValidationOptions()); + ctx.SetCurrentDocument(doc); + return (ctx, root!); + } + + private static Azure.Bicep.Types.Validation.Packaging.JsonValueNode ParseRef(string refJson, + StructuralValidationContext ctx) => + ParseJson(refJson).root; + + // ── Valid reference forms ──────────────────────────────────────────────── + + [TestMethod] + public void Same_file_ref_is_accepted() + { + var (ctx, root) = ParseJson("{\"$ref\":\"#/0\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeTrue(); + result.PackageRelativePath.Should().BeEmpty(); + result.Index.Should().Be(0); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + [TestMethod] + public void Cross_file_ref_is_accepted() + { + var (ctx, root) = ParseJson("{\"$ref\":\"types.json#/0\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeTrue(); + result.PackageRelativePath.Should().Be("types.json"); + result.Index.Should().Be(0); + } + + [TestMethod] + public void Nested_package_path_ref_is_accepted() + { + var (ctx, root) = ParseJson("{\"$ref\":\"common/types.json#/3\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeTrue(); + result.PackageRelativePath.Should().Be("common/types.json"); + result.Index.Should().Be(3); + } + + // ── Invalid reference forms ────────────────────────────────────────────── + + [TestMethod] + public void Empty_ref_string_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid); + } + + [TestMethod] + public void Missing_fragment_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"types.json\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid); + } + + [TestMethod] + public void Non_integer_index_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"types.json#/abc\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid); + } + + [TestMethod] + public void Negative_index_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"types.json#/-1\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + } + + [TestMethod] + public void Package_path_with_traversal_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"../escape.json#/0\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid); + } + + [TestMethod] + public void Package_path_with_backslash_traversal_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"..\\\\escape.json#/0\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid); + } + + [TestMethod] + public void Rooted_posix_package_path_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"/tmp/types.json#/0\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid); + } + + [TestMethod] + public void Rooted_windows_drive_package_path_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":\"C:/temp/types.json#/0\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid); + } + + [TestMethod] + public void Missing_dollar_ref_property_is_rejected() + { + var (ctx, root) = ParseJson("{\"ref\":\"#/0\"}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceObjectInvalid); + } + + [TestMethod] + public void Non_string_dollar_ref_is_rejected() + { + var (ctx, root) = ParseJson("{\"$ref\":42}"); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceObjectInvalid); + } + + [TestMethod] + public void Non_object_reference_value_is_rejected() + { + var (ctx, root) = ParseJson("\"#/0\""); + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + result.IsValid.Should().BeFalse(); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceObjectInvalid); + } + + [TestMethod] + public void Reference_object_with_extra_properties_reports_unknown_property_in_canonical_mode() + { + var (ctx, root) = ParseJson("{\"$ref\":\"#/0\",\"extra\":1}"); + ctx.Options.Mode.Should().Be(TypePackageValidationMode.CanonicalWriter); // default + var result = ReferenceSyntax.Validate(root, "prop", "/prop", ctx); + // $ref itself is valid + result.IsValid.Should().BeTrue(); + // extra property rejected + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnknownProperty); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Structural/StructuralValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Structural/StructuralValidatorTests.cs new file mode 100644 index 00000000..20308ef6 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Structural/StructuralValidatorTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Structural; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Structural; + +[TestClass] +public class StructuralValidatorTests +{ + // ── Phase gates ────────────────────────────────────────────────────────── + + [TestMethod] + public void Missing_index_document_produces_no_structural_diagnostics() + { + // Null index document = fatal read failure already reported by PackageReader + var docSet = new JsonDocumentSet(null, new PackageDocument[0]); + var diagnostics = StructuralValidator.Validate(docSet, new TypePackageValidationOptions()); + diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void Malformed_index_root_prevents_type_file_validation() + { + // index.json root is an array (wrong shape) — no child inspection + // A type file is also included but should NOT produce diagnostics + byte[] indexBytes = System.Text.Encoding.UTF8.GetBytes("[1,2]"); + SourceMap.TryParse(indexBytes, "index.json", out var indexRoot, out var indexSm, out _); + var indexDoc = new PackageDocument("index.json", PackageDocumentKind.Index, indexRoot!, indexSm); + + byte[] typeBytes = System.Text.Encoding.UTF8.GetBytes("[{\"$type\":\"StringType\"}]"); + SourceMap.TryParse(typeBytes, "types.json", out var typeRoot, out var typeSm, out _); + var typeDoc = new PackageDocument("types.json", PackageDocumentKind.TypeFile, typeRoot!, typeSm); + + var docSet = new JsonDocumentSet(indexDoc, new[] { typeDoc }); + var diagnostics = StructuralValidator.Validate(docSet, new TypePackageValidationOptions()); + + // Only the IndexRootMustBeObject diagnostic should appear; no type-file errors + diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.IndexRootMustBeObject); + } + + [TestMethod] + public void Wrong_type_file_root_shape_prevents_element_inspection() + { + // type file root is an object, not an array — should get TypeFileRootMustBeArray + // but no TypeObjectDiscriminator errors + var indexDoc = CreateDoc("index.json", PackageDocumentKind.Index, + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + var typeDoc = CreateDoc("types.json", PackageDocumentKind.TypeFile, + "{\"$type\":\"StringType\"}"); // object, not array + + var docSet = new JsonDocumentSet(indexDoc, new[] { typeDoc }); + var diagnostics = StructuralValidator.Validate(docSet, new TypePackageValidationOptions()); + + diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeFileRootMustBeArray); + } + + [TestMethod] + public void Non_object_type_file_element_prevents_type_object_inspection_for_that_element() + { + var indexDoc = CreateDoc("index.json", PackageDocumentKind.Index, + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + var typeDoc = CreateDoc("types.json", PackageDocumentKind.TypeFile, + "[42]"); // element is not an object + + var docSet = new JsonDocumentSet(indexDoc, new[] { typeDoc }); + var diagnostics = StructuralValidator.Validate(docSet, new TypePackageValidationOptions()); + + // Should get TypeFileElementMustBeObject but NOT TypeObjectDiscriminatorMissing + diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeFileElementMustBeObject); + } + + [TestMethod] + public void Type_object_with_missing_dollar_type_prevents_kind_specific_checks() + { + // An object without $type must not trigger RequiredPropertyMissing for kind-specific fields + var indexDoc = CreateDoc("index.json", PackageDocumentKind.Index, + "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + var typeDoc = CreateDoc("types.json", PackageDocumentKind.TypeFile, + "[{\"name\":\"foo\"}]"); // no $type, so kind-specific 'name' check should not run + + var docSet = new JsonDocumentSet(indexDoc, new[] { typeDoc }); + var diagnostics = StructuralValidator.Validate(docSet, new TypePackageValidationOptions()); + + diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeObjectDiscriminatorMissing); + } + + [TestMethod] + public void Malformed_ref_in_index_does_not_produce_graph_style_diagnostics() + { + // A bad ref syntax should report ReferenceSyntaxInvalid, NOT an unresolved-reference + // graph diagnostic (which is out of phase-2 scope) + var indexDoc = CreateDoc("index.json", PackageDocumentKind.Index, + "{\"resources\":{\"S/r@v1\":\"not-a-ref-object\"},\"resourceFunctions\":{},\"namespaceFunctions\":[]}"); + + var docSet = new JsonDocumentSet(indexDoc, new PackageDocument[0]); + var diagnostics = StructuralValidator.Validate(docSet, new TypePackageValidationOptions()); + + // ReferenceObjectInvalid (structural) but nothing graph-style like "unresolved reference" + diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceObjectInvalid); + } + + // ── Helper ─────────────────────────────────────────────────────────────── + + private static PackageDocument CreateDoc(string relPath, PackageDocumentKind kind, string json) + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(json); + SourceMap.TryParse(bytes, relPath, out var root, out var sm, out _); + return new PackageDocument(relPath, kind, root!, sm); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Structural/TypeFileValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Structural/TypeFileValidatorTests.cs new file mode 100644 index 00000000..79de530a --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Structural/TypeFileValidatorTests.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Structural; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Structural; + +[TestClass] +public class TypeFileValidatorTests +{ + private static (StructuralValidationContext ctx, JsonShapeReader reader) CreateContext( + string typeFileJson, + TypePackageValidationMode mode = TypePackageValidationMode.CanonicalWriter) + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(typeFileJson); + SourceMap.TryParse(bytes, "types.json", out var root, out var sm, out _); + var doc = new PackageDocument("types.json", PackageDocumentKind.TypeFile, root!, sm); + var options = new TypePackageValidationOptions { Mode = mode }; + var ctx = new StructuralValidationContext(options); + ctx.SetCurrentDocument(doc); + return (ctx, new JsonShapeReader(ctx)); + } + + // ── Root shape ─────────────────────────────────────────────────────────── + + [TestMethod] + public void Non_array_root_reports_type_file_root_must_be_array() + { + var (ctx, reader) = CreateContext("{\"bad\":1}"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeFileRootMustBeArray); + } + + // ── Element shape ──────────────────────────────────────────────────────── + + [TestMethod] + public void Primitive_array_element_reports_type_file_element_must_be_object() + { + var (ctx, reader) = CreateContext("[42]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeFileElementMustBeObject); + } + + [TestMethod] + public void Null_array_element_reports_type_file_element_must_be_object() + { + var (ctx, reader) = CreateContext("[null]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeFileElementMustBeObject); + } + + // ── $type discriminator ────────────────────────────────────────────────── + + [TestMethod] + public void Type_object_without_dollar_type_reports_discriminator_missing() + { + var (ctx, reader) = CreateContext("[{\"name\":\"x\"}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeObjectDiscriminatorMissing); + } + + [TestMethod] + public void Non_string_dollar_type_reports_discriminator_must_be_string() + { + var (ctx, reader) = CreateContext("[{\"$type\":42}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeObjectDiscriminatorMustBeString); + } + + [TestMethod] + public void Unknown_dollar_type_reports_discriminator_unsupported() + { + var (ctx, reader) = CreateContext("[{\"$type\":\"NonExistentKind\"}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TypeObjectDiscriminatorUnsupported); + } + + // ── Field validation ───────────────────────────────────────────────────── + + [TestMethod] + public void Missing_required_field_reports_required_property_missing() + { + // StringLiteralType requires "value" field + var (ctx, reader) = CreateContext("[{\"$type\":\"StringLiteralType\"}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle(d => + d.Code == TypeValidationDiagnosticCodes.RequiredPropertyMissing && d.Message.Contains("value")); + } + + [TestMethod] + public void Wrong_primitive_field_shape_reports_property_type_mismatch() + { + // StringLiteralType.value must be a string + var (ctx, reader) = CreateContext("[{\"$type\":\"StringLiteralType\",\"value\":42}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PropertyTypeMismatch); + } + + [TestMethod] + public void Unknown_type_object_field_reports_unknown_property_in_canonical_mode() + { + var (ctx, reader) = CreateContext("[{\"$type\":\"AnyType\",\"unknownField\":1}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnknownProperty); + } + + [TestMethod] + public void Legacy_field_is_structurally_known_in_canonical_mode() + { + // scopeType is a documented legacy field. As of phase 4 the structural layer treats it as + // known in both modes; the mode-policy layer owns rejecting it in CanonicalWriter. + var json = "[{\"$type\":\"ResourceType\",\"name\":\"X\",\"body\":{\"$ref\":\"#/0\"}," + + "\"readableScopes\":8,\"writableScopes\":8,\"scopeType\":0}]"; + var (ctx, reader) = CreateContext(json, TypePackageValidationMode.CanonicalWriter); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + [TestMethod] + public void Legacy_field_is_accepted_in_compatible_reader_mode() + { + // The same documented legacy field is accepted in CompatibleReader. + var json = "[{\"$type\":\"ResourceType\",\"name\":\"X\",\"body\":{\"$ref\":\"#/0\"}," + + "\"readableScopes\":8,\"writableScopes\":8,\"scopeType\":0}]"; + var (ctx, reader) = CreateContext(json, TypePackageValidationMode.CompatibleReader); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + [TestMethod] + public void Arbitrary_unknown_field_is_rejected_in_compatible_reader_mode() + { + // CompatibleReader accepts only documented legacy fields, not arbitrary unknowns. + var (ctx, reader) = CreateContext( + "[{\"$type\":\"AnyType\",\"unknownField\":1}]", TypePackageValidationMode.CompatibleReader); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnknownProperty); + } + + [TestMethod] + public void Non_integer_number_in_integer_field_reports_property_type_mismatch() + { + // IntegerType.minValue is an integer field; a fractional number must be rejected. + var (ctx, reader) = CreateContext("[{\"$type\":\"IntegerType\",\"minValue\":1.5}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PropertyTypeMismatch); + } + + [TestMethod] + public void Malformed_reference_field_reports_reference_object_invalid() + { + // ArrayType.itemType must be a ref, but we're giving a string + var (ctx, reader) = CreateContext("[{\"$type\":\"ArrayType\",\"itemType\":\"not-a-ref\"}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceObjectInvalid); + } + + // ── Valid minimal type objects ─────────────────────────────────────────── + + [TestMethod] + public void Any_type_object_with_no_extra_fields_is_valid() + { + var (ctx, reader) = CreateContext("[{\"$type\":\"AnyType\"}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } + + [TestMethod] + public void String_literal_type_with_value_is_valid() + { + var (ctx, reader) = CreateContext("[{\"$type\":\"StringLiteralType\",\"value\":\"hello\"}]"); + TypeFileValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/Structural/TypeShapeCatalogTests.cs b/src/Bicep.Types.Validation.UnitTests/Structural/TypeShapeCatalogTests.cs new file mode 100644 index 00000000..cd170504 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Structural/TypeShapeCatalogTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Azure.Bicep.Types.Concrete; +using Azure.Bicep.Types.Validation.Structural; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Structural; + +[TestClass] +public class TypeShapeCatalogTests +{ + // The expected $type discriminators derived from TypeBase [JsonDerivedType] registrations + private static readonly string[] ExpectedDiscriminators = + { + "ArrayType", "BuiltInType", "DiscriminatedObjectType", "ObjectType", + "FunctionType", "ResourceFunctionType", "NamespaceFunctionType", + "ResourceType", "StringLiteralType", "UnionType", "AnyType", + "NullType", "BooleanType", "IntegerType", "StringType", + }; + + [TestMethod] + public void Catalog_contains_every_TypeBase_derived_discriminator() + { + foreach (var discriminator in ExpectedDiscriminators) + { + TypeShapeCatalog.GetDescriptor(discriminator) + .Should().NotBeNull(because: $"'{discriminator}' is a [JsonDerivedType] on TypeBase"); + } + } + + [TestMethod] + public void Catalog_count_exactly_matches_TypeBase_json_derived_type_registrations() + { + TypeShapeCatalog.AllDiscriminators.Should().HaveCount(ExpectedDiscriminators.Length); + } + + [TestMethod] + public void Catalog_excludes_scope_type_which_is_an_enum_not_a_discriminator() + { + // ScopeType is a flags enum used as a field value inside ResourceType, not a $type discriminator + TypeShapeCatalog.GetDescriptor("ScopeType").Should().BeNull(); + } + + [TestMethod] + public void All_structurally_supported_kinds_including_BuiltInType_have_descriptors() + { + TypeShapeCatalog.GetDescriptor("BuiltInType").Should().NotBeNull(); + TypeShapeCatalog.GetDescriptor("BuiltInType")!.Fields.Should().ContainSingle(f => f.Name == "kind"); + } + + [TestMethod] + public void Required_field_metadata_exists_for_non_trivial_kinds() + { + // ObjectType has required name and properties fields + var objectType = TypeShapeCatalog.GetDescriptor("ObjectType"); + objectType.Should().NotBeNull(); + objectType!.Fields.Where(f => f.Required).Select(f => f.Name) + .Should().Contain(new[] { "name", "properties" }); + + // ResourceType has required name, body, readableScopes, writableScopes + var resourceType = TypeShapeCatalog.GetDescriptor("ResourceType"); + resourceType!.Fields.Where(f => f.Required).Select(f => f.Name) + .Should().Contain(new[] { "name", "body", "readableScopes", "writableScopes" }); + } + + [TestMethod] + public void Reference_valued_fields_are_marked_as_Ref_shape() + { + // ArrayType.itemType is a ref + var arrayType = TypeShapeCatalog.GetDescriptor("ArrayType"); + arrayType!.Fields.Single(f => f.Name == "itemType").Shape.Should().Be(FieldShape.Ref); + + // ResourceType.body is a ref + var resourceType = TypeShapeCatalog.GetDescriptor("ResourceType"); + resourceType!.Fields.Single(f => f.Name == "body").Shape.Should().Be(FieldShape.Ref); + } + + [TestMethod] + public void ResourceType_legacy_fields_are_marked_as_legacy_compat_only() + { + var resourceType = TypeShapeCatalog.GetDescriptor("ResourceType"); + var legacyFields = resourceType!.Fields.Where(f => f.LegacyCompatOnly).Select(f => f.Name); + legacyFields.Should().Contain(new[] { "scopeType", "readOnlyScopes", "flags" }); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/TypePackageValidationOptionsTests.cs b/src/Bicep.Types.Validation.UnitTests/TypePackageValidationOptionsTests.cs new file mode 100644 index 00000000..71501dc0 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/TypePackageValidationOptionsTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests; + +[TestClass] +public class TypePackageValidationOptionsTests +{ + [TestMethod] + public void Default_options_use_canonical_writer_mode() + => new TypePackageValidationOptions().Mode.Should().Be(TypePackageValidationMode.CanonicalWriter); + + [TestMethod] + public void Default_options_use_bicep_types_v1_format_version() + => new TypePackageValidationOptions().FormatVersion.Should().Be(TypePackageFormatVersion.BicepTypesV1); + + [TestMethod] + public void Bicep_types_v1_is_the_default_enum_value() + => default(TypePackageFormatVersion).Should().Be(TypePackageFormatVersion.BicepTypesV1); + + [TestMethod] + public void Default_options_include_warnings() + => new TypePackageValidationOptions().IncludeWarnings.Should().BeTrue(); + + [TestMethod] + public void Default_options_exclude_informational_diagnostics() + => new TypePackageValidationOptions().IncludeInformationalDiagnostics.Should().BeFalse(); + + [TestMethod] + public void Default_options_do_not_validate_unreachable_files() + => new TypePackageValidationOptions().ValidateUnreachableFiles.Should().BeFalse(); + + [TestMethod] + public void Default_options_have_no_diagnostic_cap() + => new TypePackageValidationOptions().MaxDiagnostics.Should().BeNull(); + + [TestMethod] + public void Max_diagnostics_accepts_null() + => new TypePackageValidationOptions { MaxDiagnostics = null }.MaxDiagnostics.Should().BeNull(); + + [TestMethod] + public void Max_diagnostics_accepts_zero() + => new TypePackageValidationOptions { MaxDiagnostics = 0 }.MaxDiagnostics.Should().Be(0); + + [TestMethod] + public void Max_diagnostics_accepts_positive_values() + => new TypePackageValidationOptions { MaxDiagnostics = 5 }.MaxDiagnostics.Should().Be(5); + + [TestMethod] + public void Max_diagnostics_rejects_negative_values() + { + Action act = () => new TypePackageValidationOptions { MaxDiagnostics = -1 }; + + act.Should().Throw(); + } +} diff --git a/src/Bicep.Types.Validation.UnitTests/TypePackageValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/TypePackageValidatorTests.cs new file mode 100644 index 00000000..f58e7407 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/TypePackageValidatorTests.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Azure.Bicep.Types.Validation.Diagnostics; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests; + +[TestClass] +public class TypePackageValidatorTests +{ + private static readonly TypePackageValidator Validator = new(); + + // ── Phase-2 real-file tests ────────────────────────────────────────────── + + [TestMethod] + public void Valid_package_directory_returns_valid_result() + { + using var pkg = CreateMinimalPackage(); + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path)); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + result.Summary.ErrorCount.Should().Be(0); + } + + [TestMethod] + public void Valid_index_file_input_returns_valid_result() + { + using var pkg = CreateMinimalPackage(); + var indexPath = Path.Combine(pkg.Path, "index.json"); + var result = Validator.Validate(TypePackageValidationInput.ForIndexFile(indexPath)); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void Nonexistent_directory_returns_package_path_invalid() + { + var result = Validator.Validate(TypePackageValidationInput.ForDirectory("nonexistent-dir-that-does-not-exist")); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PackagePathInvalid); + } + + [TestMethod] + public void Directory_with_missing_index_json_returns_index_file_missing() + { + using var emptyDir = new TempDir(); + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(emptyDir.Path)); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.IndexFileMissing); + } + + [TestMethod] + public void Malformed_index_json_returns_invalid_and_stops_before_type_file_validation() + { + using var pkg = CreatePackageWithContent("{ not valid json }", null); + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path)); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.JsonSyntaxInvalid); + } + + [TestMethod] + public void Malformed_type_file_returns_invalid_without_hiding_index_errors() + { + // index.json references types.json; types.json has a syntax error + const string indexJson = @"{ + ""resources"": { ""Sample/res@2026-01-01"": { ""$ref"": ""types.json#/0"" } }, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"; + using var pkg = CreatePackageWithContent(indexJson, "this is not json"); + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path)); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.JsonSyntaxInvalid) + .Which.Path.Should().Be("types.json"); + } + + // ── Phase-6 archive behaviour ──────────────────────────────────────────── + + [TestMethod] + public void Archive_file_input_missing_file_returns_package_path_invalid() + { + var result = Validator.Validate(TypePackageValidationInput.ForArchiveFile("some/missing-types.tgz")); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PackagePathInvalid); + result.Summary.ErrorCount.Should().Be(1); + } + + [TestMethod] + public void Archive_stream_input_non_gzip_bytes_reports_archive_invalid() + { + using var stream = new MemoryStream(new byte[] { 0x00, 0x01, 0x02, 0x03 }); + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz")); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchivePackageInvalid); + } + + [TestMethod] + public void Result_echoes_selected_mode() + { + var options = new TypePackageValidationOptions { Mode = TypePackageValidationMode.CompatibleReader }; + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveFile("types.tgz"), options); + + result.Mode.Should().Be(TypePackageValidationMode.CompatibleReader); + } + + [TestMethod] + public void Default_mode_is_canonical_writer() + { + var result = Validator.Validate(TypePackageValidationInput.ForArchiveFile("types.tgz")); + + result.Mode.Should().Be(TypePackageValidationMode.CanonicalWriter); + } + + [TestMethod] + public void Null_input_throws() + { + Action act = () => Validator.Validate(null!); + + act.Should().Throw(); + } + + [TestMethod] + public void Null_max_diagnostics_leaves_truncation_false_for_archive_error() + { + var options = new TypePackageValidationOptions { MaxDiagnostics = null }; + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveFile("types.tgz"), options); + + result.DiagnosticsTruncated.Should().BeFalse(); + } + + [TestMethod] + public void Positive_max_diagnostics_not_exceeded_leaves_truncation_false() + { + var options = new TypePackageValidationOptions { MaxDiagnostics = 1 }; + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveFile("types.tgz"), options); + + result.Diagnostics.Should().ContainSingle(); + result.DiagnosticsTruncated.Should().BeFalse(); + } + + // ── Phase-7 format version awareness ───────────────────────────────────── + + [TestMethod] + public void Unsupported_format_version_returns_single_unsupported_error() + { + using var pkg = CreateMinimalPackage(); + var options = new TypePackageValidationOptions { FormatVersion = (TypePackageFormatVersion)999 }; + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options); + + result.IsValid.Should().BeFalse(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnsupportedFormatVersion); + } + + [TestMethod] + public void Explicit_bicep_types_v1_validates_valid_package() + { + using var pkg = CreateMinimalPackage(); + var options = new TypePackageValidationOptions { FormatVersion = TypePackageFormatVersion.BicepTypesV1 }; + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().BeEmpty(); + } + + [TestMethod] + public void Explicit_bicep_types_v1_compatible_reader_accepts_legacy_form_with_warning() + { + // Explicit BicepTypesV1 + CompatibleReader + a legacy scope form: version plumbing must not + // alter existing compatible-reader policy (single BCPVT023 warning, still valid). + using var pkg = CreatePackageWithContent( + "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"types.json#/0\"}}," + + "\"resourceFunctions\":{},\"namespaceFunctions\":[]}", + "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," + + "\"body\":{\"$ref\":\"#/1\"},\"scopeType\":0}," + + "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]"); + var options = new TypePackageValidationOptions + { + FormatVersion = TypePackageFormatVersion.BicepTypesV1, + Mode = TypePackageValidationMode.CompatibleReader, + }; + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options); + + result.IsValid.Should().BeTrue(); + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.CompatibilityFormUsed); + result.Summary.WarningCount.Should().Be(1); + result.Summary.ErrorCount.Should().Be(0); + } + + [TestMethod] + public void Unsupported_format_version_wins_over_invalid_input_path() + { + var options = new TypePackageValidationOptions { FormatVersion = (TypePackageFormatVersion)999 }; + + var result = Validator.Validate( + TypePackageValidationInput.ForDirectory("nonexistent-dir-that-does-not-exist"), options); + + // The version gate runs before input resolution/reading, so the path error never appears. + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnsupportedFormatVersion); + result.Diagnostics.Should().NotContain(d => d.Code == TypeValidationDiagnosticCodes.PackagePathInvalid); + } + + [TestMethod] + public void Unsupported_format_version_does_not_consume_archive_stream() + { + using var stream = new TrackingStream(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); + var options = new TypePackageValidationOptions { FormatVersion = (TypePackageFormatVersion)999 }; + + var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"), options); + + result.Diagnostics.Should().ContainSingle() + .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnsupportedFormatVersion); + stream.ReadInvoked.Should().BeFalse("the version gate must run before the caller's stream is read"); + } + + [TestMethod] + public void Unsupported_format_version_is_invalid_and_counted_in_summary() + { + using var pkg = CreateMinimalPackage(); + var options = new TypePackageValidationOptions { FormatVersion = (TypePackageFormatVersion)999 }; + + var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options); + + result.IsValid.Should().BeFalse(); + result.Summary.ErrorCount.Should().Be(1); + result.Summary.WarningCount.Should().Be(0); + result.Diagnostics.Should().ContainSingle(); + result.DiagnosticsTruncated.Should().BeFalse(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// + /// Creates a temp directory containing a minimal structurally-valid package: + /// index.json with empty resources/resourceFunctions/namespaceFunctions + /// and types.json as an empty array. + /// + private static TempDir CreateMinimalPackage() + { + var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "index.json"), @"{ + ""resources"": {}, + ""resourceFunctions"": {}, + ""namespaceFunctions"": [] +}"); + return dir; + } + + /// + /// Creates a temp directory with custom index.json content and optionally a + /// types.json file. + /// + private static TempDir CreatePackageWithContent(string indexJson, string? typesJson) + { + var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "index.json"), indexJson); + if (typesJson != null) + { + File.WriteAllText(Path.Combine(dir.Path, "types.json"), typesJson); + } + return dir; + } + + /// Disposable temp directory that deletes itself on dispose. + private sealed class TempDir : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "bcpvt-test-" + System.IO.Path.GetRandomFileName()); + + public TempDir() => Directory.CreateDirectory(Path); + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } + } + } + + /// + /// Non-seekable read-only stream that records whether it was ever read, used to prove the + /// format-version gate returns before the caller's archive stream is consumed. + /// + private sealed class TrackingStream : Stream + { + private readonly MemoryStream inner; + + public TrackingStream(byte[] content) => inner = new MemoryStream(content); + + public bool ReadInvoked { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + ReadInvoked = true; + return inner.Read(buffer, offset, count); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) { inner.Dispose(); } + base.Dispose(disposing); + } + } +} + diff --git a/src/Bicep.Types.Validation/Bicep.Types.Validation.csproj b/src/Bicep.Types.Validation/Bicep.Types.Validation.csproj new file mode 100644 index 00000000..0b332c43 --- /dev/null +++ b/src/Bicep.Types.Validation/Bicep.Types.Validation.csproj @@ -0,0 +1,31 @@ + + + + netstandard2.0 + true + Azure.Bicep.Types.Validation + Azure.Bicep.Types.Validation + Validation for Bicep types serialized packages + README.md + + + + + + + + + + + + + + + <_Parameter1>Bicep.Types.Validation.UnitTests + + + + + + + diff --git a/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnostic.cs b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnostic.cs new file mode 100644 index 00000000..6b566fbd --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnostic.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Diagnostics +{ + /// + /// A single structured validation diagnostic. + /// + /// + /// Phase 1 uses flat location fields (, , + /// , ) instead of a source span, and omits any + /// rule identifier. Location fields may be absent for input-level diagnostics that do + /// not point into package JSON. + /// + public sealed class TypeValidationDiagnostic + { + private static readonly IReadOnlyList NoRelatedLocations = + new TypeValidationDiagnosticRelatedLocation[0]; + + public TypeValidationDiagnostic( + string code, + TypeValidationDiagnosticSeverity severity, + string message, + string? path = null, + string? jsonPointer = null, + int? line = null, + int? column = null, + IReadOnlyList? relatedLocations = null) + { + Code = code ?? throw new ArgumentNullException(nameof(code)); + Severity = severity; + Message = message ?? throw new ArgumentNullException(nameof(message)); + Path = path; + JsonPointer = jsonPointer; + Line = line; + Column = column; + RelatedLocations = relatedLocations ?? NoRelatedLocations; + } + + /// Stable diagnostic code, for example BCPVT001. + public string Code { get; } + + /// Severity of the diagnostic. + public TypeValidationDiagnosticSeverity Severity { get; } + + /// Human-readable diagnostic message. + public string Message { get; } + + /// Package-relative path of the diagnostic, when available. + public string? Path { get; } + + /// JSON pointer into the offending file, when available. + public string? JsonPointer { get; } + + /// 1-based line number, when available. + public int? Line { get; } + + /// 1-based column number, when available. + public int? Column { get; } + + /// Secondary locations associated with the diagnostic. + public IReadOnlyList RelatedLocations { get; } + } +} diff --git a/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticBuilder.cs b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticBuilder.cs new file mode 100644 index 00000000..dcf4c69b --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticBuilder.cs @@ -0,0 +1,548 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Diagnostics +{ + /// + /// Factory for validation diagnostics with stable codes and messages. + /// + public static class TypeValidationDiagnosticBuilder + { + // ── Phase 1 ────────────────────────────────────────────────────────────── + + /// + /// Builds the legacy BCPVT001 diagnostic. Archive validation is now implemented, so this + /// builder is retained only for API stability and is no longer produced during validation. + /// + public static TypeValidationDiagnostic ArchiveValidationNotImplemented(string displayPath) + { + if (displayPath is null) { throw new ArgumentNullException(nameof(displayPath)); } + + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ArchiveValidationNotImplemented, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"'{displayPath}' produced the legacy BCPVT001 diagnostic, which is retained for compatibility and no longer emitted. Archive package validation is implemented; archive inputs are validated directly."); + } + + // ── Phase 2: input/package-reading ─────────────────────────────────────── + + /// The supplied package path does not point to a valid directory. + public static TypeValidationDiagnostic PackagePathInvalid(string displayPath) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.PackagePathInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"The package path '{displayPath}' does not exist or is not a valid package directory."); + } + + /// The package root directory does not contain index.json. + public static TypeValidationDiagnostic IndexFileMissing(string displayPath) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.IndexFileMissing, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"The package at '{displayPath}' does not contain an 'index.json' file at the package root."); + } + + /// A package JSON file could not be read. + public static TypeValidationDiagnostic PackageFileReadFailed(string packageRelativePath, string ioError) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.PackageFileReadFailed, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Failed to read package file '{packageRelativePath}': {ioError}", + path: packageRelativePath); + } + + /// A package JSON file contains a syntax error. + public static TypeValidationDiagnostic JsonSyntaxInvalid(string packageRelativePath, int line, int column, string syntaxMessage) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.JsonSyntaxInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"JSON syntax error in '{packageRelativePath}': {syntaxMessage}", + path: packageRelativePath, + line: line, + column: column); + } + + // ── Phase 2: structural ─────────────────────────────────────────────────── + + /// The root value of index.json is not a JSON object. + public static TypeValidationDiagnostic IndexRootMustBeObject(string packageRelativePath, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.IndexRootMustBeObject, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"The root value of '{packageRelativePath}' must be a JSON object.", + path: packageRelativePath, + jsonPointer: string.Empty, + line: line, + column: column); + } + + /// The root value of a type file is not a JSON array. + public static TypeValidationDiagnostic TypeFileRootMustBeArray(string packageRelativePath, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.TypeFileRootMustBeArray, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"The root value of type file '{packageRelativePath}' must be a JSON array.", + path: packageRelativePath, + jsonPointer: string.Empty, + line: line, + column: column); + } + + /// An element in a type-file array is not a JSON object. + public static TypeValidationDiagnostic TypeFileElementMustBeObject(string packageRelativePath, string jsonPointer, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.TypeFileElementMustBeObject, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Element '{jsonPointer}' in type file '{packageRelativePath}' must be a JSON object.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A type object is missing the required $type discriminator. + public static TypeValidationDiagnostic TypeObjectDiscriminatorMissing(string packageRelativePath, string jsonPointer, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.TypeObjectDiscriminatorMissing, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Type object at '{jsonPointer}' in '{packageRelativePath}' is missing the required '$type' discriminator field.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// The $type discriminator is present but is not a string. + public static TypeValidationDiagnostic TypeObjectDiscriminatorMustBeString(string packageRelativePath, string jsonPointer, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.TypeObjectDiscriminatorMustBeString, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"The '$type' field at '{jsonPointer}' in '{packageRelativePath}' must be a string.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// The $type discriminator names an unsupported type kind. + public static TypeValidationDiagnostic TypeObjectDiscriminatorUnsupported(string packageRelativePath, string jsonPointer, string actualDiscriminator, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.TypeObjectDiscriminatorUnsupported, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"The '$type' value '{actualDiscriminator}' at '{jsonPointer}' in '{packageRelativePath}' is not a supported type kind.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A required property is missing. + public static TypeValidationDiagnostic RequiredPropertyMissing(string packageRelativePath, string jsonPointer, string propertyName, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.RequiredPropertyMissing, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Required property '{propertyName}' is missing at '{jsonPointer}' in '{packageRelativePath}'.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A property has the wrong JSON value type. + public static TypeValidationDiagnostic PropertyTypeMismatch(string packageRelativePath, string jsonPointer, string propertyName, string expectedType, string actualType, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.PropertyTypeMismatch, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Property '{propertyName}' at '{jsonPointer}' in '{packageRelativePath}' must be a {expectedType}, but got {actualType}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A reference value is not a valid reference object. + public static TypeValidationDiagnostic ReferenceObjectInvalid(string packageRelativePath, string jsonPointer, string propertyName, string reason, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ReferenceObjectInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Property '{propertyName}' at '{jsonPointer}' in '{packageRelativePath}' must be a reference object ({{\"$ref\": \"...\"}}): {reason}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A $ref string does not match the expected syntax. + public static TypeValidationDiagnostic ReferenceSyntaxInvalid(string packageRelativePath, string jsonPointer, string refValue, string reason, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ReferenceSyntaxInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Reference '{refValue}' at '{jsonPointer}' in '{packageRelativePath}' has invalid syntax: {reason}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// An unexpected property was found on a JSON object. + public static TypeValidationDiagnostic UnknownProperty(string packageRelativePath, string jsonPointer, string propertyName, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.UnknownProperty, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Unexpected property '{propertyName}' at '{jsonPointer}' in '{packageRelativePath}'.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + // ── Phase 3: semantic graph ─────────────────────────────────────────────── + + /// A reference targets a type file that does not exist in the package. + public static TypeValidationDiagnostic ReferencedTypeFileMissing( + string sourcePackageRelativePath, string sourceJsonPointer, string targetPackageRelativePath, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ReferencedTypeFileMissing, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Reference at '{sourceJsonPointer}' in '{sourcePackageRelativePath}' targets missing type file '{targetPackageRelativePath}'.", + path: sourcePackageRelativePath, + jsonPointer: sourceJsonPointer, + line: line, + column: column); + } + + /// + /// A reference targets a type file that exists but could not be read. Uses the + /// code but, unlike the + /// reader-time overload, points at the referencing $ref site and names the target file. + /// + public static TypeValidationDiagnostic ReferencedTypeFileReadFailed( + string sourcePackageRelativePath, string sourceJsonPointer, string targetPackageRelativePath, string ioError, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.PackageFileReadFailed, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Reference at '{sourceJsonPointer}' in '{sourcePackageRelativePath}' targets type file '{targetPackageRelativePath}', which could not be read: {ioError}", + path: sourcePackageRelativePath, + jsonPointer: sourceJsonPointer, + line: line, + column: column); + } + + /// A reference targets a type file that could not be parsed or is not a usable type-file array. + public static TypeValidationDiagnostic ReferencedTypeFileUnusable( + string sourcePackageRelativePath, string sourceJsonPointer, string targetPackageRelativePath, int line, int column, + IReadOnlyList? relatedLocations = null) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ReferencedTypeFileUnusable, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Reference at '{sourceJsonPointer}' in '{sourcePackageRelativePath}' targets type file '{targetPackageRelativePath}', which is not a usable type-file array.", + path: sourcePackageRelativePath, + jsonPointer: sourceJsonPointer, + line: line, + column: column, + relatedLocations: relatedLocations); + } + + /// A reference names a type-object index that is out of range for the target file. + public static TypeValidationDiagnostic ReferenceIndexOutOfRange( + string sourcePackageRelativePath, string sourceJsonPointer, string targetPackageRelativePath, int targetIndex, int targetCount, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ReferenceIndexOutOfRange, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Reference at '{sourceJsonPointer}' in '{sourcePackageRelativePath}' targets index {targetIndex} in '{targetPackageRelativePath}', but the file contains {targetCount} type objects.", + path: sourcePackageRelativePath, + jsonPointer: sourceJsonPointer, + line: line, + column: column); + } + + /// A top-level index.json root reference resolves to the wrong type-object kind. + public static TypeValidationDiagnostic TopLevelTargetKindMismatch( + string sourcePackageRelativePath, string sourceJsonPointer, string rootDescription, string expectedKinds, string actualKind, int line, int column, + IReadOnlyList? relatedLocations = null) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"{rootDescription} must reference {expectedKinds}, but the target is {actualKind}.", + path: sourcePackageRelativePath, + jsonPointer: sourceJsonPointer, + line: line, + column: column, + relatedLocations: relatedLocations); + } + + /// A nested type-object reference resolves to a kind not allowed for its role. + public static TypeValidationDiagnostic NestedTargetKindMismatch( + string sourcePackageRelativePath, string sourceJsonPointer, string roleDescription, string expectedKinds, string actualKind, int line, int column, + IReadOnlyList? relatedLocations = null) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.NestedTargetKindMismatch, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Reference at '{sourceJsonPointer}' in '{sourcePackageRelativePath}' for role '{roleDescription}' must target {expectedKinds}, but the target is {actualKind}.", + path: sourcePackageRelativePath, + jsonPointer: sourceJsonPointer, + line: line, + column: column, + relatedLocations: relatedLocations); + } + + // ── Phase 4: mode policy ─────────────────────────────────────────────────── + + /// A legacy ResourceType scope field is present in a CanonicalWriter package. + public static TypeValidationDiagnostic CanonicalScopeFieldViolation( + string packageRelativePath, string jsonPointer, string fieldName, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.CanonicalFormViolation, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Property '{fieldName}' at '{jsonPointer}' in '{packageRelativePath}' is a legacy ResourceType scope field. CanonicalWriter packages must use 'readableScopes' and 'writableScopes'.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A legacy ResourceType scope field accepted for CompatibleReader (warning). + public static TypeValidationDiagnostic CompatibilityScopeFieldUsed( + string packageRelativePath, string jsonPointer, string fieldName, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.CompatibilityFormUsed, + severity: TypeValidationDiagnosticSeverity.Warning, + message: $"Property '{fieldName}' at '{jsonPointer}' in '{packageRelativePath}' is accepted only for CompatibleReader mode. Prefer canonical fields 'readableScopes' and 'writableScopes'.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A ResourceType mixes modern scope fields with an effective legacy scope field. + public static TypeValidationDiagnostic ResourceScopeFormMixed( + string packageRelativePath, string jsonPointer, string legacyFieldName, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ResourceScopeFormMixed, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"ResourceType at '{jsonPointer}' in '{packageRelativePath}' mixes modern scope fields with legacy scope field '{legacyFieldName}'. Use either the canonical modern pair or a documented legacy form, not both.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A documented legacy BuiltInType kind is present in a CanonicalWriter package. + public static TypeValidationDiagnostic CanonicalBuiltInTypeViolation( + string packageRelativePath, string jsonPointer, long kind, string kindName, string? replacement, int line, int column) + { + string message = replacement != null + ? $"BuiltInType.kind at '{jsonPointer}' in '{packageRelativePath}' uses legacy built-in kind {kind} ('{kindName}'). CanonicalWriter packages must use '{replacement}'." + : $"BuiltInType.kind at '{jsonPointer}' in '{packageRelativePath}' uses reserved legacy built-in kind {kind} ('{kindName}'), which CanonicalWriter packages must not emit."; + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.CanonicalFormViolation, + severity: TypeValidationDiagnosticSeverity.Error, + message: message, + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A documented legacy BuiltInType kind accepted for CompatibleReader (warning). + public static TypeValidationDiagnostic CompatibilityBuiltInTypeUsed( + string packageRelativePath, string jsonPointer, long kind, string kindName, string? replacement, int line, int column) + { + string message = replacement != null + ? $"BuiltInType.kind at '{jsonPointer}' in '{packageRelativePath}' uses legacy built-in kind {kind} ('{kindName}') accepted only for CompatibleReader mode. Prefer '{replacement}'." + : $"BuiltInType.kind at '{jsonPointer}' in '{packageRelativePath}' uses reserved legacy built-in kind {kind} ('{kindName}'), accepted only for CompatibleReader mode."; + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.CompatibilityFormUsed, + severity: TypeValidationDiagnosticSeverity.Warning, + message: message, + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// BuiltInType.kind is outside the documented serialized enum range (1..8). + public static TypeValidationDiagnostic BuiltInTypeKindInvalid( + string packageRelativePath, string jsonPointer, long kind, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.BuiltInTypeKindInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"BuiltInType.kind at '{jsonPointer}' in '{packageRelativePath}' must be one of 1..8, but got {kind}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + // ── Phase 5: semantic constraints ──────────────────────────────────────── + + /// A numeric range constraint has its minimum greater than its maximum. + public static TypeValidationDiagnostic NumericRangeInvalid( + string packageRelativePath, string jsonPointer, string typeName, + string minFieldName, long minValue, string maxFieldName, long maxValue, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.NumericRangeInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"{typeName} at '{jsonPointer}' in '{packageRelativePath}' has {minFieldName} {minValue} greater than {maxFieldName} {maxValue}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A length constraint (minLength/maxLength) is negative. + public static TypeValidationDiagnostic LengthConstraintNegative( + string packageRelativePath, string jsonPointer, string typeName, string fieldName, long value, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.LengthConstraintNegative, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"{typeName}.{fieldName} at '{jsonPointer}' in '{packageRelativePath}' must be non-negative, but got {value}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// An enum-valued field is outside its documented value set for this validator version. + public static TypeValidationDiagnostic EnumValueInvalid( + string packageRelativePath, string jsonPointer, string qualifiedFieldName, string allowedText, long value, + TypeValidationDiagnosticSeverity severity, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.EnumValueInvalid, + severity: severity, + message: $"{qualifiedFieldName} at '{jsonPointer}' in '{packageRelativePath}' must be one of {allowedText} for this validator version, but got {value}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + /// A flags-valued field contains bits outside its known mask for this validator version. + public static TypeValidationDiagnostic FlagsValueInvalid( + string packageRelativePath, string jsonPointer, string description, long unknownBits, long knownMask, + TypeValidationDiagnosticSeverity severity, int line, int column) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.FlagsValueInvalid, + severity: severity, + message: $"{description} at '{jsonPointer}' in '{packageRelativePath}' contain unknown bits {unknownBits} for this validator version. Known mask is {knownMask}.", + path: packageRelativePath, + jsonPointer: jsonPointer, + line: line, + column: column); + } + + // ── Phase 6: archive inputs and strict package hygiene ─────────────────── + + /// Archive bytes cannot be read as a valid gzip/tar package (fatal container failure). + public static TypeValidationDiagnostic ArchivePackageInvalid(string displayPath, string readerMessage) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ArchivePackageInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Archive input '{displayPath}' could not be read as a gzip-compressed tar package: {readerMessage}."); + } + + /// An archive member has an invalid package-relative path. + public static TypeValidationDiagnostic ArchiveMemberPathInvalid(string memberName) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Archive member '{memberName}' is not a valid package-relative file path.", + path: memberName); + } + + /// An archive member uses an unsupported tar entry type such as a symlink or hardlink. + public static TypeValidationDiagnostic ArchiveMemberEntryTypeUnsupported(string memberName, string entryTypeName) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Archive member '{memberName}' has unsupported tar entry type '{entryTypeName}'. Only regular files and directories are supported.", + path: memberName); + } + + /// The archive contains the same canonical package-relative file path more than once. + public static TypeValidationDiagnostic ArchiveMemberDuplicate(string displayPath, string memberName) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ArchiveMemberDuplicate, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Archive member '{memberName}' appears more than once in archive input '{displayPath}'.", + path: memberName); + } + + /// Two distinct archive member names collide after canonical path normalization. + public static TypeValidationDiagnostic ArchiveMemberPathCollision(string firstMemberName, string secondMemberName) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.ArchiveMemberPathCollision, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Archive members '{firstMemberName}' and '{secondMemberName}' collide after package path normalization.", + path: secondMemberName); + } + + /// A package file is not reachable from index.json roots under strict hygiene validation. + public static TypeValidationDiagnostic UnreachablePackageFile(string packageRelativePath) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.UnreachablePackageFile, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Package file '{packageRelativePath}' is not reachable from 'index.json' roots.", + path: packageRelativePath); + } + + /// A strict package scan found an unsupported non-JSON package member. + public static TypeValidationDiagnostic UnexpectedPackageFile(string packageRelativePath) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.UnexpectedPackageFile, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Package file '{packageRelativePath}' is not a supported Bicep Types package file.", + path: packageRelativePath); + } + + // ── Phase 7: format version awareness ──────────────────────────────────── + + /// The selected package format version is not supported by this validator. + public static TypeValidationDiagnostic UnsupportedFormatVersion(TypePackageFormatVersion version) + { + return new TypeValidationDiagnostic( + code: TypeValidationDiagnosticCodes.UnsupportedFormatVersion, + severity: TypeValidationDiagnosticSeverity.Error, + message: $"Unsupported Bicep Types package format version '{version}'. This validator supports '{TypePackageFormatVersion.BicepTypesV1}'."); + } + } +} diff --git a/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticCodes.cs b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticCodes.cs new file mode 100644 index 00000000..8098ab5f --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticCodes.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Diagnostics +{ + /// + /// Stable diagnostic code constants. Codes are plain strings and form the + /// user-facing and baseline-facing contract for validation diagnostics. + /// + public static class TypeValidationDiagnosticCodes + { + /// + /// Legacy code from phase 1 when archive inputs returned a not-implemented diagnostic. Archive + /// validation is now implemented (see BCPVT030BCPVT033); this code is retained for + /// API stability but is no longer emitted. + /// + public const string ArchiveValidationNotImplemented = "BCPVT001"; + + /// The package root directory does not contain index.json. + public const string IndexFileMissing = "BCPVT002"; + + /// A package JSON file is not syntactically valid JSON. + public const string JsonSyntaxInvalid = "BCPVT003"; + + /// The root value of index.json is not a JSON object. + public const string IndexRootMustBeObject = "BCPVT004"; + + /// The root value of a type file is not a JSON array. + public const string TypeFileRootMustBeArray = "BCPVT005"; + + /// An element in a type-file array is not a JSON object. + public const string TypeFileElementMustBeObject = "BCPVT006"; + + /// A type object does not contain a $type discriminator field. + public const string TypeObjectDiscriminatorMissing = "BCPVT007"; + + /// The $type discriminator field is not a string. + public const string TypeObjectDiscriminatorMustBeString = "BCPVT008"; + + /// The $type discriminator names a type kind that is not supported. + public const string TypeObjectDiscriminatorUnsupported = "BCPVT009"; + + /// A required property is missing from the JSON object. + public const string RequiredPropertyMissing = "BCPVT010"; + + /// A property has the wrong JSON value type (e.g. string where integer expected). + public const string PropertyTypeMismatch = "BCPVT011"; + + /// A reference value is not a well-formed reference object. + public const string ReferenceObjectInvalid = "BCPVT012"; + + /// A reference string does not match the expected path#/index syntax. + public const string ReferenceSyntaxInvalid = "BCPVT013"; + + /// An unexpected property was found on a JSON object. + public const string UnknownProperty = "BCPVT014"; + + /// A package file named by a reference could not be read. + public const string PackageFileReadFailed = "BCPVT015"; + + /// The supplied package path does not point to a valid directory or file. + public const string PackagePathInvalid = "BCPVT016"; + + // ── Phase 3: semantic graph ────────────────────────────────────────────── + + /// A reference targets a type file that does not exist in the package. + public const string ReferencedTypeFileMissing = "BCPVT017"; + + /// A reference targets a type file that could not be parsed or is not a usable type-file array. + public const string ReferencedTypeFileUnusable = "BCPVT018"; + + /// A reference names a type-object index that is out of range for the target file. + public const string ReferenceIndexOutOfRange = "BCPVT019"; + + /// A top-level index.json root reference resolves to the wrong type-object kind. + public const string TopLevelTargetKindMismatch = "BCPVT020"; + + /// A nested type-object reference resolves to a kind not allowed for its role. + public const string NestedTargetKindMismatch = "BCPVT021"; + + // ── Phase 4: mode policy ───────────────────────────────────────────────── + + /// The package uses a documented legacy form that canonical writers must not emit. + public const string CanonicalFormViolation = "BCPVT022"; + + /// A compatible reader accepted a documented legacy form (warning). + public const string CompatibilityFormUsed = "BCPVT023"; + + /// A ResourceType mixes modern scope fields with effective legacy scope fields. + public const string ResourceScopeFormMixed = "BCPVT024"; + + /// BuiltInType.kind is outside the documented serialized enum range. + public const string BuiltInTypeKindInvalid = "BCPVT025"; + + // ── Phase 5: semantic constraints ──────────────────────────────────────── + + /// A numeric range constraint has its minimum greater than its maximum. + public const string NumericRangeInvalid = "BCPVT026"; + + /// A length constraint (minLength/maxLength) is negative. + public const string LengthConstraintNegative = "BCPVT027"; + + /// An enum-valued field is outside its documented value set for this validator version. + public const string EnumValueInvalid = "BCPVT028"; + + /// A flags-valued field contains bits outside its known mask for this validator version. + public const string FlagsValueInvalid = "BCPVT029"; + + // ── Phase 6: archive inputs and strict package hygiene ─────────────────── + + /// Archive bytes cannot be read as a valid gzip/tar package (fatal container failure). + public const string ArchivePackageInvalid = "BCPVT030"; + + /// An archive member has an invalid package-relative path or an unsupported entry type. + public const string ArchiveMemberPathInvalid = "BCPVT031"; + + /// The archive contains the same canonical package-relative file path more than once. + public const string ArchiveMemberDuplicate = "BCPVT032"; + + /// Two distinct archive member names collide after canonical path normalization. + public const string ArchiveMemberPathCollision = "BCPVT033"; + + /// A package file is not reachable from index.json roots under strict hygiene validation. + public const string UnreachablePackageFile = "BCPVT034"; + + /// A strict package scan found an unsupported package member, such as a non-JSON regular file. + public const string UnexpectedPackageFile = "BCPVT035"; + + // ── Phase 7: format version awareness ──────────────────────────────────── + + /// The selected package format version is not supported by this validator. + public const string UnsupportedFormatVersion = "BCPVT036"; + } +} diff --git a/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticComparer.cs b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticComparer.cs new file mode 100644 index 00000000..56957266 --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticComparer.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Diagnostics +{ + /// + /// Central deterministic ordering for validation diagnostics. + /// + /// + /// Sort order: + /// + /// Input-level diagnostics with no package path sort before file-scoped diagnostics. + /// Package-relative path. + /// Line. + /// Column. + /// Diagnostic code. + /// JSON pointer. + /// Message as the final tie-breaker. + /// + /// + public sealed class TypeValidationDiagnosticComparer : IComparer + { + /// Shared stateless instance. + public static TypeValidationDiagnosticComparer Instance { get; } = new TypeValidationDiagnosticComparer(); + + public int Compare(TypeValidationDiagnostic? x, TypeValidationDiagnostic? y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + if (x is null) + { + return -1; + } + + if (y is null) + { + return 1; + } + + // 1. Location scope: input-level (no package path) sorts before file-scoped. + var xHasPath = !string.IsNullOrEmpty(x.Path); + var yHasPath = !string.IsNullOrEmpty(y.Path); + if (xHasPath != yHasPath) + { + return xHasPath ? 1 : -1; + } + + // 2. Package-relative path. + var cmp = string.CompareOrdinal(x.Path ?? string.Empty, y.Path ?? string.Empty); + if (cmp != 0) + { + return cmp; + } + + // 3. Line. + cmp = CompareNullable(x.Line, y.Line); + if (cmp != 0) + { + return cmp; + } + + // 4. Column. + cmp = CompareNullable(x.Column, y.Column); + if (cmp != 0) + { + return cmp; + } + + // 5. Diagnostic code. + cmp = string.CompareOrdinal(x.Code, y.Code); + if (cmp != 0) + { + return cmp; + } + + // 6. JSON pointer. + cmp = string.CompareOrdinal(x.JsonPointer ?? string.Empty, y.JsonPointer ?? string.Empty); + if (cmp != 0) + { + return cmp; + } + + // 7. Message. + return string.CompareOrdinal(x.Message, y.Message); + } + + private static int CompareNullable(int? a, int? b) + { + if (!a.HasValue && !b.HasValue) + { + return 0; + } + + // A missing line/column sorts before a present one. + if (!a.HasValue) + { + return -1; + } + + if (!b.HasValue) + { + return 1; + } + + return a.Value.CompareTo(b.Value); + } + } +} diff --git a/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticRelatedLocation.cs b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticRelatedLocation.cs new file mode 100644 index 00000000..9de23469 --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticRelatedLocation.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Azure.Bicep.Types.Validation.Diagnostics +{ + /// + /// A secondary source location associated with a diagnostic, such as the + /// declaration site referenced by a wrong-target-kind diagnostic. + /// + public sealed class TypeValidationDiagnosticRelatedLocation + { + public TypeValidationDiagnosticRelatedLocation( + string message, + string? path = null, + string? jsonPointer = null, + int? line = null, + int? column = null) + { + Message = message ?? throw new ArgumentNullException(nameof(message)); + Path = path; + JsonPointer = jsonPointer; + Line = line; + Column = column; + } + + /// Human-readable note describing the related location. + public string Message { get; } + + /// Package-relative path of the related location, when available. + public string? Path { get; } + + /// JSON pointer into the related file, when available. + public string? JsonPointer { get; } + + /// 1-based line number, when available. + public int? Line { get; } + + /// 1-based column number, when available. + public int? Column { get; } + } +} diff --git a/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticSeverity.cs b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticSeverity.cs new file mode 100644 index 00000000..c9e79071 --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticSeverity.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Diagnostics +{ + /// + /// Severity of a single validation diagnostic. + /// + public enum TypeValidationDiagnosticSeverity + { + /// + /// A problem that makes the package invalid. + /// + Error, + + /// + /// A tolerated concern that does not make the package invalid. + /// + Warning, + + /// + /// Informational output that is suppressed by default. + /// + Info, + } +} diff --git a/src/Bicep.Types.Validation/Graph/PackageDocumentProvider.cs b/src/Bicep.Types.Validation/Graph/PackageDocumentProvider.cs new file mode 100644 index 00000000..c8092eef --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/PackageDocumentProvider.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Structural; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// Loads type files on demand for graph traversal. Each file is read, parsed, structurally + /// validated, and turned into graph nodes exactly once; subsequent lookups are served from a + /// cache. All diagnostics produced during loading are accumulated in . + /// + internal sealed class PackageDocumentProvider + { + private readonly IPackageFileSystem fileSystem; + private readonly PackageDocument indexDocument; + private readonly string indexPath; + private readonly TypePackageValidationOptions options; + private readonly Dictionary cache = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly List loadDiagnostics = new List(); + + public PackageDocumentProvider( + IPackageFileSystem fileSystem, + PackageDocument indexDocument, + TypePackageValidationOptions options) + { + this.fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + this.indexDocument = indexDocument ?? throw new ArgumentNullException(nameof(indexDocument)); + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.indexPath = DirectoryPackageFileSystem.NormalizeSeparators(indexDocument.PackageRelativePath); + } + + /// All diagnostics accumulated across loaded type files. + public IReadOnlyList LoadDiagnostics => loadDiagnostics; + + /// + /// Returns the type files that graph traversal loaded and that parsed into a usable + /// type-file array. Policy validation inspects every structurally usable element of + /// these reached files (including elements at unreferenced indices), which is why this + /// exposes the whole reached file rather than only graph-visited nodes. The index + /// document (served for same-file references) is excluded because its root is an object. + /// + public IEnumerable GetReachedUsableTypeFiles() + { + foreach (var result in cache.Values) + { + if (result.IsStructurallyUsable && + result.Document != null && + result.Document.Kind == PackageDocumentKind.TypeFile) + { + yield return result; + } + } + } + + /// + /// Returns the package-relative paths reached by graph traversal: index.json plus every + /// type file that was loaded and exists on the package file system (whether or not it parsed + /// into a usable type-file array). Strict package hygiene uses this to distinguish reachable + /// files from unreachable ones. The comparer matches the provider cache so casing and leading + /// prefixes are treated consistently. + /// + public HashSet GetReachedFilePaths() + { + var reached = new HashSet(StringComparer.OrdinalIgnoreCase) { indexPath }; + foreach (var entry in cache) + { + if (entry.Value.Exists) + { + reached.Add(entry.Key); + } + } + return reached; + } + + /// Loads (or returns the cached result for) a type file by package-relative path. + public PackageDocumentProviderResult GetTypeFile(string packageRelativePath) + { + if (packageRelativePath == null) { throw new ArgumentNullException(nameof(packageRelativePath)); } + + string key = DirectoryPackageFileSystem.NormalizeSeparators(packageRelativePath); + if (cache.TryGetValue(key, out var cached)) + { + return cached; + } + + var result = Load(key); + cache[key] = result; + if (result.Diagnostics.Count > 0) + { + loadDiagnostics.AddRange(result.Diagnostics); + } + return result; + } + + private PackageDocumentProviderResult Load(string path) + { + // A reference that resolves to index.json (e.g. a same-file ref inside index) is + // served from the already-parsed index document. Its root is an object, so it is + // never a usable type-file array. + if (string.Equals(path, indexPath, StringComparison.OrdinalIgnoreCase)) + { + var indexNodes = TypeGraphBuilder.BuildNodes(indexDocument); + return PackageDocumentProviderResult.Loaded(indexDocument, indexNodes != null, indexNodes, Array.Empty()); + } + + if (!fileSystem.FileExists(path)) + { + return PackageDocumentProviderResult.Missing(); + } + + if (!fileSystem.TryReadAllBytes(path, out byte[] bytes, out string error)) + { + return PackageDocumentProviderResult.ReadFailed(error); + } + + if (!SourceMap.TryParse(bytes, path, out JsonValueNode? root, out SourceMap sourceMap, out var parseError)) + { + var err = parseError!.Value; + var diagnostic = TypeValidationDiagnosticBuilder.JsonSyntaxInvalid(path, err.line, err.column, err.message); + return PackageDocumentProviderResult.ParseFailed(new[] { diagnostic }); + } + + var document = new PackageDocument(path, PackageDocumentKind.TypeFile, root!, sourceMap); + + // Structurally validate the type file exactly once, and build its graph nodes. + var structuralDiagnostics = StructuralValidator.ValidateTypeFileDocument(document, options); + var nodes = TypeGraphBuilder.BuildNodes(document); + bool usable = nodes != null; // usable iff root is a type-file array + + return PackageDocumentProviderResult.Loaded(document, usable, nodes, structuralDiagnostics); + } + } +} diff --git a/src/Bicep.Types.Validation/Graph/PackageDocumentProviderResult.cs b/src/Bicep.Types.Validation/Graph/PackageDocumentProviderResult.cs new file mode 100644 index 00000000..17669515 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/PackageDocumentProviderResult.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// Outcome of a package document provider lookup for one type file. + /// + internal sealed class PackageDocumentProviderResult + { + private static readonly IReadOnlyList NoNodes = new TypeGraphNode?[0]; + private static readonly IReadOnlyList NoDiagnostics = new TypeValidationDiagnostic[0]; + + private PackageDocumentProviderResult( + bool exists, + bool isStructurallyUsable, + string? readError, + PackageDocument? document, + IReadOnlyList nodesByIndex, + IReadOnlyList diagnostics) + { + Exists = exists; + IsStructurallyUsable = isStructurallyUsable; + ReadError = readError; + Document = document; + NodesByIndex = nodesByIndex; + Diagnostics = diagnostics; + } + + /// true if the type file exists on disk. + public bool Exists { get; } + + /// true if the file parsed and its root is a type-file array. + public bool IsStructurallyUsable { get; } + + /// IO error message when the file exists but could not be read; otherwise null. + public string? ReadError { get; } + + /// The parsed document when available; otherwise null. + public PackageDocument? Document { get; } + + /// Index-aligned graph nodes; null entries mark non-usable elements. Empty when not usable. + public IReadOnlyList NodesByIndex { get; } + + /// Structural or parse diagnostics produced while loading this file. + public IReadOnlyList Diagnostics { get; } + + public static PackageDocumentProviderResult Missing() => + new PackageDocumentProviderResult(false, false, null, null, NoNodes, NoDiagnostics); + + public static PackageDocumentProviderResult ReadFailed(string readError) => + new PackageDocumentProviderResult(true, false, readError, null, NoNodes, NoDiagnostics); + + public static PackageDocumentProviderResult ParseFailed(IReadOnlyList diagnostics) => + new PackageDocumentProviderResult(true, false, null, null, NoNodes, diagnostics); + + public static PackageDocumentProviderResult Loaded( + PackageDocument document, + bool isStructurallyUsable, + IReadOnlyList? nodesByIndex, + IReadOnlyList diagnostics) => + new PackageDocumentProviderResult(true, isStructurallyUsable, null, document, nodesByIndex ?? NoNodes, diagnostics); + } +} diff --git a/src/Bicep.Types.Validation/Graph/ParsedTypeReference.cs b/src/Bicep.Types.Validation/Graph/ParsedTypeReference.cs new file mode 100644 index 00000000..4d2654cf --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/ParsedTypeReference.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// A structurally valid reference to a type object, parsed from a {"$ref": "..."} + /// object. Carries both the target (path + index) and the source location of the + /// $ref string so graph diagnostics can point back at the referencing site. + /// + internal sealed class ParsedTypeReference + { + public ParsedTypeReference( + string rawText, + string packageRelativePath, + int index, + string sourcePackageRelativePath, + string sourceJsonPointer, + SourceLocation location) + { + RawText = rawText ?? throw new ArgumentNullException(nameof(rawText)); + PackageRelativePath = packageRelativePath ?? throw new ArgumentNullException(nameof(packageRelativePath)); + Index = index; + SourcePackageRelativePath = sourcePackageRelativePath ?? throw new ArgumentNullException(nameof(sourcePackageRelativePath)); + SourceJsonPointer = sourceJsonPointer ?? throw new ArgumentNullException(nameof(sourceJsonPointer)); + Location = location; + } + + /// The raw $ref string value, e.g. types.json#/3. + public string RawText { get; } + + /// Target file package-relative path; empty string for a same-file reference. + public string PackageRelativePath { get; } + + /// Target type-object index within the target file. + public int Index { get; } + + /// Package-relative path of the file that contains this reference. + public string SourcePackageRelativePath { get; } + + /// JSON pointer of the $ref string within the source file. + public string SourceJsonPointer { get; } + + /// Source location of the $ref string. + public SourceLocation Location { get; } + + /// + /// Effective target path used for resolution: the source path for same-file + /// references, otherwise the explicit package path. + /// + public string EffectiveTargetPath => + PackageRelativePath.Length == 0 ? SourcePackageRelativePath : PackageRelativePath; + } +} diff --git a/src/Bicep.Types.Validation/Graph/SemanticGraphValidator.cs b/src/Bicep.Types.Validation/Graph/SemanticGraphValidator.cs new file mode 100644 index 00000000..74a794bb --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/SemanticGraphValidator.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// Validates the package's semantic reference graph: resolves every reachable reference, + /// reports unresolvable or mis-targeted references, and traverses the reachable closure of + /// type objects exactly once. Traversal is iterative (explicit stack) so deeply nested or + /// cyclic graphs cannot overflow the call stack. + /// + internal static class SemanticGraphValidator + { + public static IReadOnlyList Validate( + PackageDocumentProvider provider, + PackageDocument indexDocument, + TypePackageValidationOptions options) => + Validate(provider, indexDocument, options, out _); + + /// + /// Validates the reference graph and also returns the set of node ids reached from + /// index.json roots, so strict package hygiene can avoid re-walking the root closure. + /// + public static IReadOnlyList Validate( + PackageDocumentProvider provider, + PackageDocument indexDocument, + TypePackageValidationOptions options, + out HashSet visited) + { + if (provider == null) { throw new ArgumentNullException(nameof(provider)); } + if (indexDocument == null) { throw new ArgumentNullException(nameof(indexDocument)); } + if (options == null) { throw new ArgumentNullException(nameof(options)); } + + var diagnostics = new List(); + var resolver = new TypeReferenceResolver(provider); + visited = new HashSet(); + var stack = new Stack(); + + // Seed traversal from index.json roots. + foreach (var root in TypeGraphBuilder.ExtractRoots(indexDocument)) + { + var resolution = resolver.Resolve(root.Reference); + var target = HandleResolution(root.Reference, root.Role, root.Description, resolution, diagnostics); + if (target != null && visited.Add(target.Id)) + { + stack.Push(target); + } + } + + Traverse(resolver, stack, visited, diagnostics); + + // Include structural/parse diagnostics accumulated while loading type files. + diagnostics.AddRange(provider.LoadDiagnostics); + return diagnostics; + } + + /// + /// Validates reference edges reachable from a set of seed nodes that were not reached from + /// index.json roots (strict package hygiene). The set is + /// shared with the root traversal so nodes already validated are not re-checked, keeping + /// diagnostics duplicate-free. Only edge-resolution diagnostics are returned; structural and + /// parse diagnostics for newly loaded files are surfaced by the caller via load-diagnostic + /// deltas. + /// + public static IReadOnlyList ValidateUnreachableSeeds( + PackageDocumentProvider provider, + IEnumerable seedNodes, + HashSet visited) + { + if (provider == null) { throw new ArgumentNullException(nameof(provider)); } + if (seedNodes == null) { throw new ArgumentNullException(nameof(seedNodes)); } + if (visited == null) { throw new ArgumentNullException(nameof(visited)); } + + var diagnostics = new List(); + var resolver = new TypeReferenceResolver(provider); + var stack = new Stack(); + + foreach (var seed in seedNodes) + { + if (seed != null && visited.Add(seed.Id)) + { + stack.Push(seed); + } + } + + Traverse(resolver, stack, visited, diagnostics); + return diagnostics; + } + + /// + /// Depth-first traversal of the reachable closure. A node is enqueued once (visited set), + /// but every edge that targets it still gets an independent kind check. + /// + private static void Traverse( + TypeReferenceResolver resolver, + Stack stack, + HashSet visited, + List diagnostics) + { + while (stack.Count > 0) + { + var node = stack.Pop(); + foreach (var edge in TypeGraphBuilder.ExtractEdges(node)) + { + var resolution = resolver.Resolve(edge.Reference); + var target = HandleResolution(edge.Reference, edge.Role, rootDescription: null, resolution, diagnostics); + if (target != null && visited.Add(target.Id)) + { + stack.Push(target); + } + } + } + } + + /// + /// Emits a diagnostic for a non-resolving or mis-targeted reference and returns the resolved + /// target node (for traversal) when the reference resolved to a usable type object. + /// + private static TypeGraphNode? HandleResolution( + ParsedTypeReference reference, + TypeReferenceRole role, + string? rootDescription, + TypeReferenceResolution resolution, + List diagnostics) + { + int line = reference.Location.Line; + int column = reference.Location.Column; + + switch (resolution.Outcome) + { + case TypeReferenceResolutionOutcome.MissingFile: + diagnostics.Add(TypeValidationDiagnosticBuilder.ReferencedTypeFileMissing( + reference.SourcePackageRelativePath, reference.SourceJsonPointer, resolution.TargetPath, line, column)); + return null; + + case TypeReferenceResolutionOutcome.FileReadFailed: + diagnostics.Add(TypeValidationDiagnosticBuilder.ReferencedTypeFileReadFailed( + reference.SourcePackageRelativePath, reference.SourceJsonPointer, resolution.TargetPath, + resolution.ReadError ?? string.Empty, line, column)); + return null; + + case TypeReferenceResolutionOutcome.FileUnusable: + diagnostics.Add(TypeValidationDiagnosticBuilder.ReferencedTypeFileUnusable( + reference.SourcePackageRelativePath, reference.SourceJsonPointer, resolution.TargetPath, line, column)); + return null; + + case TypeReferenceResolutionOutcome.IndexOutOfRange: + diagnostics.Add(TypeValidationDiagnosticBuilder.ReferenceIndexOutOfRange( + reference.SourcePackageRelativePath, reference.SourceJsonPointer, resolution.TargetPath, + reference.Index, resolution.TargetElementCount, line, column)); + return null; + + case TypeReferenceResolutionOutcome.TargetNotTypeObject: + // The structural layer already reported that this element is not a usable + // type object; no additional graph diagnostic is emitted. + return null; + + case TypeReferenceResolutionOutcome.Resolved: + var node = resolution.TargetNode!; + if (!TypeTargetKindValidator.IsAllowed(role, node.Discriminator)) + { + var related = new[] + { + new TypeValidationDiagnosticRelatedLocation( + "Target type is declared here.", + node.Id.PackageRelativePath, + node.JsonPointer, + node.Location.Line, + node.Location.Column), + }; + + string expected = TypeTargetKindValidator.ExpectedText(role); + string actual = $"'{node.Discriminator}'"; + + if (TypeTargetKindValidator.IsTopLevel(role)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.TopLevelTargetKindMismatch( + reference.SourcePackageRelativePath, reference.SourceJsonPointer, + rootDescription ?? "This entry", expected, actual, line, column, related)); + } + else + { + diagnostics.Add(TypeValidationDiagnosticBuilder.NestedTargetKindMismatch( + reference.SourcePackageRelativePath, reference.SourceJsonPointer, + RoleDescription(role), expected, actual, line, column, related)); + } + + // Recovery: a wrong-kind target is not traversed. A wrong root kind + // continues to the next root; a wrong nested kind does not descend + // through this edge. This prevents follow-on diagnostics rooted at a + // target whose kind is already known to be invalid. + return null; + } + return node; + + default: + return null; + } + } + + private static string RoleDescription(TypeReferenceRole role) + { + switch (role) + { + case TypeReferenceRole.ResourceBody: return "resource body"; + case TypeReferenceRole.ObjectPropertyType: return "object property type"; + case TypeReferenceRole.AdditionalProperties: return "additional properties type"; + case TypeReferenceRole.ArrayItem: return "array item type"; + case TypeReferenceRole.UnionMember: return "union member"; + case TypeReferenceRole.FunctionParameter: return "function parameter type"; + case TypeReferenceRole.FunctionOutput: return "function output type"; + case TypeReferenceRole.ResourceFunctionInput: return "resource function input type"; + case TypeReferenceRole.ResourceFunctionOutput: return "resource function output type"; + case TypeReferenceRole.NamespaceFunctionParameter: return "namespace function parameter type"; + case TypeReferenceRole.NamespaceFunctionOutput: return "namespace function output type"; + case TypeReferenceRole.ResourceTypeFunction: return "resource type function"; + case TypeReferenceRole.DiscriminatedObjectElement: return "discriminated object element"; + default: return "reference"; + } + } + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeGraphBuilder.cs b/src/Bicep.Types.Validation/Graph/TypeGraphBuilder.cs new file mode 100644 index 00000000..ff1564c2 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeGraphBuilder.cs @@ -0,0 +1,393 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Structural; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// Extracts graph roots (from index.json), graph nodes (type objects), and graph + /// edges (nested references) from already-parsed package documents. Only well-formed, + /// structurally usable references are surfaced; malformed references are silently skipped + /// because they are already reported by the structural layer. + /// + internal static class TypeGraphBuilder + { + // ── Roots ──────────────────────────────────────────────────────────────── + + /// Extracts the graph roots referenced by index.json. + public static IReadOnlyList ExtractRoots(PackageDocument indexDocument) + { + if (indexDocument == null) { throw new ArgumentNullException(nameof(indexDocument)); } + + var roots = new List(); + var root = indexDocument.Root; + if (root.Kind != JsonValueKind.Object) + { + return roots; + } + + // resources: name -> ref + if (root.TryGetProperty("resources", out var resources) && resources.Kind == JsonValueKind.Object) + { + foreach (var entry in resources.Properties) + { + string pointer = "/resources/" + JsonPointerEscape(entry.Name); + if (TryParseReferenceObject(entry.Value, indexDocument, pointer, out var reference)) + { + roots.Add(new TypeGraphRoot( + TypeGraphRootKind.Resource, + $"Resource entry '{entry.Name}'", + TypeReferenceRole.ResourceRoot, + reference)); + } + } + } + + // resourceFunctions: resourceType -> apiVersion -> [ref, ...] + if (root.TryGetProperty("resourceFunctions", out var resourceFunctions) && resourceFunctions.Kind == JsonValueKind.Object) + { + foreach (var rtEntry in resourceFunctions.Properties) + { + if (rtEntry.Value.Kind != JsonValueKind.Object) { continue; } + string rtPointer = "/resourceFunctions/" + JsonPointerEscape(rtEntry.Name); + + foreach (var avEntry in rtEntry.Value.Properties) + { + if (avEntry.Value.Kind != JsonValueKind.Array) { continue; } + string avPointer = rtPointer + "/" + JsonPointerEscape(avEntry.Name); + + for (int i = 0; i < avEntry.Value.Elements.Count; i++) + { + string pointer = avPointer + "/" + i; + if (TryParseReferenceObject(avEntry.Value.Elements[i], indexDocument, pointer, out var reference)) + { + roots.Add(new TypeGraphRoot( + TypeGraphRootKind.ResourceFunction, + $"Resource function '{rtEntry.Name}@{avEntry.Name}[{i}]'", + TypeReferenceRole.ResourceFunctionRoot, + reference)); + } + } + } + } + } + + // namespaceFunctions: [ref, ...] + if (root.TryGetProperty("namespaceFunctions", out var namespaceFunctions) && namespaceFunctions.Kind == JsonValueKind.Array) + { + for (int i = 0; i < namespaceFunctions.Elements.Count; i++) + { + string pointer = "/namespaceFunctions/" + i; + if (TryParseReferenceObject(namespaceFunctions.Elements[i], indexDocument, pointer, out var reference)) + { + roots.Add(new TypeGraphRoot( + TypeGraphRootKind.NamespaceFunction, + $"Namespace function [{i}]", + TypeReferenceRole.NamespaceFunctionRoot, + reference)); + } + } + } + + // settings.configurationType: ref + if (root.TryGetProperty("settings", out var settings) && settings.Kind == JsonValueKind.Object && + settings.TryGetProperty("configurationType", out var configurationType)) + { + if (TryParseReferenceObject(configurationType, indexDocument, "/settings/configurationType", out var reference)) + { + roots.Add(new TypeGraphRoot( + TypeGraphRootKind.ConfigurationType, + "Settings configurationType", + TypeReferenceRole.ConfigurationType, + reference)); + } + } + + // fallbackResourceType: ref + if (root.TryGetProperty("fallbackResourceType", out var fallback)) + { + if (TryParseReferenceObject(fallback, indexDocument, "/fallbackResourceType", out var reference)) + { + roots.Add(new TypeGraphRoot( + TypeGraphRootKind.FallbackResourceType, + "Fallback resource type", + TypeReferenceRole.FallbackResourceType, + reference)); + } + } + + return roots; + } + + // ── Nodes ──────────────────────────────────────────────────────────────── + + /// + /// Builds an index-aligned list of graph nodes for a type file. Elements that are not + /// structurally usable type objects (non-object, missing/non-string $type, or an + /// unsupported discriminator) are represented as null. Returns null if the + /// file root is not an array. + /// + public static IReadOnlyList? BuildNodes(PackageDocument typeFile) + { + if (typeFile == null) { throw new ArgumentNullException(nameof(typeFile)); } + + var root = typeFile.Root; + if (root.Kind != JsonValueKind.Array) + { + return null; + } + + var nodes = new List(root.Elements.Count); + for (int i = 0; i < root.Elements.Count; i++) + { + nodes.Add(BuildNode(typeFile, root.Elements[i], i)); + } + return nodes; + } + + private static TypeGraphNode? BuildNode(PackageDocument typeFile, JsonValueNode element, int index) + { + if (element.Kind != JsonValueKind.Object) + { + return null; + } + + if (!element.TryGetProperty("$type", out var typeNode) || typeNode.Kind != JsonValueKind.String) + { + return null; + } + + string discriminator = typeNode.StringValue ?? string.Empty; + if (TypeShapeCatalog.GetDescriptor(discriminator) == null) + { + return null; + } + + var id = new TypeNodeId(typeFile.PackageRelativePath, index); + var location = typeFile.SourceMap.GetLocation(element.ByteOffset); + return new TypeGraphNode(id, discriminator, element, typeFile, "/" + index, location); + } + + // ── Edges ──────────────────────────────────────────────────────────────── + + /// Extracts the outgoing reference edges from a graph node. + public static IReadOnlyList ExtractEdges(TypeGraphNode node) + { + if (node == null) { throw new ArgumentNullException(nameof(node)); } + + var edges = new List(); + var doc = node.Document; + var obj = node.ObjectNode; + string basePointer = node.JsonPointer; + + switch (node.Discriminator) + { + case "ResourceType": + AddEdge(edges, node, doc, obj, "body", basePointer, TypeReferenceRole.ResourceBody); + AddMapMemberEdges(edges, node, doc, obj, "functions", basePointer, TypeReferenceRole.ResourceTypeFunction, memberValueRefProperty: "type"); + break; + + case "ObjectType": + AddMapMemberEdges(edges, node, doc, obj, "properties", basePointer, TypeReferenceRole.ObjectPropertyType, memberValueRefProperty: "type"); + AddEdge(edges, node, doc, obj, "additionalProperties", basePointer, TypeReferenceRole.AdditionalProperties); + break; + + case "DiscriminatedObjectType": + AddMapMemberEdges(edges, node, doc, obj, "baseProperties", basePointer, TypeReferenceRole.ObjectPropertyType, memberValueRefProperty: "type"); + AddMapMemberEdges(edges, node, doc, obj, "elements", basePointer, TypeReferenceRole.DiscriminatedObjectElement, memberValueRefProperty: null); + break; + + case "ArrayType": + AddEdge(edges, node, doc, obj, "itemType", basePointer, TypeReferenceRole.ArrayItem); + break; + + case "UnionType": + AddArrayMemberEdges(edges, node, doc, obj, "elements", basePointer, TypeReferenceRole.UnionMember); + break; + + case "FunctionType": + AddParameterEdges(edges, node, doc, obj, "parameters", basePointer, TypeReferenceRole.FunctionParameter); + AddEdge(edges, node, doc, obj, "output", basePointer, TypeReferenceRole.FunctionOutput); + break; + + case "ResourceFunctionType": + AddEdge(edges, node, doc, obj, "input", basePointer, TypeReferenceRole.ResourceFunctionInput); + AddEdge(edges, node, doc, obj, "output", basePointer, TypeReferenceRole.ResourceFunctionOutput); + break; + + case "NamespaceFunctionType": + AddParameterEdges(edges, node, doc, obj, "parameters", basePointer, TypeReferenceRole.NamespaceFunctionParameter); + AddEdge(edges, node, doc, obj, "outputType", basePointer, TypeReferenceRole.NamespaceFunctionOutput); + break; + + default: + // Value-only kinds (AnyType, NullType, BooleanType, IntegerType, StringType, + // StringLiteralType, BuiltInType) have no outgoing references. + break; + } + + return edges; + } + + /// Adds a single edge for a direct reference-valued field, if present and well-formed. + private static void AddEdge( + List edges, TypeGraphNode node, PackageDocument doc, JsonValueNode obj, + string fieldName, string basePointer, TypeReferenceRole role) + { + if (!obj.TryGetProperty(fieldName, out var value)) { return; } + string pointer = basePointer + "/" + fieldName; + if (TryParseReferenceObject(value, doc, pointer, out var reference)) + { + edges.Add(new TypeGraphEdge(node.Id, role, reference, fieldName)); + } + } + + /// + /// Adds edges for an object-map field whose members either are references directly + /// ( is null) or contain a nested + /// reference under the given property (e.g. type). + /// + private static void AddMapMemberEdges( + List edges, TypeGraphNode node, PackageDocument doc, JsonValueNode obj, + string fieldName, string basePointer, TypeReferenceRole role, string? memberValueRefProperty) + { + if (!obj.TryGetProperty(fieldName, out var map) || map.Kind != JsonValueKind.Object) { return; } + string mapPointer = basePointer + "/" + fieldName; + + foreach (var member in map.Properties) + { + string memberPointer = mapPointer + "/" + JsonPointerEscape(member.Name); + + if (memberValueRefProperty == null) + { + if (TryParseReferenceObject(member.Value, doc, memberPointer, out var reference)) + { + edges.Add(new TypeGraphEdge(node.Id, role, reference, member.Name)); + } + continue; + } + + if (member.Value.Kind != JsonValueKind.Object) { continue; } + if (!member.Value.TryGetProperty(memberValueRefProperty, out var refValue)) { continue; } + string refPointer = memberPointer + "/" + memberValueRefProperty; + if (TryParseReferenceObject(refValue, doc, refPointer, out var typeReference)) + { + edges.Add(new TypeGraphEdge(node.Id, role, typeReference, member.Name)); + } + } + } + + /// Adds edges for an array-of-references field. + private static void AddArrayMemberEdges( + List edges, TypeGraphNode node, PackageDocument doc, JsonValueNode obj, + string fieldName, string basePointer, TypeReferenceRole role) + { + if (!obj.TryGetProperty(fieldName, out var array) || array.Kind != JsonValueKind.Array) { return; } + string arrayPointer = basePointer + "/" + fieldName; + + for (int i = 0; i < array.Elements.Count; i++) + { + string pointer = arrayPointer + "/" + i; + if (TryParseReferenceObject(array.Elements[i], doc, pointer, out var reference)) + { + edges.Add(new TypeGraphEdge(node.Id, role, reference, "[" + i + "]")); + } + } + } + + /// Adds edges for an array-of-parameter-objects field, following each element's type. + private static void AddParameterEdges( + List edges, TypeGraphNode node, PackageDocument doc, JsonValueNode obj, + string fieldName, string basePointer, TypeReferenceRole role) + { + if (!obj.TryGetProperty(fieldName, out var array) || array.Kind != JsonValueKind.Array) { return; } + string arrayPointer = basePointer + "/" + fieldName; + + for (int i = 0; i < array.Elements.Count; i++) + { + var element = array.Elements[i]; + if (element.Kind != JsonValueKind.Object) { continue; } + if (!element.TryGetProperty("type", out var refValue)) { continue; } + string pointer = arrayPointer + "/" + i + "/type"; + if (TryParseReferenceObject(refValue, doc, pointer, out var reference)) + { + edges.Add(new TypeGraphEdge(node.Id, role, reference, "[" + i + "]")); + } + } + } + + // ── Reference parsing ──────────────────────────────────────────────────── + + /// + /// Attempts to parse a {"$ref": "..."} object into a . + /// Returns false for anything that is not a well-formed, safe reference; such cases + /// are already reported by the structural layer. + /// + private static bool TryParseReferenceObject( + JsonValueNode node, PackageDocument sourceDocument, string refObjectPointer, out ParsedTypeReference reference) + { + reference = null!; + + if (node == null || node.Kind != JsonValueKind.Object) { return false; } + if (!node.TryGetProperty("$ref", out var refNode) || refNode.Kind != JsonValueKind.String) { return false; } + + // A reference object must be canonical: exactly the '$ref' property and nothing + // else. Extra properties are already reported by the structural layer (BCPVT014); + // skipping such references here honors the recovery rule that malformed references + // do not produce follow-on graph diagnostics. + foreach (var property in node.Properties) + { + if (!string.Equals(property.Name, "$ref", StringComparison.Ordinal)) { return false; } + } + + string rawText = refNode.StringValue ?? string.Empty; + if (!ReferencePath.TryParse(rawText, out string packagePath, out int index)) { return false; } + if (IsUnsafePackagePath(packagePath)) { return false; } + + string normalizedPath = packagePath.Length == 0 + ? string.Empty + : DirectoryPackageFileSystem.NormalizeSeparators(packagePath); + + string sourcePointer = refObjectPointer + "/$ref"; + var loc = sourceDocument.SourceMap.GetLocation(refNode.ByteOffset); + + reference = new ParsedTypeReference( + rawText, + normalizedPath, + index, + sourceDocument.PackageRelativePath, + sourcePointer, + loc); + return true; + } + + /// + /// Mirrors the structural layer's safe-path check: rejects rooted paths and any + /// .. traversal segment so graph resolution never escapes the package root. + /// + private static bool IsUnsafePackagePath(string packagePath) + { + if (string.IsNullOrEmpty(packagePath)) { return false; } + + string normalized = packagePath.Replace('\\', '/'); + + if (normalized.StartsWith("/", StringComparison.Ordinal)) { return true; } + if (normalized.Length >= 2 && normalized[1] == ':') { return true; } + + foreach (string segment in normalized.Split('/')) + { + if (segment == "..") { return true; } + } + return false; + } + + private static string JsonPointerEscape(string token) + { + return token.Replace("~", "~0").Replace("/", "~1"); + } + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeGraphEdge.cs b/src/Bicep.Types.Validation/Graph/TypeGraphEdge.cs new file mode 100644 index 00000000..d8905bfa --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeGraphEdge.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// One outgoing reference from a type object field. + /// + internal sealed class TypeGraphEdge + { + public TypeGraphEdge( + TypeNodeId sourceNodeId, + TypeReferenceRole role, + ParsedTypeReference reference, + string? memberName = null) + { + SourceNodeId = sourceNodeId; + Role = role; + Reference = reference ?? throw new ArgumentNullException(nameof(reference)); + MemberName = memberName; + } + + /// Identity of the type object that owns this reference. + public TypeNodeId SourceNodeId { get; } + + /// The role used to validate the target kind. + public TypeReferenceRole Role { get; } + + /// The parsed reference. + public ParsedTypeReference Reference { get; } + + /// Optional member name (property, variant, or parameter) for context. + public string? MemberName { get; } + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeGraphNode.cs b/src/Bicep.Types.Validation/Graph/TypeGraphNode.cs new file mode 100644 index 00000000..5a32e6f1 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeGraphNode.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// One structurally usable type object in the package graph. + /// + internal sealed class TypeGraphNode + { + public TypeGraphNode( + TypeNodeId id, + string discriminator, + JsonValueNode objectNode, + PackageDocument document, + string jsonPointer, + SourceLocation location) + { + Id = id; + Discriminator = discriminator ?? throw new ArgumentNullException(nameof(discriminator)); + ObjectNode = objectNode ?? throw new ArgumentNullException(nameof(objectNode)); + Document = document ?? throw new ArgumentNullException(nameof(document)); + JsonPointer = jsonPointer ?? throw new ArgumentNullException(nameof(jsonPointer)); + Location = location; + } + + /// Stable identity of this type object. + public TypeNodeId Id { get; } + + /// The $type discriminator, e.g. ObjectType. + public string Discriminator { get; } + + /// The JSON object node backing this type object. + public JsonValueNode ObjectNode { get; } + + /// The document that contains this type object. + public PackageDocument Document { get; } + + /// JSON pointer of this type object within its file (e.g. /0). + public string JsonPointer { get; } + + /// Source location of this type object. + public SourceLocation Location { get; } + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeGraphRoot.cs b/src/Bicep.Types.Validation/Graph/TypeGraphRoot.cs new file mode 100644 index 00000000..d435aaba --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeGraphRoot.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// One root reference discovered from index.json. + /// + internal sealed class TypeGraphRoot + { + public TypeGraphRoot( + TypeGraphRootKind kind, + string description, + TypeReferenceRole role, + ParsedTypeReference reference) + { + Kind = kind; + Description = description ?? throw new ArgumentNullException(nameof(description)); + Role = role; + Reference = reference ?? throw new ArgumentNullException(nameof(reference)); + } + + /// The kind of root. + public TypeGraphRootKind Kind { get; } + + /// Human-readable description used in diagnostics, e.g. Resource entry 'Foo/bar@v1'. + public string Description { get; } + + /// The role used to validate the target kind. + public TypeReferenceRole Role { get; } + + /// The parsed reference to the root type object. + public ParsedTypeReference Reference { get; } + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeGraphRootKind.cs b/src/Bicep.Types.Validation/Graph/TypeGraphRootKind.cs new file mode 100644 index 00000000..17999029 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeGraphRootKind.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// Kind of a graph root reference discovered from index.json. + /// + internal enum TypeGraphRootKind + { + Resource, + ResourceFunction, + NamespaceFunction, + ConfigurationType, + FallbackResourceType, + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeNodeId.cs b/src/Bicep.Types.Validation/Graph/TypeNodeId.cs new file mode 100644 index 00000000..ec913001 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeNodeId.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// Stable identity for a type object: its package-relative file path and its index + /// within that type file's array. Paths are compared case-insensitively to match the + /// package file-system and JsonDocumentSet behavior, so references that differ + /// only by path case identify the same node. + /// + internal readonly struct TypeNodeId : IEquatable + { + public TypeNodeId(string packageRelativePath, int index) + { + PackageRelativePath = packageRelativePath ?? throw new ArgumentNullException(nameof(packageRelativePath)); + Index = index; + } + + /// Package-relative path of the type file (forward-slash normalized). + public string PackageRelativePath { get; } + + /// 0-based index of the type object within the type file's array. + public int Index { get; } + + public bool Equals(TypeNodeId other) => + Index == other.Index && + string.Equals(PackageRelativePath, other.PackageRelativePath, StringComparison.OrdinalIgnoreCase); + + public override bool Equals(object? obj) => obj is TypeNodeId other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = (hash * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(PackageRelativePath); + hash = (hash * 31) + Index; + return hash; + } + } + + public override string ToString() => $"{PackageRelativePath}#/{Index}"; + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeReferenceResolver.cs b/src/Bicep.Types.Validation/Graph/TypeReferenceResolver.cs new file mode 100644 index 00000000..91daeca1 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeReferenceResolver.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// Discriminated outcome of resolving a . + internal enum TypeReferenceResolutionOutcome + { + /// The reference resolved to a usable type object. + Resolved, + + /// The target type file does not exist. + MissingFile, + + /// The target type file exists but could not be read. + FileReadFailed, + + /// The target type file could not be parsed or is not a type-file array. + FileUnusable, + + /// The referenced index is out of range for the target file. + IndexOutOfRange, + + /// The referenced element exists but is not a usable type object. + TargetNotTypeObject, + } + + /// The result of resolving a reference, including the target when available. + internal sealed class TypeReferenceResolution + { + private TypeReferenceResolution( + TypeReferenceResolutionOutcome outcome, + string targetPath, + TypeGraphNode? targetNode, + int targetElementCount, + string? readError) + { + Outcome = outcome; + TargetPath = targetPath; + TargetNode = targetNode; + TargetElementCount = targetElementCount; + ReadError = readError; + } + + public TypeReferenceResolutionOutcome Outcome { get; } + + /// Effective target package-relative path used for resolution. + public string TargetPath { get; } + + /// The resolved target node when is . + public TypeGraphNode? TargetNode { get; } + + /// Number of type-object slots in the target file (for out-of-range messaging). + public int TargetElementCount { get; } + + /// IO error text when is . + public string? ReadError { get; } + + public static TypeReferenceResolution Resolved(string targetPath, TypeGraphNode node, int count) => + new TypeReferenceResolution(TypeReferenceResolutionOutcome.Resolved, targetPath, node, count, null); + + public static TypeReferenceResolution Missing(string targetPath) => + new TypeReferenceResolution(TypeReferenceResolutionOutcome.MissingFile, targetPath, null, 0, null); + + public static TypeReferenceResolution ReadFailed(string targetPath, string readError) => + new TypeReferenceResolution(TypeReferenceResolutionOutcome.FileReadFailed, targetPath, null, 0, readError); + + public static TypeReferenceResolution Unusable(string targetPath) => + new TypeReferenceResolution(TypeReferenceResolutionOutcome.FileUnusable, targetPath, null, 0, null); + + public static TypeReferenceResolution OutOfRange(string targetPath, int count) => + new TypeReferenceResolution(TypeReferenceResolutionOutcome.IndexOutOfRange, targetPath, null, count, null); + + public static TypeReferenceResolution NotTypeObject(string targetPath, int count) => + new TypeReferenceResolution(TypeReferenceResolutionOutcome.TargetNotTypeObject, targetPath, null, count, null); + } + + /// + /// Resolves parsed references to target nodes using a . + /// Pure with respect to diagnostics: the caller decides how to report each outcome, since + /// target-kind expectations depend on the referencing role. + /// + internal sealed class TypeReferenceResolver + { + private readonly PackageDocumentProvider provider; + + public TypeReferenceResolver(PackageDocumentProvider provider) + { + this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public TypeReferenceResolution Resolve(ParsedTypeReference reference) + { + if (reference == null) { throw new ArgumentNullException(nameof(reference)); } + + string targetPath = reference.EffectiveTargetPath; + var file = provider.GetTypeFile(targetPath); + + if (!file.Exists) + { + return TypeReferenceResolution.Missing(targetPath); + } + + if (file.ReadError != null) + { + return TypeReferenceResolution.ReadFailed(targetPath, file.ReadError); + } + + if (!file.IsStructurallyUsable) + { + return TypeReferenceResolution.Unusable(targetPath); + } + + int count = file.NodesByIndex.Count; + if (reference.Index < 0 || reference.Index >= count) + { + return TypeReferenceResolution.OutOfRange(targetPath, count); + } + + var node = file.NodesByIndex[reference.Index]; + if (node == null) + { + return TypeReferenceResolution.NotTypeObject(targetPath, count); + } + + return TypeReferenceResolution.Resolved(targetPath, node, count); + } + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeReferenceRole.cs b/src/Bicep.Types.Validation/Graph/TypeReferenceRole.cs new file mode 100644 index 00000000..adb2dd76 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeReferenceRole.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// The role a reference plays in the package graph. The role determines which + /// target type-object kinds are allowed and whether a mismatch is reported as a + /// top-level or nested diagnostic. + /// + internal enum TypeReferenceRole + { + // Top-level roots from index.json + ResourceRoot, + ResourceFunctionRoot, + NamespaceFunctionRoot, + FallbackResourceType, + ConfigurationType, + + // Nested edges from type objects + ResourceBody, + ObjectPropertyType, + AdditionalProperties, + ArrayItem, + UnionMember, + FunctionParameter, + FunctionOutput, + ResourceFunctionInput, + ResourceFunctionOutput, + NamespaceFunctionParameter, + NamespaceFunctionOutput, + ResourceTypeFunction, + DiscriminatedObjectElement, + } +} diff --git a/src/Bicep.Types.Validation/Graph/TypeTargetKindValidator.cs b/src/Bicep.Types.Validation/Graph/TypeTargetKindValidator.cs new file mode 100644 index 00000000..7e6efbc6 --- /dev/null +++ b/src/Bicep.Types.Validation/Graph/TypeTargetKindValidator.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Graph +{ + /// + /// Encodes which target type-object kinds are valid for each reference role, and provides + /// human-readable expectation text for diagnostics. + /// + internal static class TypeTargetKindValidator + { + // Kinds usable as a "type value" (a property type, array item, union member, function + // parameter/output, etc.). Excludes the resource/function container kinds. + private static readonly HashSet ValueTypeKinds = new HashSet(System.StringComparer.Ordinal) + { + "AnyType", "NullType", "BooleanType", "IntegerType", "StringType", "StringLiteralType", + "ObjectType", "DiscriminatedObjectType", "ArrayType", "UnionType", "BuiltInType", + }; + + private static readonly HashSet ObjectLikeKinds = new HashSet(System.StringComparer.Ordinal) + { + "ObjectType", "DiscriminatedObjectType", + }; + + /// Returns true if roots of this role are validated as top-level (index.json) references. + public static bool IsTopLevel(TypeReferenceRole role) + { + switch (role) + { + case TypeReferenceRole.ResourceRoot: + case TypeReferenceRole.ResourceFunctionRoot: + case TypeReferenceRole.NamespaceFunctionRoot: + case TypeReferenceRole.FallbackResourceType: + case TypeReferenceRole.ConfigurationType: + return true; + default: + return false; + } + } + + /// Returns true if is an allowed target for . + public static bool IsAllowed(TypeReferenceRole role, string discriminator) + { + switch (role) + { + case TypeReferenceRole.ResourceRoot: + case TypeReferenceRole.FallbackResourceType: + return discriminator == "ResourceType"; + + case TypeReferenceRole.ResourceFunctionRoot: + return discriminator == "ResourceFunctionType"; + + case TypeReferenceRole.NamespaceFunctionRoot: + return discriminator == "NamespaceFunctionType"; + + case TypeReferenceRole.ConfigurationType: + case TypeReferenceRole.ResourceBody: + return ObjectLikeKinds.Contains(discriminator); + + case TypeReferenceRole.ResourceTypeFunction: + return discriminator == "FunctionType"; + + case TypeReferenceRole.DiscriminatedObjectElement: + return discriminator == "ObjectType"; + + default: + // All remaining roles are value-type positions. + return ValueTypeKinds.Contains(discriminator); + } + } + + /// Returns a human-readable description of the allowed target kinds for a role. + public static string ExpectedText(TypeReferenceRole role) + { + switch (role) + { + case TypeReferenceRole.ResourceRoot: + case TypeReferenceRole.FallbackResourceType: + return "a resource type ('ResourceType')"; + + case TypeReferenceRole.ResourceFunctionRoot: + return "a resource function type ('ResourceFunctionType')"; + + case TypeReferenceRole.NamespaceFunctionRoot: + return "a namespace function type ('NamespaceFunctionType')"; + + case TypeReferenceRole.ConfigurationType: + case TypeReferenceRole.ResourceBody: + return "an object type ('ObjectType' or 'DiscriminatedObjectType')"; + + case TypeReferenceRole.ResourceTypeFunction: + return "a function type ('FunctionType')"; + + case TypeReferenceRole.DiscriminatedObjectElement: + return "an object type ('ObjectType')"; + + default: + return "a value type"; + } + } + } +} diff --git a/src/Bicep.Types.Validation/Hygiene/PackageHygieneValidator.cs b/src/Bicep.Types.Validation/Hygiene/PackageHygieneValidator.cs new file mode 100644 index 00000000..9c392145 --- /dev/null +++ b/src/Bicep.Types.Validation/Hygiene/PackageHygieneValidator.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Policy; +using Azure.Bicep.Types.Validation.Semantic; + +namespace Azure.Bicep.Types.Validation.Hygiene +{ + /// + /// Strict package-hygiene validation, enabled only when + /// is set. Every package file + /// that is not reachable from index.json roots is reported, and unreachable JSON type files + /// are additionally validated for structural, graph, scalar, and policy defects so latent invalid + /// content is caught. The root-reachable closure is never re-validated, keeping diagnostics + /// duplicate-free. + /// + internal static class PackageHygieneValidator + { + public static IReadOnlyList Validate( + IPackageFileSystem fileSystem, + PackageDocumentProvider provider, + TypePackageValidationOptions options, + HashSet visited) + { + if (fileSystem == null) { throw new ArgumentNullException(nameof(fileSystem)); } + if (provider == null) { throw new ArgumentNullException(nameof(provider)); } + if (options == null) { throw new ArgumentNullException(nameof(options)); } + if (visited == null) { throw new ArgumentNullException(nameof(visited)); } + + var diagnostics = new List(); + + var reached = provider.GetReachedFilePaths(); + var files = fileSystem.EnumerateFiles() + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + + // Snapshot the provider's load diagnostics so only files newly loaded by strict hygiene + // contribute structural/parse diagnostics (added exactly once as a delta below). + int loadDiagnosticsBefore = provider.LoadDiagnostics.Count; + + var seeds = new List(); + var unreachableUsableFiles = new List(); + + foreach (var file in files) + { + if (reached.Contains(file)) + { + continue; + } + + if (!IsJsonFile(file)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.UnexpectedPackageFile(file)); + continue; + } + + diagnostics.Add(TypeValidationDiagnosticBuilder.UnreachablePackageFile(file)); + + // Load the unreachable JSON file through the provider so parsing, structural + // classification, diagnostics, and caching match reachable type-file loading. + var result = provider.GetTypeFile(file); + if (result.IsStructurallyUsable) + { + unreachableUsableFiles.Add(result); + foreach (var node in result.NodesByIndex) + { + if (node != null) + { + seeds.Add(node); + } + } + } + } + + // Validate reference edges from unreachable type objects. Sharing the root-closure + // visited set prevents re-checking already-validated nodes. Edges from unreachable files + // resolve through the same graph rules as reachable edges, so this may add BCPVT017, + // BCPVT018, BCPVT019, or target-kind diagnostics for latent invalid references. + diagnostics.AddRange(SemanticGraphValidator.ValidateUnreachableSeeds(provider, seeds, visited)); + + // Scalar and policy validation over only the newly loaded unreachable files. + diagnostics.AddRange(ScalarSemanticValidator.Validate(unreachableUsableFiles, options)); + diagnostics.AddRange(PolicyValidator.Validate(unreachableUsableFiles, options)); + + // Append structural/parse diagnostics for every file newly loaded during strict hygiene + // (the unreachable seeds plus anything their edges reached), exactly once. + for (int i = loadDiagnosticsBefore; i < provider.LoadDiagnostics.Count; i++) + { + diagnostics.Add(provider.LoadDiagnostics[i]); + } + + return diagnostics; + } + + private static bool IsJsonFile(string packageRelativePath) => + packageRelativePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Bicep.Types.Validation/Packaging/ArchivePackageFileSystem.cs b/src/Bicep.Types.Validation/Packaging/ArchivePackageFileSystem.cs new file mode 100644 index 00000000..c5cdd3f1 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/ArchivePackageFileSystem.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Archive-backed implementation of . Regular-file entries from a + /// gzip-compressed tar (types.tgz) are read into memory and keyed by their canonical + /// package-relative path so downstream validators cannot tell whether files came from a directory + /// or an archive. Container defects surface as diagnostics rather than exceptions. + /// + internal sealed class ArchivePackageFileSystem : IPackageFileSystem + { + // Regular-file tar typeflags: '0' for POSIX regular files and NUL for the historical form. + private const byte RegularFileTypeFlag = (byte)'0'; + private const byte AlternateRegularFileTypeFlag = 0; + private const byte DirectoryTypeFlag = (byte)'5'; + + private readonly Dictionary membersByKey; + + private ArchivePackageFileSystem( + Dictionary membersByKey, + IReadOnlyList diagnostics) + { + this.membersByKey = membersByKey; + Diagnostics = diagnostics; + } + + /// Container diagnostics accumulated while reading the archive. + public IReadOnlyList Diagnostics { get; } + + /// + /// true when the archive could not be read as a self-contained package, either because the + /// gzip/tar container is malformed or because a member is unsafe, duplicated, or collides. + /// + public bool HasFatalContainerFailure => Diagnostics.Count > 0; + + /// Reads an archive from its raw bytes, classifying entries and validating member paths. + public static ArchivePackageFileSystem Create(byte[] archiveBytes, string displayPath) + { + if (archiveBytes == null) { throw new ArgumentNullException(nameof(archiveBytes)); } + if (displayPath == null) { throw new ArgumentNullException(nameof(displayPath)); } + + var readResult = TarGzArchiveReader.Read(archiveBytes); + if (!readResult.Success) + { + // A gzip/tar structural failure is a single fatal diagnostic with no member-level detail. + var fatal = new[] { TypeValidationDiagnosticBuilder.ArchivePackageInvalid(displayPath, readResult.ErrorMessage!) }; + return new ArchivePackageFileSystem(new Dictionary(StringComparer.OrdinalIgnoreCase), fatal); + } + + var members = new Dictionary(StringComparer.OrdinalIgnoreCase); + var diagnostics = new List(); + + foreach (var entry in readResult.Entries) + { + if (entry.TypeFlag == DirectoryTypeFlag) + { + // Directory entries carry no file content and never enter the package file map. + continue; + } + + if (entry.TypeFlag != RegularFileTypeFlag && entry.TypeFlag != AlternateRegularFileTypeFlag) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.ArchiveMemberEntryTypeUnsupported( + entry.RawName, DescribeEntryType(entry.TypeFlag))); + continue; + } + + if (!TryCanonicalize(entry.RawName, out string canonical)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.ArchiveMemberPathInvalid(entry.RawName)); + continue; + } + + if (members.TryGetValue(canonical, out var existing)) + { + if (string.Equals(existing.RawName, entry.RawName, StringComparison.Ordinal)) + { + // The identical raw member name appears more than once. + diagnostics.Add(TypeValidationDiagnosticBuilder.ArchiveMemberDuplicate(displayPath, entry.RawName)); + } + else + { + // Distinct raw names that normalize or compare equal (leading "./" alias or + // case-only difference) collide under the package-path comparer. + diagnostics.Add(TypeValidationDiagnosticBuilder.ArchiveMemberPathCollision(existing.RawName, entry.RawName)); + } + continue; + } + + members[canonical] = new ArchiveMember(canonical, entry.RawName, entry.Content); + } + + return new ArchivePackageFileSystem(members, diagnostics); + } + + /// + public bool FileExists(string packageRelativePath) + { + return TryNormalizeLookup(packageRelativePath, out string key) && membersByKey.ContainsKey(key); + } + + /// + public bool TryReadAllBytes(string packageRelativePath, out byte[] bytes, out string error) + { + if (TryNormalizeLookup(packageRelativePath, out string key) && membersByKey.TryGetValue(key, out var member)) + { + bytes = member.Content; + error = string.Empty; + return true; + } + + bytes = Array.Empty(); + error = $"Archive member '{packageRelativePath}' was not found in the package."; + return false; + } + + /// + public IEnumerable EnumerateFiles() + { + return membersByKey.Values + .Select(m => m.Canonical) + .OrderBy(p => p, StringComparer.Ordinal); + } + + /// + /// Validates a raw archive member name as a package-relative path and produces its canonical form. + /// + private static bool TryCanonicalize(string rawName, out string canonical) + { + canonical = string.Empty; + if (string.IsNullOrEmpty(rawName)) + { + return false; + } + + // Backslashes are never valid canonical archive member characters. + if (rawName.IndexOf('\\') >= 0) + { + return false; + } + + string name = rawName; + + // A single leading "./" prefix is accepted for tar-writer interoperability and stripped. + if (name.StartsWith("./", StringComparison.Ordinal)) + { + name = name.Substring(2); + } + + if (name.Length == 0) + { + return false; + } + + // Reject Unix-rooted paths. + if (name[0] == '/') + { + return false; + } + + // Reject Windows drive-rooted paths such as "C:/types.json". + if (name.Length >= 2 && name[1] == ':') + { + return false; + } + + var segments = name.Split('/'); + foreach (var segment in segments) + { + if (segment.Length == 0) + { + // Empty segment: leading, trailing, or doubled slash. + return false; + } + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + return false; + } + } + + canonical = name; + return true; + } + + private static bool TryNormalizeLookup(string packageRelativePath, out string key) + { + key = string.Empty; + if (string.IsNullOrEmpty(packageRelativePath)) + { + return false; + } + + string name = packageRelativePath.Replace('\\', '/'); + if (name.StartsWith("./", StringComparison.Ordinal)) + { + name = name.Substring(2); + } + + if (name.Length == 0) + { + return false; + } + + key = name; + return true; + } + + private static string DescribeEntryType(byte typeFlag) + { + switch (typeFlag) + { + case (byte)'1': return "HardLink"; + case (byte)'2': return "SymbolicLink"; + case (byte)'3': return "CharacterDevice"; + case (byte)'4': return "BlockDevice"; + case (byte)'6': return "Fifo"; + case (byte)'7': return "ContiguousFile"; + case (byte)'g': return "GlobalExtendedHeader"; + case (byte)'x': return "ExtendedHeader"; + case (byte)'L': return "GnuLongName"; + case (byte)'K': return "GnuLongLink"; + default: return "Unsupported"; + } + } + + private readonly struct ArchiveMember + { + public ArchiveMember(string canonical, string rawName, byte[] content) + { + Canonical = canonical; + RawName = rawName; + Content = content; + } + + public string Canonical { get; } + + public string RawName { get; } + + public byte[] Content { get; } + } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/DirectoryPackageFileSystem.cs b/src/Bicep.Types.Validation/Packaging/DirectoryPackageFileSystem.cs new file mode 100644 index 00000000..dea9c7bc --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/DirectoryPackageFileSystem.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Directory-backed implementation of . + /// Package-relative paths are resolved against a physical root directory. + /// Absolute paths and .. traversal outside the root are rejected. + /// + internal sealed class DirectoryPackageFileSystem : IPackageFileSystem + { + private readonly string physicalRoot; + + public DirectoryPackageFileSystem(string physicalRoot) + { + this.physicalRoot = physicalRoot ?? throw new ArgumentNullException(nameof(physicalRoot)); + } + + /// + public bool FileExists(string packageRelativePath) + { + if (!TryResolvePhysicalPath(packageRelativePath, out string? physical)) + { + return false; + } + return File.Exists(physical); + } + + /// + public bool TryReadAllBytes(string packageRelativePath, out byte[] bytes, out string error) + { + if (!TryResolvePhysicalPath(packageRelativePath, out string? physical)) + { + bytes = Array.Empty(); + error = $"Package-relative path '{packageRelativePath}' is not valid or escapes the package root."; + return false; + } + + try + { + bytes = File.ReadAllBytes(physical!); + error = string.Empty; + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + bytes = Array.Empty(); + error = ex.Message; + return false; + } + } + + /// + public IEnumerable EnumerateFiles() + { + if (!Directory.Exists(physicalRoot)) + { + return Enumerable.Empty(); + } + + var rootFull = NormalizeRoot(physicalRoot); + return Directory.EnumerateFiles(physicalRoot, "*", SearchOption.AllDirectories) + .Select(f => + { + var rel = Path.GetFullPath(f).Substring(rootFull.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return rel.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + }); + } + + /// + /// Resolves a package-relative path to a physical path under the root. + /// Returns false if the path is absolute, empty, or would escape the root. + /// Diagnostics use the package-relative path; the physical path is returned only for file IO. + /// + private bool TryResolvePhysicalPath(string packageRelativePath, out string? physical) + { + if (string.IsNullOrEmpty(packageRelativePath)) + { + physical = null; + return false; + } + + // Reject absolute paths + if (Path.IsPathRooted(packageRelativePath)) + { + physical = null; + return false; + } + + // Combine and normalize + string combined; + try + { + combined = Path.GetFullPath(Path.Combine(physicalRoot, packageRelativePath)); + } + catch + { + physical = null; + return false; + } + + // Reject traversal outside the root. The normalized root has any trailing + // separator removed so a directory root supplied with a trailing slash (e.g. + // "C:\\pkg\\") still matches its own children (e.g. "C:\\pkg\\index.json"). + string rootFull = NormalizeRoot(physicalRoot); + if (!combined.StartsWith(rootFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + !combined.StartsWith(rootFull + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + !string.Equals(combined, rootFull, StringComparison.OrdinalIgnoreCase)) + { + physical = null; + return false; + } + + physical = combined; + return true; + } + + /// + /// Returns the absolute root path with any trailing directory separators removed, + /// so prefix-based containment checks are not defeated by a trailing slash. + /// + private static string NormalizeRoot(string root) => + Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + /// Normalizes a package-relative path to use forward slashes. + public static string NormalizeSeparators(string path) => + path.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + } +} diff --git a/src/Bicep.Types.Validation/Packaging/IPackageFileSystem.cs b/src/Bicep.Types.Validation/Packaging/IPackageFileSystem.cs new file mode 100644 index 00000000..f8d80606 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/IPackageFileSystem.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Internal abstraction over package file access. Implementations must normalize + /// path separators to / and reject paths that escape the package root. + /// + internal interface IPackageFileSystem + { + /// Returns true if the package-relative path exists. + bool FileExists(string packageRelativePath); + + /// + /// Reads the package-relative file as UTF-8 bytes. + /// Returns false and sets on failure. + /// + bool TryReadAllBytes(string packageRelativePath, out byte[] bytes, out string error); + + /// + /// Enumerates known package-relative file paths for unreachable-file checking. + /// May return an empty enumerable if enumeration is not supported. + /// + IEnumerable EnumerateFiles(); + } +} diff --git a/src/Bicep.Types.Validation/Packaging/JsonDocumentSet.cs b/src/Bicep.Types.Validation/Packaging/JsonDocumentSet.cs new file mode 100644 index 00000000..fb4a573f --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/JsonDocumentSet.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Immutable, ordered collection of parsed package documents. + /// Documents are keyed by their package-relative path (forward-slash normalized). + /// Duplicate paths are rejected at construction time. + /// + internal sealed class JsonDocumentSet + { + private readonly Dictionary byPath; + private readonly List typeFiles; + + public JsonDocumentSet(PackageDocument? indexDocument, IEnumerable typeFiles) + { + this.byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + this.typeFiles = new List(); + + IndexDocument = indexDocument; + + if (indexDocument != null) + { + this.byPath[indexDocument.PackageRelativePath] = indexDocument; + } + + if (typeFiles != null) + { + foreach (var doc in typeFiles) + { + if (!this.byPath.ContainsKey(doc.PackageRelativePath)) + { + this.byPath[doc.PackageRelativePath] = doc; + this.typeFiles.Add(doc); + } + } + } + } + + /// The index document, or null if reading failed. + public PackageDocument? IndexDocument { get; } + + /// Type-file documents in deterministic (load-discovery) order. + public IReadOnlyList TypeFiles => typeFiles; + + /// Looks up a document by package-relative path. Returns null if not found. + public PackageDocument? TryGetDocument(string packageRelativePath) + { + return this.byPath.TryGetValue(packageRelativePath, out var doc) ? doc : null; + } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/JsonValueKindText.cs b/src/Bicep.Types.Validation/Packaging/JsonValueKindText.cs new file mode 100644 index 00000000..f66e550b --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/JsonValueKindText.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Produces the stable, baseline-facing textual description of a + /// used in diagnostic messages. + /// + internal static class JsonValueKindText + { + public static string Describe(JsonValueKind kind) + { + switch (kind) + { + case JsonValueKind.Null: return "null"; + case JsonValueKind.True: return "boolean (true)"; + case JsonValueKind.False: return "boolean (false)"; + case JsonValueKind.Number: return "number"; + case JsonValueKind.String: return "string"; + case JsonValueKind.Array: return "array"; + case JsonValueKind.Object: return "object"; + default: return "unknown"; + } + } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/JsonValueNode.cs b/src/Bicep.Types.Validation/Packaging/JsonValueNode.cs new file mode 100644 index 00000000..fee3a578 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/JsonValueNode.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Discriminated kind of a JSON value in the lightweight node model. + /// + internal enum JsonValueKind + { + Null, + True, + False, + Number, + String, + Object, + Array, + } + + /// + /// One property in a JSON object node, carrying the name, the byte offset of the + /// name token, and the parsed value. + /// + internal readonly struct JsonProperty + { + public readonly string Name; + public readonly long NameByteOffset; + public readonly JsonValueNode Value; + + public JsonProperty(string name, long nameByteOffset, JsonValueNode value) + { + Name = name; + NameByteOffset = nameByteOffset; + Value = value; + } + } + + /// + /// Lightweight JSON value node built from a single + /// pass. Each node stores its byte offset so callers can convert to source line/column via + /// without a second pass. + /// + internal sealed class JsonValueNode + { + private static readonly IReadOnlyList NoProperties = new JsonProperty[0]; + private static readonly IReadOnlyList NoElements = new JsonValueNode[0]; + + private JsonValueNode( + JsonValueKind kind, + long byteOffset, + string? stringValue, + bool isValidInt64, + long int64Value, + IReadOnlyList? properties, + IReadOnlyList? elements) + { + Kind = kind; + ByteOffset = byteOffset; + StringValue = stringValue; + IsValidInt64 = isValidInt64; + Int64Value = int64Value; + Properties = properties ?? NoProperties; + Elements = elements ?? NoElements; + } + + /// Discriminated kind of this node. + public JsonValueKind Kind { get; } + + /// Byte offset of the token start within the original UTF-8 source. + public long ByteOffset { get; } + + /// Decoded string value; set when is . + public string? StringValue { get; } + + /// + /// true when is and the value + /// fits in a 64-bit signed integer. + /// + public bool IsValidInt64 { get; } + + /// Integer value; valid when is true. + public long Int64Value { get; } + + /// Object properties in declaration order; non-empty only for . + public IReadOnlyList Properties { get; } + + /// Array elements in order; non-empty only for . + public IReadOnlyList Elements { get; } + + /// Looks up a property by name using ordinal comparison. Returns false if absent. + public bool TryGetProperty(string name, out JsonValueNode value) + { + foreach (var prop in Properties) + { + if (string.Equals(prop.Name, name, StringComparison.Ordinal)) + { + value = prop.Value; + return true; + } + } + value = default!; + return false; + } + + // ----- Factory methods ----- + + internal static JsonValueNode CreateNull(long byteOffset) => + new JsonValueNode(JsonValueKind.Null, byteOffset, null, false, 0, null, null); + + internal static JsonValueNode CreateTrue(long byteOffset) => + new JsonValueNode(JsonValueKind.True, byteOffset, null, false, 0, null, null); + + internal static JsonValueNode CreateFalse(long byteOffset) => + new JsonValueNode(JsonValueKind.False, byteOffset, null, false, 0, null, null); + + internal static JsonValueNode CreateString(long byteOffset, string value) => + new JsonValueNode(JsonValueKind.String, byteOffset, value, false, 0, null, null); + + internal static JsonValueNode CreateNumber(long byteOffset, bool isValidInt64, long int64Value) => + new JsonValueNode(JsonValueKind.Number, byteOffset, null, isValidInt64, int64Value, null, null); + + internal static JsonValueNode CreateObject(long byteOffset, List properties) => + new JsonValueNode(JsonValueKind.Object, byteOffset, null, false, 0, properties, null); + + internal static JsonValueNode CreateArray(long byteOffset, List elements) => + new JsonValueNode(JsonValueKind.Array, byteOffset, null, false, 0, null, elements); + } +} diff --git a/src/Bicep.Types.Validation/Packaging/PackageDocument.cs b/src/Bicep.Types.Validation/Packaging/PackageDocument.cs new file mode 100644 index 00000000..685d8b99 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageDocument.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// One parsed package JSON document. Carries the package-relative path, kind, + /// source-mapped value tree, and optionally the physical path for debug use. + /// + internal sealed class PackageDocument + { + public PackageDocument( + string packageRelativePath, + PackageDocumentKind kind, + JsonValueNode root, + SourceMap sourceMap, + string? physicalPath = null) + { + PackageRelativePath = packageRelativePath ?? throw new ArgumentNullException(nameof(packageRelativePath)); + Kind = kind; + Root = root ?? throw new ArgumentNullException(nameof(root)); + SourceMap = sourceMap ?? throw new ArgumentNullException(nameof(sourceMap)); + PhysicalPath = physicalPath; + } + + /// Package-relative path using / separators. + public string PackageRelativePath { get; } + + /// Whether this is the index or a type file. + public PackageDocumentKind Kind { get; } + + /// Parsed JSON value tree with source offsets. + public JsonValueNode Root { get; } + + /// Source-location map for this document's bytes. + public SourceMap SourceMap { get; } + + /// Physical file path, present only for file-backed documents; omit from diagnostics. + public string? PhysicalPath { get; } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/PackageDocumentKind.cs b/src/Bicep.Types.Validation/Packaging/PackageDocumentKind.cs new file mode 100644 index 00000000..6707856f --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageDocumentKind.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// Kind of a parsed package JSON document. + internal enum PackageDocumentKind + { + /// The index.json file at the package root. + Index, + + /// A type file referenced from index.json. + TypeFile, + } +} diff --git a/src/Bicep.Types.Validation/Packaging/PackageInputKind.cs b/src/Bicep.Types.Validation/Packaging/PackageInputKind.cs new file mode 100644 index 00000000..282eac49 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageInputKind.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Normalized classification of a validation input. + /// + internal enum PackageInputKind + { + Directory, + IndexFile, + ArchiveFile, + ArchiveStream, + } +} diff --git a/src/Bicep.Types.Validation/Packaging/PackageInputResolution.cs b/src/Bicep.Types.Validation/Packaging/PackageInputResolution.cs new file mode 100644 index 00000000..b13f723c --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageInputResolution.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// The first normalized shape produced from a public validation input. + /// + internal sealed class PackageInputResolution + { + public PackageInputResolution( + PackageInputKind kind, + string displayPath, + string? packageRootPath, + string? indexFilePath, + IReadOnlyList diagnostics, + string? archiveFilePath = null, + byte[]? archiveBytes = null) + { + Kind = kind; + DisplayPath = displayPath; + PackageRootPath = packageRootPath; + IndexFilePath = indexFilePath; + Diagnostics = diagnostics; + ArchiveFilePath = archiveFilePath; + ArchiveBytes = archiveBytes; + } + + public PackageInputKind Kind { get; } + + public string DisplayPath { get; } + + /// Package root, for directory and index-file inputs. + public string? PackageRootPath { get; } + + /// Index file path, for raw index inputs. + public string? IndexFilePath { get; } + + /// Diagnostics produced while resolving the input. + public IReadOnlyList Diagnostics { get; } + + /// Physical archive path, for archive-file inputs. + public string? ArchiveFilePath { get; } + + /// Archive bytes read fully into memory, for archive-stream inputs. + public byte[]? ArchiveBytes { get; } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/PackageInputResolver.cs b/src/Bicep.Types.Validation/Packaging/PackageInputResolver.cs new file mode 100644 index 00000000..814eaa17 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageInputResolver.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Azure.Bicep.Types.Validation.Diagnostics; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Classifies a public validation input into its first normalized shape. + /// + /// + /// The resolver records the input shape that extends. Directory and + /// index-file inputs carry a package root; archive inputs carry the archive path or the archive + /// bytes read fully from the caller's stream. Archive validity is decided later by the reader. + /// + internal static class PackageInputResolver + { + private static readonly TypeValidationDiagnostic[] NoDiagnostics = new TypeValidationDiagnostic[0]; + + public static PackageInputResolution Resolve(TypePackageValidationInput input) + { + switch (input) + { + case DirectoryValidationInput directory: + return new PackageInputResolution( + PackageInputKind.Directory, + directory.DisplayPath, + packageRootPath: directory.Path, + indexFilePath: null, + diagnostics: NoDiagnostics); + + case IndexFileValidationInput index: + var root = Path.GetDirectoryName(index.Path); + return new PackageInputResolution( + PackageInputKind.IndexFile, + index.DisplayPath, + packageRootPath: string.IsNullOrEmpty(root) ? "." : root, + indexFilePath: index.Path, + diagnostics: NoDiagnostics); + + case ArchiveFileValidationInput archiveFile: + return new PackageInputResolution( + PackageInputKind.ArchiveFile, + archiveFile.DisplayPath, + packageRootPath: null, + indexFilePath: null, + diagnostics: NoDiagnostics, + archiveFilePath: archiveFile.Path); + + case ArchiveStreamValidationInput archiveStream: + // Read the caller-provided stream fully into memory without disposing it and + // without requiring seekability. + var bytes = ReadStreamFully(archiveStream.Content); + return new PackageInputResolution( + PackageInputKind.ArchiveStream, + archiveStream.DisplayPath, + packageRootPath: null, + indexFilePath: null, + diagnostics: NoDiagnostics, + archiveBytes: bytes); + + default: + throw new ArgumentOutOfRangeException( + nameof(input), + input?.GetType().FullName, + "Unsupported validation input type."); + } + } + + private static byte[] ReadStreamFully(Stream stream) + { + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/PackageReadResult.cs b/src/Bicep.Types.Validation/Packaging/PackageReadResult.cs new file mode 100644 index 00000000..9982f51f --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageReadResult.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Internal result from . + /// + internal sealed class PackageReadResult + { + public PackageReadResult( + JsonDocumentSet documents, + IReadOnlyList diagnostics, + bool hasFatalReadFailure, + IPackageFileSystem? fileSystem) + { + Documents = documents; + Diagnostics = diagnostics; + HasFatalReadFailure = hasFatalReadFailure; + FileSystem = fileSystem; + } + + /// All parsed package documents. + public JsonDocumentSet Documents { get; } + + /// Diagnostics produced during package reading (IO and parse errors). + public IReadOnlyList Diagnostics { get; } + + /// + /// true when the index document is unavailable (missing or unparseable), + /// indicating that structural validation should not proceed. + /// + public bool HasFatalReadFailure { get; } + + /// + /// File system rooted at the package, used by the graph layer to load type files on + /// demand. null when reading failed before a package root was established. + /// + public IPackageFileSystem? FileSystem { get; } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/PackageReader.cs b/src/Bicep.Types.Validation/Packaging/PackageReader.cs new file mode 100644 index 00000000..94587ff6 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageReader.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using Azure.Bicep.Types.Validation.Diagnostics; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Reads a package's index.json into a by resolving the + /// package root and parsing the index document. Type files are not loaded here; they are + /// loaded on demand by the graph layer's package document provider, which owns transitive + /// closure and per-file structural validation. + /// + internal static class PackageReader + { + private const string IndexFileName = "index.json"; + + public static PackageReadResult Read(PackageInputResolution resolution, TypePackageValidationOptions options) + { + if (resolution == null) { throw new ArgumentNullException(nameof(resolution)); } + if (options == null) { throw new ArgumentNullException(nameof(options)); } + + var diagnostics = new List(); + + if (!TryOpenFileSystem(resolution, diagnostics, out IPackageFileSystem? fs)) + { + return Fatal(diagnostics); + } + + // The index document is always at the package-relative path "index.json". + // For directory inputs, the file must exist at packageRoot/index.json. + // For indexFile inputs, the resolution already computed the root as the + // containing directory, so the file is also at packageRoot/index.json. + // For archive inputs, only the archive-root index.json is the package index. + if (!fs!.FileExists(IndexFileName)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.IndexFileMissing(resolution.DisplayPath)); + return Fatal(diagnostics); + } + + // Read index.json bytes + if (!fs.TryReadAllBytes(IndexFileName, out byte[] indexBytes, out string indexReadError)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.PackageFileReadFailed(IndexFileName, indexReadError)); + return Fatal(diagnostics); + } + + // Parse index.json + if (!SourceMap.TryParse(indexBytes, IndexFileName, out JsonValueNode? indexRoot, out SourceMap indexSourceMap, out var indexParseError)) + { + var err = indexParseError!.Value; + diagnostics.Add(TypeValidationDiagnosticBuilder.JsonSyntaxInvalid(IndexFileName, err.line, err.column, err.message)); + return Fatal(diagnostics); + } + + var indexDoc = new PackageDocument(IndexFileName, PackageDocumentKind.Index, indexRoot!, indexSourceMap); + + // Type files are loaded lazily by the graph layer's provider; only the index + // document is materialized here. + var documents = new JsonDocumentSet(indexDoc, new PackageDocument[0]); + return new PackageReadResult(documents, diagnostics, hasFatalReadFailure: false, fileSystem: fs); + } + + /// + /// Opens the appropriate for the resolved input kind, adding a + /// fatal diagnostic and returning false when the package container cannot be opened. + /// + private static bool TryOpenFileSystem( + PackageInputResolution resolution, + List diagnostics, + out IPackageFileSystem? fileSystem) + { + fileSystem = null; + + if (resolution.Kind == PackageInputKind.ArchiveFile || resolution.Kind == PackageInputKind.ArchiveStream) + { + if (!TryGetArchiveBytes(resolution, diagnostics, out byte[]? archiveBytes)) + { + return false; + } + + var archiveFs = ArchivePackageFileSystem.Create(archiveBytes!, resolution.DisplayPath); + if (archiveFs.HasFatalContainerFailure) + { + diagnostics.AddRange(archiveFs.Diagnostics); + return false; + } + + fileSystem = archiveFs; + return true; + } + + string? packageRoot = resolution.PackageRootPath; + if (string.IsNullOrEmpty(packageRoot) || !Directory.Exists(packageRoot)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.PackagePathInvalid(resolution.DisplayPath)); + return false; + } + + fileSystem = new DirectoryPackageFileSystem(packageRoot!); + return true; + } + + /// Resolves the raw archive bytes for an archive-file or archive-stream input. + private static bool TryGetArchiveBytes( + PackageInputResolution resolution, + List diagnostics, + out byte[]? archiveBytes) + { + if (resolution.Kind == PackageInputKind.ArchiveStream) + { + archiveBytes = resolution.ArchiveBytes ?? Array.Empty(); + return true; + } + + archiveBytes = null; + var path = resolution.ArchiveFilePath; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.PackagePathInvalid(resolution.DisplayPath)); + return false; + } + + try + { + archiveBytes = File.ReadAllBytes(path!); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.PackageFileReadFailed(resolution.DisplayPath, ex.Message)); + return false; + } + } + + private static PackageReadResult Fatal(List diagnostics) + { + return new PackageReadResult( + new JsonDocumentSet(null, new PackageDocument[0]), + diagnostics, + hasFatalReadFailure: true, + fileSystem: null); + } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/ReferencePath.cs b/src/Bicep.Types.Validation/Packaging/ReferencePath.cs new file mode 100644 index 00000000..99474249 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/ReferencePath.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Diagnostic-free parser for $ref string values. + /// Splits a reference string into an optional package-relative path and a non-negative + /// integer index. The expected formats are #/3 (same-file) and + /// types.json#/0 (cross-file). + /// + /// This type intentionally produces no diagnostics so that + /// can discover which type files to load without depending on structural validators. + /// ReferenceSyntax in the Structural layer adds diagnostic production on top of + /// the same parsing logic. + /// + /// + internal static class ReferencePath + { + private const string FragmentSeparator = "#/"; + + /// + /// Attempts to parse a $ref string value. + /// Returns true on success and sets + /// (empty string for same-file refs) and . + /// + public static bool TryParse(string refValue, out string packageRelativePath, out int index) + { + if (string.IsNullOrEmpty(refValue)) + { + packageRelativePath = string.Empty; + index = -1; + return false; + } + + int sepIndex = refValue.IndexOf(FragmentSeparator, StringComparison.Ordinal); + if (sepIndex < 0) + { + packageRelativePath = string.Empty; + index = -1; + return false; + } + + string indexText = refValue.Substring(sepIndex + FragmentSeparator.Length); + + if (!int.TryParse(indexText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int parsedIndex) || parsedIndex < 0) + { + packageRelativePath = string.Empty; + index = -1; + return false; + } + + packageRelativePath = refValue.Substring(0, sepIndex); // empty = same-file ref + index = parsedIndex; + return true; + } + + /// + /// Returns the package-relative path portion of a $ref string, or + /// null if the string is not a valid cross-file reference. + /// Returns an empty string for valid same-file references. + /// + public static string? ExtractPackagePath(string refValue) + { + return TryParse(refValue, out string path, out _) ? path : null; + } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/SourceLocation.cs b/src/Bicep.Types.Validation/Packaging/SourceLocation.cs new file mode 100644 index 00000000..4d3ca7bd --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/SourceLocation.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// A 1-based line and column position within a package JSON file. + /// Column is measured in UTF-16 code units, matching .NET string indexing. + /// + internal struct SourceLocation + { + public static readonly SourceLocation Unknown = new SourceLocation(0, 0); + + public int Line; + public int Column; + + public SourceLocation(int line, int column) + { + Line = line; + Column = column; + } + + public bool IsKnown => Line > 0 && Column > 0; + } +} diff --git a/src/Bicep.Types.Validation/Packaging/SourceMap.cs b/src/Bicep.Types.Validation/Packaging/SourceMap.cs new file mode 100644 index 00000000..1f5b7245 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/SourceMap.cs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// Maps byte offsets in a UTF-8 JSON file to 1-based line/column positions. + /// Columns are measured in UTF-16 code units, matching .NET string indexing. + /// Both \n and \r\n are treated as single line breaks. + /// + internal sealed class SourceMap + { + private readonly byte[] rawBytes; + + // lineStartOffsets[i] = byte offset of the first byte of line (i+1). + // lineStartOffsets[0] = 0 (line 1 starts at offset 0). + private readonly int[] lineStartOffsets; + + private SourceMap(byte[] rawBytes, int[] lineStartOffsets) + { + this.rawBytes = rawBytes; + this.lineStartOffsets = lineStartOffsets; + } + + /// Builds a from the raw UTF-8 bytes of a file. + public static SourceMap Create(byte[] rawBytes) + { + if (rawBytes == null) + { + throw new ArgumentNullException(nameof(rawBytes)); + } + + var starts = new List { 0 }; + for (int i = 0; i < rawBytes.Length; i++) + { + if (rawBytes[i] == 0x0A) // \n + { + starts.Add(i + 1); + } + else if (rawBytes[i] == 0x0D) // \r + { + // \r\n counts as one line break; skip the \n + if (i + 1 < rawBytes.Length && rawBytes[i + 1] == 0x0A) + { + i++; + } + starts.Add(i + 1); + } + } + + return new SourceMap(rawBytes, starts.ToArray()); + } + + /// + /// Converts a byte offset to a 1-based line/column location. + /// + public SourceLocation GetLocation(long byteOffset) + { + if (byteOffset < 0 || rawBytes.Length == 0) + { + return new SourceLocation(1, 1); + } + + int offset = (int)Math.Min(byteOffset, (long)(rawBytes.Length - 1)); + int lineIndex = FindLineIndex(offset); + int line = lineIndex + 1; // 1-based + int lineStart = lineStartOffsets[lineIndex]; + int prefixByteCount = offset - lineStart; + + int column; + if (prefixByteCount <= 0) + { + column = 1; + } + else + { + try + { + // Decode the bytes from line start to the target offset as UTF-8 to get UTF-16 length + string prefix = Encoding.UTF8.GetString(rawBytes, lineStart, prefixByteCount); + column = prefix.Length + 1; // string.Length = UTF-16 code unit count; +1 for 1-based + } + catch + { + column = prefixByteCount + 1; // fallback: byte count approximation + } + } + + return new SourceLocation(line, column); + } + + /// + /// Converts the error position from a to a + /// 1-based line/column location. and + /// are 0-based as reported by + /// and . + /// + public SourceLocation GetLocationForException(long zeroBasedLine, long bytePositionInLine) + { + int lineIndex = (int)Math.Max(0L, Math.Min(zeroBasedLine, (long)(lineStartOffsets.Length - 1))); + long lineStart = lineStartOffsets[lineIndex]; + long byteOffset = lineStart + Math.Max(0L, bytePositionInLine); + return GetLocation(byteOffset); + } + + /// Binary search for the 0-based line index that contains . + private int FindLineIndex(int byteOffset) + { + int lo = 0; + int hi = lineStartOffsets.Length - 1; + while (lo < hi) + { + int mid = (lo + hi + 1) / 2; + if (lineStartOffsets[mid] <= byteOffset) + { + lo = mid; + } + else + { + hi = mid - 1; + } + } + return lo; + } + + /// + /// Parses the UTF-8 bytes as JSON and returns the root node. + /// On success, also provides a built from the same bytes. + /// On parse failure, is null and + /// describes the first syntax error. + /// + public static bool TryParse( + byte[] utf8Bytes, + string packageRelativePath, + out JsonValueNode? root, + out SourceMap sourceMap, + out (int line, int column, string message)? parseError) + { + sourceMap = Create(utf8Bytes); + + if (utf8Bytes.Length == 0) + { + root = null; + parseError = (1, 1, "The JSON file is empty."); + return false; + } + + try + { + var readerOptions = new JsonReaderOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + }; + var span = new ReadOnlySpan(utf8Bytes); + var reader = new Utf8JsonReader(span, readerOptions); + + if (!reader.Read()) + { + root = null; + parseError = (1, 1, "Unexpected end of JSON input."); + return false; + } + + root = ParseCurrentToken(ref reader); + + // Ensure the top-level value is the entire document. Any non-whitespace + // content after the root value is malformed JSON (e.g. "{} garbage" or + // two top-level values). Read() returns false at EOF (trailing whitespace + // only) and throws JsonException for invalid trailing bytes. + if (reader.Read()) + { + var trailingLoc = sourceMap.GetLocation(reader.TokenStartIndex); + root = null; + parseError = (trailingLoc.Line, trailingLoc.Column, "Unexpected content after the top-level JSON value."); + return false; + } + + parseError = null; + return true; + } + catch (JsonException ex) + { + root = null; + var loc = sourceMap.GetLocationForException( + ex.LineNumber ?? 0L, + ex.BytePositionInLine ?? 0L); + string msg = ex.Message ?? "Invalid JSON."; + parseError = (loc.Line, loc.Column, msg); + return false; + } + } + + private static JsonValueNode ParseCurrentToken(ref Utf8JsonReader reader) + { + long offset = reader.TokenStartIndex; + + if (reader.TokenType == JsonTokenType.StartObject) + { + var properties = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + long nameOffset = reader.TokenStartIndex; + string propName = reader.GetString()!; + reader.Read(); + var propValue = ParseCurrentToken(ref reader); + properties.Add(new JsonProperty(propName, nameOffset, propValue)); + } + return JsonValueNode.CreateObject(offset, properties); + } + + if (reader.TokenType == JsonTokenType.StartArray) + { + var elements = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + elements.Add(ParseCurrentToken(ref reader)); + } + return JsonValueNode.CreateArray(offset, elements); + } + + if (reader.TokenType == JsonTokenType.String) + { + return JsonValueNode.CreateString(offset, reader.GetString()!); + } + + if (reader.TokenType == JsonTokenType.Number) + { + bool ok = reader.TryGetInt64(out long val); + return JsonValueNode.CreateNumber(offset, ok, val); + } + + if (reader.TokenType == JsonTokenType.True) + { + return JsonValueNode.CreateTrue(offset); + } + + if (reader.TokenType == JsonTokenType.False) + { + return JsonValueNode.CreateFalse(offset); + } + + // JsonTokenType.Null + return JsonValueNode.CreateNull(offset); + } + } +} diff --git a/src/Bicep.Types.Validation/Packaging/TarGzArchiveReader.cs b/src/Bicep.Types.Validation/Packaging/TarGzArchiveReader.cs new file mode 100644 index 00000000..3d86179f --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/TarGzArchiveReader.cs @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Azure.Bicep.Types.Validation.Packaging +{ + /// + /// One raw entry read from a tar archive, before package-path validation. + /// + internal readonly struct TarArchiveEntry + { + public TarArchiveEntry(string rawName, byte typeFlag, byte[] content) + { + RawName = rawName; + TypeFlag = typeFlag; + Content = content; + } + + /// The raw member name as stored in the tar header (prefix already applied). + public string RawName { get; } + + /// The tar typeflag byte (0/'0' = regular file, '5' = directory, etc.). + public byte TypeFlag { get; } + + /// The member content bytes for regular files; empty for non-file entries. + public byte[] Content { get; } + } + + /// + /// Outcome of reading a gzip-compressed tar archive. + /// + internal sealed class TarGzArchiveReadResult + { + private TarGzArchiveReadResult(bool success, string? errorMessage, IReadOnlyList entries) + { + Success = success; + ErrorMessage = errorMessage; + Entries = entries; + } + + /// true when the container decompressed and parsed as a structurally valid tar. + public bool Success { get; } + + /// Reader message describing a fatal container failure; otherwise null. + public string? ErrorMessage { get; } + + /// The raw entries read from the archive; empty on failure. + public IReadOnlyList Entries { get; } + + public static TarGzArchiveReadResult Failure(string message) => + new TarGzArchiveReadResult(false, message, Array.Empty()); + + public static TarGzArchiveReadResult Ok(IReadOnlyList entries) => + new TarGzArchiveReadResult(true, null, entries); + } + + /// + /// Minimal read-only reader for gzip-compressed ustar archives (types.tgz). + /// + /// + /// The reader intentionally supports only what a Bicep type package needs: it decompresses the + /// gzip container and parses ustar 512-byte headers well enough to read regular-file content and + /// classify directory, symlink, hardlink, and other unsupported entry types. PAX extended headers + /// and GNU long-name entries (emitted by .NET's TarWriter in PAX format, which Bicep's own + /// tgz writer uses) are consumed as metadata rather than treated as members, applying their + /// path and size overrides to the following file entry. Structural failures are + /// reported as a single fatal message rather than thrown, so callers can surface a BCPVT030 + /// diagnostic. It does not depend on System.Formats.Tar, which is not available on + /// netstandard2.0. + /// + internal static class TarGzArchiveReader + { + private const int BlockSize = 512; + private const int NameOffset = 0; + private const int NameLength = 100; + private const int SizeOffset = 124; + private const int SizeLength = 12; + private const int TypeFlagOffset = 156; + private const int MagicOffset = 257; + private const int PrefixOffset = 345; + private const int PrefixLength = 155; + + // Metadata tar entries that carry information about the following file entry rather than + // package content of their own. + private const byte PaxExtendedHeaderTypeFlag = (byte)'x'; + private const byte PaxGlobalHeaderTypeFlag = (byte)'g'; + private const byte GnuLongNameTypeFlag = (byte)'L'; + private const byte GnuLongLinkTypeFlag = (byte)'K'; + private const byte DirectoryTypeFlag = (byte)'5'; + + /// Reads all entries from a gzip-compressed tar archive held in memory. + public static TarGzArchiveReadResult Read(byte[] archiveBytes) + { + if (archiveBytes == null) { throw new ArgumentNullException(nameof(archiveBytes)); } + + byte[] tarBytes; + try + { + tarBytes = Decompress(archiveBytes); + } + catch (Exception ex) when (ex is InvalidDataException || ex is IOException || ex is EndOfStreamException) + { + return TarGzArchiveReadResult.Failure("the input is not a valid gzip stream"); + } + + return ParseTar(tarBytes); + } + + private static byte[] Decompress(byte[] archiveBytes) + { + using var input = new MemoryStream(archiveBytes, writable: false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } + + private static TarGzArchiveReadResult ParseTar(byte[] tar) + { + var entries = new List(); + int pos = 0; + + // Overrides carried forward from a preceding PAX extended header or GNU long-name entry. + string? pendingName = null; + long? pendingSize = null; + + while (pos + BlockSize <= tar.Length) + { + if (IsZeroBlock(tar, pos)) + { + // First all-zero block marks the end-of-archive terminator. + return TarGzArchiveReadResult.Ok(entries); + } + + if (!HasUstarMagic(tar, pos)) + { + return TarGzArchiveReadResult.Failure("a tar header block is missing the 'ustar' magic marker"); + } + + string name = ReadString(tar, pos + NameOffset, NameLength); + string prefix = ReadString(tar, pos + PrefixOffset, PrefixLength); + string rawName = prefix.Length > 0 ? prefix + "/" + name : name; + byte typeFlag = tar[pos + TypeFlagOffset]; + + if (!TryReadOctal(tar, pos + SizeOffset, SizeLength, out long headerSize) || headerSize < 0) + { + return TarGzArchiveReadResult.Failure("a tar header block has an invalid size field"); + } + + pos += BlockSize; + + // A metadata entry (PAX header or GNU long name) uses its own header size for its + // content span. A file/directory entry may have that size overridden by a preceding + // PAX "size" record (used when the real length does not fit the octal size field). + bool isMetadata = + typeFlag == PaxExtendedHeaderTypeFlag || + typeFlag == PaxGlobalHeaderTypeFlag || + typeFlag == GnuLongNameTypeFlag || + typeFlag == GnuLongLinkTypeFlag; + + long contentSize = isMetadata ? headerSize : (pendingSize ?? headerSize); + if (contentSize < 0 || contentSize > int.MaxValue || pos + contentSize > tar.Length) + { + return TarGzArchiveReadResult.Failure("a tar entry declares more content than the archive contains"); + } + + long contentByteSpan = ((contentSize + BlockSize - 1) / BlockSize) * BlockSize; + + if (typeFlag == PaxExtendedHeaderTypeFlag || typeFlag == PaxGlobalHeaderTypeFlag) + { + ParsePaxRecords(tar, pos, (int)contentSize, ref pendingName, ref pendingSize); + pos += (int)contentByteSpan; + continue; + } + + if (typeFlag == GnuLongNameTypeFlag) + { + pendingName = ReadString(tar, pos, (int)contentSize); + pos += (int)contentByteSpan; + continue; + } + + if (typeFlag == GnuLongLinkTypeFlag) + { + // Long link targets are irrelevant to package files; consume and ignore. + pos += (int)contentByteSpan; + continue; + } + + string effectiveName = pendingName ?? rawName; + pendingName = null; + pendingSize = null; + + byte[] content = new byte[contentSize]; + Array.Copy(tar, pos, content, 0, (int)contentSize); + entries.Add(new TarArchiveEntry(effectiveName, typeFlag, content)); + + pos += (int)contentByteSpan; + } + + // A well-formed archive terminates with zero blocks. Reaching the end without a + // terminator still yields the entries read so far; only header/size corruption is fatal. + return TarGzArchiveReadResult.Ok(entries); + } + + /// + /// Parses PAX extended-header records ("len key=value\n") and applies the path and + /// size overrides to the following file entry. Unknown keys are ignored. + /// + private static void ParsePaxRecords(byte[] tar, int offset, int length, ref string? pendingName, ref long? pendingSize) + { + int i = offset; + int end = offset + length; + + while (i < end) + { + int spaceIndex = i; + while (spaceIndex < end && tar[spaceIndex] != (byte)' ') + { + spaceIndex++; + } + + if (spaceIndex >= end || !TryParseDecimal(tar, i, spaceIndex - i, out int recordLength) || recordLength <= 0) + { + return; + } + + int recordEnd = i + recordLength; + if (recordEnd > end || recordEnd <= spaceIndex + 1) + { + return; + } + + int keyStart = spaceIndex + 1; + int valueEnd = recordEnd - 1; // Exclude the trailing newline. + int equalsIndex = keyStart; + while (equalsIndex < valueEnd && tar[equalsIndex] != (byte)'=') + { + equalsIndex++; + } + + if (equalsIndex < valueEnd) + { + string key = Encoding.ASCII.GetString(tar, keyStart, equalsIndex - keyStart); + if (string.Equals(key, "path", StringComparison.Ordinal)) + { + pendingName = Encoding.UTF8.GetString(tar, equalsIndex + 1, valueEnd - (equalsIndex + 1)); + } + else if (string.Equals(key, "size", StringComparison.Ordinal)) + { + string sizeText = Encoding.ASCII.GetString(tar, equalsIndex + 1, valueEnd - (equalsIndex + 1)); + if (long.TryParse(sizeText, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsedSize) && parsedSize >= 0) + { + pendingSize = parsedSize; + } + } + } + + i = recordEnd; + } + } + + private static bool TryParseDecimal(byte[] buffer, int offset, int length, out int value) + { + value = 0; + if (length <= 0) + { + return false; + } + + for (int i = 0; i < length; i++) + { + byte b = buffer[offset + i]; + if (b < (byte)'0' || b > (byte)'9') + { + return false; + } + value = (value * 10) + (b - (byte)'0'); + } + + return true; + } + + private static bool IsZeroBlock(byte[] buffer, int offset) + { + for (int i = 0; i < BlockSize; i++) + { + if (buffer[offset + i] != 0) + { + return false; + } + } + return true; + } + + private static bool HasUstarMagic(byte[] buffer, int offset) + { + // "ustar" as ASCII bytes; POSIX uses "ustar\0", GNU uses "ustar ". Match the prefix only. + return buffer[offset + MagicOffset + 0] == (byte)'u' + && buffer[offset + MagicOffset + 1] == (byte)'s' + && buffer[offset + MagicOffset + 2] == (byte)'t' + && buffer[offset + MagicOffset + 3] == (byte)'a' + && buffer[offset + MagicOffset + 4] == (byte)'r'; + } + + private static string ReadString(byte[] buffer, int offset, int length) + { + int end = offset; + int limit = offset + length; + while (end < limit && buffer[end] != 0) + { + end++; + } + return Encoding.ASCII.GetString(buffer, offset, end - offset); + } + + private static bool TryReadOctal(byte[] buffer, int offset, int length, out long value) + { + value = 0; + bool sawDigit = false; + for (int i = 0; i < length; i++) + { + byte b = buffer[offset + i]; + if (b == 0 || b == (byte)' ') + { + if (sawDigit) + { + break; + } + continue; + } + if (b < (byte)'0' || b > (byte)'7') + { + return false; + } + value = (value << 3) + (b - (byte)'0'); + sawDigit = true; + } + return true; + } + } +} diff --git a/src/Bicep.Types.Validation/Policy/BuiltInTypePolicyValidator.cs b/src/Bicep.Types.Validation/Policy/BuiltInTypePolicyValidator.cs new file mode 100644 index 00000000..0db1128f --- /dev/null +++ b/src/Bicep.Types.Validation/Policy/BuiltInTypePolicyValidator.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Policy +{ + /// + /// Policy for BuiltInType: canonical writers must not emit any documented built-in + /// kind (they must use the concrete replacement type), while compatible readers accept them + /// with a warning. kind values outside the documented range are errors in both modes. + /// + internal static class BuiltInTypePolicyValidator + { + // Documented BuiltInTypeKind serialized values and their canonical replacement types. + // Kind 8 (ResourceRef) is a reserved legacy form with no canonical replacement. + private static readonly Dictionary DocumentedKinds = + new Dictionary + { + [1] = ("Any", "AnyType"), + [2] = ("Null", "NullType"), + [3] = ("Bool", "BooleanType"), + [4] = ("Int", "IntegerType"), + [5] = ("String", "StringType"), + [6] = ("Object", "ObjectType"), + [7] = ("Array", "ArrayType"), + [8] = ("ResourceRef", null), + }; + + public static void Validate( + TypeGraphNode node, + TypePackageValidationOptions options, + List diagnostics) + { + // A missing or non-integer 'kind' is owned by the structural layer (BCPVT010/BCPVT011). + if (!PolicyNodeReader.TryGetIntegerProperty(node.ObjectNode, "kind", out var kindProperty, out long kind)) + { + return; + } + + var document = node.Document; + var location = document.SourceMap.GetLocation(kindProperty.NameByteOffset); + string path = document.PackageRelativePath; + string pointer = node.JsonPointer + "/kind"; + + if (!DocumentedKinds.TryGetValue(kind, out var info)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.BuiltInTypeKindInvalid( + path, pointer, kind, location.Line, location.Column)); + return; + } + + if (options.Mode == TypePackageValidationMode.CanonicalWriter) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.CanonicalBuiltInTypeViolation( + path, pointer, kind, info.Name, info.Replacement, location.Line, location.Column)); + } + else + { + diagnostics.Add(TypeValidationDiagnosticBuilder.CompatibilityBuiltInTypeUsed( + path, pointer, kind, info.Name, info.Replacement, location.Line, location.Column)); + } + } + } +} diff --git a/src/Bicep.Types.Validation/Policy/PolicyNodeReader.cs b/src/Bicep.Types.Validation/Policy/PolicyNodeReader.cs new file mode 100644 index 00000000..58c30e1b --- /dev/null +++ b/src/Bicep.Types.Validation/Policy/PolicyNodeReader.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Policy +{ + /// + /// Small helpers for reading raw JSON fields for policy classification. + /// + internal static class PolicyNodeReader + { + /// Finds a direct property by ordinal name, returning the property (with its name offset). + public static bool TryGetProperty(JsonValueNode obj, string name, out JsonProperty property) + { + foreach (var candidate in obj.Properties) + { + if (string.Equals(candidate.Name, name, StringComparison.Ordinal)) + { + property = candidate; + return true; + } + } + + property = default; + return false; + } + + /// + /// Returns true when a direct property is present and is a shape-valid JSON integer. + /// When the property is present but has a non-integer shape, this returns false so + /// policy defers to the structural layer, which owns the primitive-shape diagnostic. + /// + public static bool TryGetIntegerProperty(JsonValueNode obj, string name, out JsonProperty property, out long value) + { + if (TryGetProperty(obj, name, out property) && + property.Value.Kind == JsonValueKind.Number && + property.Value.IsValidInt64) + { + value = property.Value.Int64Value; + return true; + } + + value = 0; + return false; + } + } +} diff --git a/src/Bicep.Types.Validation/Policy/PolicyValidator.cs b/src/Bicep.Types.Validation/Policy/PolicyValidator.cs new file mode 100644 index 00000000..5e3fe655 --- /dev/null +++ b/src/Bicep.Types.Validation/Policy/PolicyValidator.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Policy +{ + /// + /// Mode-policy layer. Runs after structural and semantic-graph validation and classifies + /// documented legacy serialized forms: it rejects them in CanonicalWriter and accepts + /// them with warnings in CompatibleReader. Policy reads the raw JSON node model + /// (never the deserialized type model) so the original source forms remain visible. + /// + internal static class PolicyValidator + { + public static IReadOnlyList Validate( + IEnumerable reachedTypeFiles, + TypePackageValidationOptions options) + { + if (reachedTypeFiles == null) { throw new ArgumentNullException(nameof(reachedTypeFiles)); } + if (options == null) { throw new ArgumentNullException(nameof(options)); } + + var diagnostics = new List(); + + foreach (var file in reachedTypeFiles) + { + // Inspect every structurally usable element of a reached type file, including + // elements at indices no reference points to. Non-usable elements (null) were + // already reported by the structural layer and are skipped here. + foreach (var node in file.NodesByIndex) + { + if (node == null) + { + continue; + } + + switch (node.Discriminator) + { + case "ResourceType": + ResourceScopePolicyValidator.Validate(node, options, diagnostics); + break; + + case "BuiltInType": + BuiltInTypePolicyValidator.Validate(node, options, diagnostics); + break; + } + } + } + + return diagnostics; + } + } +} diff --git a/src/Bicep.Types.Validation/Policy/ResourceScopePolicyValidator.cs b/src/Bicep.Types.Validation/Policy/ResourceScopePolicyValidator.cs new file mode 100644 index 00000000..fc529b8c --- /dev/null +++ b/src/Bicep.Types.Validation/Policy/ResourceScopePolicyValidator.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Policy +{ + /// + /// Policy for ResourceType scope fields. The legacy scope fields scopeType, + /// readOnlyScopes and flags are rejected in CanonicalWriter (BCPVT022) + /// and accepted with a warning in CompatibleReader (BCPVT023). When a package mixes + /// the modern scope pair with an effective legacy scope field, a single BCPVT024 is + /// emitted for the object and the per-field classification is suppressed. + /// + /// + /// Only the direct root fields of the ResourceType object are considered; property-level + /// or parameter-level flags elsewhere in the document are never read here. + /// + internal static class ResourceScopePolicyValidator + { + // Legacy scope field names, in the deterministic order used when a single mixed-form + // diagnostic must name the first effective legacy field. + private const string ScopeType = "scopeType"; + private const string ReadOnlyScopes = "readOnlyScopes"; + private const string Flags = "flags"; + + public static void Validate( + TypeGraphNode node, + TypePackageValidationOptions options, + List diagnostics) + { + var obj = node.ObjectNode; + var document = node.Document; + string path = document.PackageRelativePath; + var sourceMap = document.SourceMap; + + // Collect present, shape-valid legacy scope fields (wrong-shape fields are owned by + // the structural layer and are skipped here). + bool hasScopeType = PolicyNodeReader.TryGetIntegerProperty(obj, ScopeType, out var scopeTypeProp, out _); + bool hasReadOnlyScopes = PolicyNodeReader.TryGetIntegerProperty(obj, ReadOnlyScopes, out var readOnlyScopesProp, out _); + bool hasFlags = PolicyNodeReader.TryGetIntegerProperty(obj, Flags, out var flagsProp, out long flagsValue); + + if (!hasScopeType && !hasReadOnlyScopes && !hasFlags) + { + return; + } + + bool modernPresent = + PolicyNodeReader.TryGetIntegerProperty(obj, "readableScopes", out _, out _) || + PolicyNodeReader.TryGetIntegerProperty(obj, "writableScopes", out _, out _); + + // An effective legacy scope field is one that a reader would treat as legacy: scopeType + // and readOnlyScopes always count; flags only counts when it is a non-zero flag value. + string? firstEffectiveLegacy = + hasScopeType ? ScopeType : + hasReadOnlyScopes ? ReadOnlyScopes : + (hasFlags && flagsValue != 0) ? Flags : + null; + + if (modernPresent && firstEffectiveLegacy != null) + { + var location = node.Location; + diagnostics.Add(TypeValidationDiagnosticBuilder.ResourceScopeFormMixed( + path, node.JsonPointer, firstEffectiveLegacy, location.Line, location.Column)); + return; + } + + if (hasScopeType) + { + ClassifyField(options, diagnostics, path, node.JsonPointer, ScopeType, scopeTypeProp, sourceMap); + } + + if (hasReadOnlyScopes) + { + ClassifyField(options, diagnostics, path, node.JsonPointer, ReadOnlyScopes, readOnlyScopesProp, sourceMap); + } + + if (hasFlags) + { + ClassifyField(options, diagnostics, path, node.JsonPointer, Flags, flagsProp, sourceMap); + } + } + + private static void ClassifyField( + TypePackageValidationOptions options, + List diagnostics, + string path, + string nodeJsonPointer, + string fieldName, + JsonProperty property, + SourceMap sourceMap) + { + var location = sourceMap.GetLocation(property.NameByteOffset); + string pointer = nodeJsonPointer + "/" + fieldName; + + if (options.Mode == TypePackageValidationMode.CanonicalWriter) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.CanonicalScopeFieldViolation( + path, pointer, fieldName, location.Line, location.Column)); + } + else + { + diagnostics.Add(TypeValidationDiagnosticBuilder.CompatibilityScopeFieldUsed( + path, pointer, fieldName, location.Line, location.Column)); + } + } + } +} diff --git a/src/Bicep.Types.Validation/README.md b/src/Bicep.Types.Validation/README.md new file mode 100644 index 00000000..03bca12f --- /dev/null +++ b/src/Bicep.Types.Validation/README.md @@ -0,0 +1,27 @@ +# Azure.Bicep.Types.Validation + +`Azure.Bicep.Types.Validation` validates serialized Bicep type packages. It can validate an extracted package directory, a raw `index.json` file, or a `types.tgz` archive. + +```csharp +using Azure.Bicep.Types.Validation; + +var validator = new TypePackageValidator(); + +var result = validator.Validate( + TypePackageValidationInput.ForArchiveFile("types.tgz"), + new TypePackageValidationOptions + { + Mode = TypePackageValidationMode.CanonicalWriter, + ValidateUnreachableFiles = true, + }); + +if (!result.IsValid) +{ + foreach (var diagnostic in result.Diagnostics) + { + Console.Error.WriteLine($"{diagnostic.Code}: {diagnostic.Message}"); + } +} +``` + +Use `CanonicalWriter` mode for packages being produced or published. Use `CompatibleReader` mode only when intentionally validating documented legacy package forms that current readers still accept. diff --git a/src/Bicep.Types.Validation/Semantic/ScalarSemanticValidator.cs b/src/Bicep.Types.Validation/Semantic/ScalarSemanticValidator.cs new file mode 100644 index 00000000..400edfbb --- /dev/null +++ b/src/Bicep.Types.Validation/Semantic/ScalarSemanticValidator.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Policy; + +namespace Azure.Bicep.Types.Validation.Semantic +{ + /// + /// Scalar-semantic layer. Runs after semantic-graph validation and before mode policy, over + /// the type files graph traversal reached. It validates value-domain constraints that the + /// structural layer intentionally does not check: numeric range ordering, non-negative length + /// bounds, and enum/flags value domains. Like the policy layer it reads the raw JSON node + /// model (never the deserialized type model) so it can report precise source locations. + /// + /// + /// Range and length rules (BCPVT026/BCPVT027) are unconditional errors. Enum and flags domain + /// rules (BCPVT028/BCPVT029) are mode-aware: an Error in CanonicalWriter and a + /// Warning in CompatibleReader, because new enum values and flag bits are + /// backward-compatible model evolution that a compatible reader must tolerate. Direct-root + /// legacy ResourceType scope fields (scopeType/readOnlyScopes/flags) + /// are owned by the mode-policy layer and are never read here. Wrong-shape (non-integer) + /// fields are owned by the structural layer and are skipped. + /// + internal static class ScalarSemanticValidator + { + // Known masks and value sets from the frozen Bicep.Types model. + private const long ScopeTypeMask = 31; // ScopeType.All + private const long ObjectTypePropertyFlagsMask = 31; // Required|ReadOnly|WriteOnly|DeployTimeConstant|Identifier + private const long NamespaceFunctionParameterFlagsMask = 7; // Required|CompileTimeConstant|DeployTimeConstant + private static readonly long[] BicepSourceFileKindValues = { 1, 2 }; // BicepFile, ParamsFile + + public static IReadOnlyList Validate( + IEnumerable reachedTypeFiles, + TypePackageValidationOptions options) + { + if (reachedTypeFiles == null) { throw new ArgumentNullException(nameof(reachedTypeFiles)); } + if (options == null) { throw new ArgumentNullException(nameof(options)); } + + var diagnostics = new List(); + + // Enum/flags domain violations are backward-compatible evolution: errors for a + // canonical writer, warnings for a compatible reader. + var domainSeverity = options.Mode == TypePackageValidationMode.CanonicalWriter + ? TypeValidationDiagnosticSeverity.Error + : TypeValidationDiagnosticSeverity.Warning; + + foreach (var file in reachedTypeFiles) + { + foreach (var node in file.NodesByIndex) + { + if (node == null) + { + continue; + } + + switch (node.Discriminator) + { + case "IntegerType": + ValidateNumericRange(node, "IntegerType", "minValue", "maxValue", diagnostics); + break; + + case "StringType": + ValidateNumericRange(node, "StringType", "minLength", "maxLength", diagnostics); + ValidateNonNegativeLength(node, "StringType", "minLength", diagnostics); + ValidateNonNegativeLength(node, "StringType", "maxLength", diagnostics); + break; + + case "ArrayType": + ValidateNumericRange(node, "ArrayType", "minLength", "maxLength", diagnostics); + ValidateNonNegativeLength(node, "ArrayType", "minLength", diagnostics); + ValidateNonNegativeLength(node, "ArrayType", "maxLength", diagnostics); + break; + + case "ResourceType": + // Only the modern scope pair participates here; the legacy direct-root + // scope fields are owned by the mode-policy layer. + ValidateFlagsField(node, "readableScopes", ScopeTypeMask, "ResourceType readableScopes", domainSeverity, diagnostics); + ValidateFlagsField(node, "writableScopes", ScopeTypeMask, "ResourceType writableScopes", domainSeverity, diagnostics); + break; + + case "ObjectType": + ValidatePropertyMapFlags(node, "properties", ObjectTypePropertyFlagsMask, "ObjectType property flags", domainSeverity, diagnostics); + break; + + case "DiscriminatedObjectType": + ValidatePropertyMapFlags(node, "baseProperties", ObjectTypePropertyFlagsMask, "DiscriminatedObjectType base property flags", domainSeverity, diagnostics); + break; + + case "NamespaceFunctionType": + ValidateParameterArrayFlags(node, "parameters", NamespaceFunctionParameterFlagsMask, "NamespaceFunctionType parameter flags", domainSeverity, diagnostics); + ValidateEnumField(node, "visibleInFileKind", BicepSourceFileKindValues, "NamespaceFunctionType.visibleInFileKind", domainSeverity, diagnostics); + break; + } + } + } + + return diagnostics; + } + + /// + /// Reports BCPVT026 when both range bounds are present, shape-valid integers, and the + /// minimum exceeds the maximum. The diagnostic points at the maximum field so baselines + /// do not depend on source property order. + /// + private static void ValidateNumericRange( + TypeGraphNode node, string typeName, string minFieldName, string maxFieldName, + List diagnostics) + { + var obj = node.ObjectNode; + if (!PolicyNodeReader.TryGetIntegerProperty(obj, minFieldName, out _, out long min)) { return; } + if (!PolicyNodeReader.TryGetIntegerProperty(obj, maxFieldName, out var maxProperty, out long max)) { return; } + if (min <= max) { return; } + + var location = node.Document.SourceMap.GetLocation(maxProperty.NameByteOffset); + diagnostics.Add(TypeValidationDiagnosticBuilder.NumericRangeInvalid( + node.Document.PackageRelativePath, node.JsonPointer, typeName, + minFieldName, min, maxFieldName, max, location.Line, location.Column)); + } + + /// Reports BCPVT027 when a present, shape-valid length field is negative. + private static void ValidateNonNegativeLength( + TypeGraphNode node, string typeName, string fieldName, List diagnostics) + { + var obj = node.ObjectNode; + if (!PolicyNodeReader.TryGetIntegerProperty(obj, fieldName, out var property, out long value)) { return; } + if (value >= 0) { return; } + + var location = node.Document.SourceMap.GetLocation(property.NameByteOffset); + string pointer = node.JsonPointer + "/" + fieldName; + diagnostics.Add(TypeValidationDiagnosticBuilder.LengthConstraintNegative( + node.Document.PackageRelativePath, pointer, typeName, fieldName, value, location.Line, location.Column)); + } + + /// Reports BCPVT029 when a present, shape-valid direct-root flags field carries bits outside . + private static void ValidateFlagsField( + TypeGraphNode node, string fieldName, long mask, string description, + TypeValidationDiagnosticSeverity severity, List diagnostics) + { + var obj = node.ObjectNode; + if (!PolicyNodeReader.TryGetIntegerProperty(obj, fieldName, out var property, out long value)) { return; } + long unknownBits = value & ~mask; + if (unknownBits == 0) { return; } + + var location = node.Document.SourceMap.GetLocation(property.NameByteOffset); + string pointer = node.JsonPointer + "/" + fieldName; + diagnostics.Add(TypeValidationDiagnosticBuilder.FlagsValueInvalid( + node.Document.PackageRelativePath, pointer, description, unknownBits, mask, severity, location.Line, location.Column)); + } + + /// Reports BCPVT029 for each object-map member whose nested flags field carries bits outside . + private static void ValidatePropertyMapFlags( + TypeGraphNode node, string mapFieldName, long mask, string description, + TypeValidationDiagnosticSeverity severity, List diagnostics) + { + var obj = node.ObjectNode; + if (!obj.TryGetProperty(mapFieldName, out var map) || map.Kind != JsonValueKind.Object) { return; } + + string mapPointer = node.JsonPointer + "/" + mapFieldName; + var sourceMap = node.Document.SourceMap; + string path = node.Document.PackageRelativePath; + + foreach (var member in map.Properties) + { + if (member.Value.Kind != JsonValueKind.Object) { continue; } + if (!PolicyNodeReader.TryGetIntegerProperty(member.Value, "flags", out var flagsProperty, out long value)) { continue; } + long unknownBits = value & ~mask; + if (unknownBits == 0) { continue; } + + var location = sourceMap.GetLocation(flagsProperty.NameByteOffset); + string pointer = mapPointer + "/" + JsonPointerEscape(member.Name) + "/flags"; + diagnostics.Add(TypeValidationDiagnosticBuilder.FlagsValueInvalid( + path, pointer, description, unknownBits, mask, severity, location.Line, location.Column)); + } + } + + /// Reports BCPVT029 for each parameter element whose flags field carries bits outside . + private static void ValidateParameterArrayFlags( + TypeGraphNode node, string arrayFieldName, long mask, string description, + TypeValidationDiagnosticSeverity severity, List diagnostics) + { + var obj = node.ObjectNode; + if (!obj.TryGetProperty(arrayFieldName, out var array) || array.Kind != JsonValueKind.Array) { return; } + + string arrayPointer = node.JsonPointer + "/" + arrayFieldName; + var sourceMap = node.Document.SourceMap; + string path = node.Document.PackageRelativePath; + + for (int i = 0; i < array.Elements.Count; i++) + { + var element = array.Elements[i]; + if (element.Kind != JsonValueKind.Object) { continue; } + if (!PolicyNodeReader.TryGetIntegerProperty(element, "flags", out var flagsProperty, out long value)) { continue; } + long unknownBits = value & ~mask; + if (unknownBits == 0) { continue; } + + var location = sourceMap.GetLocation(flagsProperty.NameByteOffset); + string pointer = arrayPointer + "/" + i + "/flags"; + diagnostics.Add(TypeValidationDiagnosticBuilder.FlagsValueInvalid( + path, pointer, description, unknownBits, mask, severity, location.Line, location.Column)); + } + } + + /// Reports BCPVT028 when a present, shape-valid enum field is outside its documented value set. + private static void ValidateEnumField( + TypeGraphNode node, string fieldName, long[] allowedValues, string qualifiedFieldName, + TypeValidationDiagnosticSeverity severity, List diagnostics) + { + var obj = node.ObjectNode; + if (!PolicyNodeReader.TryGetIntegerProperty(obj, fieldName, out var property, out long value)) { return; } + + foreach (long allowed in allowedValues) + { + if (value == allowed) { return; } + } + + var location = node.Document.SourceMap.GetLocation(property.NameByteOffset); + string pointer = node.JsonPointer + "/" + fieldName; + diagnostics.Add(TypeValidationDiagnosticBuilder.EnumValueInvalid( + node.Document.PackageRelativePath, pointer, qualifiedFieldName, FormatAllowed(allowedValues), value, severity, location.Line, location.Column)); + } + + private static string FormatAllowed(long[] values) + { + string result = string.Empty; + for (int i = 0; i < values.Length; i++) + { + string text = values[i].ToString(CultureInfo.InvariantCulture); + if (i == 0) + { + result = text; + } + else if (i == values.Length - 1) + { + result = result + " or " + text; + } + else + { + result = result + ", " + text; + } + } + return result; + } + + private static string JsonPointerEscape(string token) + { + return token.Replace("~", "~0").Replace("/", "~1"); + } + } +} diff --git a/src/Bicep.Types.Validation/Structural/IndexDocumentValidator.cs b/src/Bicep.Types.Validation/Structural/IndexDocumentValidator.cs new file mode 100644 index 00000000..1c8a3a4b --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/IndexDocumentValidator.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Validates the local shape of the index.json document. + /// Does not resolve or follow type-file references (that belongs to graph validation). + /// + internal static class IndexDocumentValidator + { + // Known top-level fields of index.json + private static readonly string[] KnownTopLevel = new[] + { + "resources", "resourceFunctions", "namespaceFunctions", "settings", "fallbackResourceType" + }; + + public static void Validate(JsonShapeReader reader, StructuralValidationContext context) + { + var doc = context.CurrentDocument; + var root = doc.Root; + + // root must be an object + if (!reader.RequireRootObject(root, out var obj)) + { + return; // phase gate: wrong root shape, do not inspect children + } + + // Required top-level fields + bool hasResources = reader.RequireProperty(obj, string.Empty, "resources", out var resources); + bool hasResourceFunctions = reader.RequireProperty(obj, string.Empty, "resourceFunctions", out var resourceFunctions); + bool hasNamespaceFunctions = reader.RequireProperty(obj, string.Empty, "namespaceFunctions", out var namespaceFunctions); + + // resources must be an object map + if (hasResources) + { + ValidateResourcesMap(reader, context, resources); + } + + // resourceFunctions must be a nested map: resourceType -> apiVersion -> array of refs + if (hasResourceFunctions) + { + ValidateResourceFunctionsMap(reader, context, resourceFunctions); + } + + // namespaceFunctions must be an array of refs + if (hasNamespaceFunctions) + { + ValidateNamespaceFunctionsArray(reader, context, namespaceFunctions); + } + + // Optional: settings + if (obj.TryGetProperty("settings", out var settings)) + { + ValidateSettings(reader, context, settings); + } + + // Optional: fallbackResourceType + if (obj.TryGetProperty("fallbackResourceType", out var fallback)) + { + ReferenceSyntax.Validate(fallback, "fallbackResourceType", "/fallbackResourceType", context); + } + + // Unknown top-level fields. index.json has no documented legacy top-level + // fields, so unknown fields are rejected in both modes. + var knownSet = new HashSet(KnownTopLevel, StringComparer.Ordinal); + foreach (var prop in obj.Properties) + { + if (!knownSet.Contains(prop.Name)) + { + var loc = doc.SourceMap.GetLocation(prop.NameByteOffset); + context.Add(TypeValidationDiagnosticBuilder.UnknownProperty( + doc.PackageRelativePath, string.Empty, prop.Name, loc.Line, loc.Column)); + } + } + } + + private static void ValidateResourcesMap(JsonShapeReader reader, StructuralValidationContext context, JsonValueNode resources) + { + if (!reader.RequireObject(resources, string.Empty, "resources")) + { + return; + } + + foreach (var entry in resources.Properties) + { + string pointer = "/resources/" + JsonPointerEscape(entry.Name); + ReferenceSyntax.Validate(entry.Value, entry.Name, pointer, context); + } + } + + private static void ValidateResourceFunctionsMap(JsonShapeReader reader, StructuralValidationContext context, JsonValueNode resourceFunctions) + { + if (!reader.RequireObject(resourceFunctions, string.Empty, "resourceFunctions")) + { + return; + } + + foreach (var rtEntry in resourceFunctions.Properties) + { + string rtPointer = "/resourceFunctions/" + JsonPointerEscape(rtEntry.Name); + + if (rtEntry.Value.Kind != JsonValueKind.Object) + { + var loc = context.CurrentDocument.SourceMap.GetLocation(rtEntry.Value.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.PropertyTypeMismatch( + context.CurrentDocument.PackageRelativePath, + rtPointer, rtEntry.Name, "object", + DescribeKind(rtEntry.Value.Kind), + loc.Line, loc.Column)); + continue; + } + + foreach (var avEntry in rtEntry.Value.Properties) + { + string avPointer = rtPointer + "/" + JsonPointerEscape(avEntry.Name); + + if (!reader.RequireArray(avEntry.Value, avPointer, avEntry.Name, out var funcRefs)) + { + continue; + } + + for (int i = 0; i < funcRefs.Count; i++) + { + ReferenceSyntax.Validate(funcRefs[i], avEntry.Name + "[" + i + "]", avPointer + "/" + i, context); + } + } + } + } + + private static void ValidateNamespaceFunctionsArray(JsonShapeReader reader, StructuralValidationContext context, JsonValueNode namespaceFunctions) + { + if (!reader.RequireArray(namespaceFunctions, string.Empty, "namespaceFunctions", out var elements)) + { + return; + } + + for (int i = 0; i < elements.Count; i++) + { + ReferenceSyntax.Validate(elements[i], "namespaceFunctions[" + i + "]", "/namespaceFunctions/" + i, context); + } + } + + private static readonly string[] KnownSettingsFields = new[] + { + "name", "version", "isSingleton", "isPreview", "isDeprecated", "configurationType" + }; + + private static void ValidateSettings(JsonShapeReader reader, StructuralValidationContext context, JsonValueNode settings) + { + if (!reader.RequireObject(settings, string.Empty, "settings")) + { + return; + } + + string pointer = "/settings"; + + // Required string fields + if (reader.RequireProperty(settings, pointer, "name", out var name)) + { + reader.RequireString(name, pointer + "/name", "name", out _); + } + + if (reader.RequireProperty(settings, pointer, "version", out var version)) + { + reader.RequireString(version, pointer + "/version", "version", out _); + } + + if (reader.RequireProperty(settings, pointer, "isSingleton", out var isSingleton)) + { + reader.RequireBool(isSingleton, pointer + "/isSingleton", "isSingleton", out _); + } + + // Optional bool-or-null fields (null accepted as read-side leniency) + if (settings.TryGetProperty("isPreview", out var isPreview)) + { + if (isPreview.Kind != JsonValueKind.Null) + { + reader.RequireBool(isPreview, pointer + "/isPreview", "isPreview", out _); + } + } + + if (settings.TryGetProperty("isDeprecated", out var isDeprecated)) + { + if (isDeprecated.Kind != JsonValueKind.Null) + { + reader.RequireBool(isDeprecated, pointer + "/isDeprecated", "isDeprecated", out _); + } + } + + // Optional reference field + if (settings.TryGetProperty("configurationType", out var configurationType)) + { + ReferenceSyntax.Validate(configurationType, "configurationType", pointer + "/configurationType", context); + } + + // Unknown fields in settings. No documented legacy settings fields, so + // unknown fields are rejected in both modes. + var knownSet = new HashSet(KnownSettingsFields, StringComparer.Ordinal); + foreach (var prop in settings.Properties) + { + if (!knownSet.Contains(prop.Name)) + { + var loc = context.CurrentDocument.SourceMap.GetLocation(prop.NameByteOffset); + context.Add(TypeValidationDiagnosticBuilder.UnknownProperty( + context.CurrentDocument.PackageRelativePath, pointer, prop.Name, loc.Line, loc.Column)); + } + } + } + + private static string JsonPointerEscape(string token) + { + return token.Replace("~", "~0").Replace("/", "~1"); + } + + private static string DescribeKind(JsonValueKind kind) => JsonValueKindText.Describe(kind); + } +} diff --git a/src/Bicep.Types.Validation/Structural/JsonShapeReader.cs b/src/Bicep.Types.Validation/Structural/JsonShapeReader.cs new file mode 100644 index 00000000..0ea1a83e --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/JsonShapeReader.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Helper that navigates a tree and produces + /// consistent structural diagnostics. Carries the current document context + /// so callers do not need to pass path/source-map to every check. + /// + internal sealed class JsonShapeReader + { + private readonly StructuralValidationContext context; + + public JsonShapeReader(StructuralValidationContext context) + { + this.context = context ?? throw new ArgumentNullException(nameof(context)); + } + + private PackageDocument Doc => context.CurrentDocument; + private SourceMap SM => Doc.SourceMap; + private string FilePath => Doc.PackageRelativePath; + + // ── Root-shape checks ──────────────────────────────────────────────────── + + /// Returns true if is a JSON object. + public bool RequireRootObject(JsonValueNode root, out JsonValueNode obj) + { + if (root.Kind == JsonValueKind.Object) { obj = root; return true; } + var loc = SM.GetLocation(root.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.IndexRootMustBeObject(FilePath, loc.Line, loc.Column)); + obj = root; + return false; + } + + /// Returns true if is a JSON array. + public bool RequireRootArray(JsonValueNode root, out IReadOnlyList elements) + { + if (root.Kind == JsonValueKind.Array) { elements = root.Elements; return true; } + var loc = SM.GetLocation(root.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.TypeFileRootMustBeArray(FilePath, loc.Line, loc.Column)); + elements = root.Elements; + return false; + } + + // ── Property existence ─────────────────────────────────────────────────── + + /// + /// Looks up a required property. Adds a RequiredPropertyMissing diagnostic on + /// failure and returns false. On success sets and returns + /// true. The containing-object location is used for missing-property diagnostics. + /// + public bool RequireProperty(JsonValueNode obj, string pointer, string name, out JsonValueNode value) + { + if (obj.TryGetProperty(name, out value)) { return true; } + var loc = SM.GetLocation(obj.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.RequiredPropertyMissing(FilePath, pointer, name, loc.Line, loc.Column)); + return false; + } + + // ── Type checks ────────────────────────────────────────────────────────── + + /// Checks that is a JSON string. Returns false and adds diagnostic on failure. + public bool RequireString(JsonValueNode node, string pointer, string name, out string value) + { + if (node.Kind == JsonValueKind.String && node.StringValue != null) + { + value = node.StringValue; + return true; + } + var loc = SM.GetLocation(node.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.PropertyTypeMismatch( + FilePath, pointer, name, "string", DescribeKind(node.Kind), loc.Line, loc.Column)); + value = string.Empty; + return false; + } + + /// Checks that is a JSON boolean. + public bool RequireBool(JsonValueNode node, string pointer, string name, out bool value) + { + if (node.Kind == JsonValueKind.True) { value = true; return true; } + if (node.Kind == JsonValueKind.False) { value = false; return true; } + var loc = SM.GetLocation(node.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.PropertyTypeMismatch( + FilePath, pointer, name, "boolean", DescribeKind(node.Kind), loc.Line, loc.Column)); + value = false; + return false; + } + + /// Checks that is a JSON integer (a number with no fractional part). + public bool RequireInteger(JsonValueNode node, string pointer, string name, out long value) + { + if (node.Kind == JsonValueKind.Number && node.IsValidInt64) + { + value = node.Int64Value; + return true; + } + var loc = SM.GetLocation(node.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.PropertyTypeMismatch( + FilePath, pointer, name, "integer", DescribeKind(node.Kind), loc.Line, loc.Column)); + value = 0; + return false; + } + + /// Checks that is a JSON object. + public bool RequireObject(JsonValueNode node, string pointer, string name) + { + if (node.Kind == JsonValueKind.Object) { return true; } + var loc = SM.GetLocation(node.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.PropertyTypeMismatch( + FilePath, pointer, name, "object", DescribeKind(node.Kind), loc.Line, loc.Column)); + return false; + } + + /// Checks that is a JSON array. + public bool RequireArray(JsonValueNode node, string pointer, string name, out IReadOnlyList elements) + { + if (node.Kind == JsonValueKind.Array) { elements = node.Elements; return true; } + var loc = SM.GetLocation(node.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.PropertyTypeMismatch( + FilePath, pointer, name, "array", DescribeKind(node.Kind), loc.Line, loc.Column)); + elements = node.Elements; + return false; + } + + // ── Optional property helpers ──────────────────────────────────────────── + + /// + /// Checks a field whose JSON type is determined by . + /// Called for each field descriptor in the type-shape catalog. + /// + public void CheckFieldShape(JsonValueNode fieldValue, string fieldPointer, TypeFieldDescriptor descriptor) + { + switch (descriptor.Shape) + { + case FieldShape.String: + RequireString(fieldValue, fieldPointer, descriptor.Name, out _); + break; + case FieldShape.Bool: + // Accept null as intentional read-side leniency for bool? fields + if (fieldValue.Kind != JsonValueKind.Null) + { + RequireBool(fieldValue, fieldPointer, descriptor.Name, out _); + } + break; + case FieldShape.Integer: + RequireInteger(fieldValue, fieldPointer, descriptor.Name, out _); + break; + case FieldShape.Ref: + ReferenceSyntax.Validate(fieldValue, descriptor.Name, fieldPointer, context); + break; + case FieldShape.ArrayOfRefs: + if (RequireArray(fieldValue, fieldPointer, descriptor.Name, out var refElements)) + { + for (int i = 0; i < refElements.Count; i++) + { + ReferenceSyntax.Validate(refElements[i], descriptor.Name + "[" + i + "]", fieldPointer + "/" + i, context); + } + } + break; + case FieldShape.ObjectMap: + RequireObject(fieldValue, fieldPointer, descriptor.Name); + break; + case FieldShape.ArrayOfObjects: + if (RequireArray(fieldValue, fieldPointer, descriptor.Name, out var objElements)) + { + for (int i = 0; i < objElements.Count; i++) + { + if (objElements[i].Kind != JsonValueKind.Object) + { + var loc = SM.GetLocation(objElements[i].ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.PropertyTypeMismatch( + FilePath, fieldPointer + "/" + i, descriptor.Name + "[" + i + "]", + "object", DescribeKind(objElements[i].Kind), loc.Line, loc.Column)); + } + } + } + break; + } + } + + // ── Location helper ────────────────────────────────────────────────────── + + public SourceLocation GetLocation(JsonValueNode node) => SM.GetLocation(node.ByteOffset); + public SourceLocation GetKeyLocation(JsonProperty prop) => SM.GetLocation(prop.NameByteOffset); + + // ── String description helpers ─────────────────────────────────────────── + + private static string DescribeKind(JsonValueKind kind) => JsonValueKindText.Describe(kind); + } +} diff --git a/src/Bicep.Types.Validation/Structural/ReferenceSyntax.cs b/src/Bicep.Types.Validation/Structural/ReferenceSyntax.cs new file mode 100644 index 00000000..297e0888 --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/ReferenceSyntax.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Validates the syntax of reference objects ({"$ref": "path#/index"}) and + /// produces structural diagnostics. Uses for the actual + /// string parsing so that and structural validation share + /// the same parser without coupling layers. + /// + internal static class ReferenceSyntax + { + /// + /// Validates a JSON value node that is expected to be a reference object. + /// Adds diagnostics to on failure and returns + /// . + /// + public static ReferenceSyntaxResult Validate( + JsonValueNode node, + string propertyName, + string jsonPointer, + StructuralValidationContext context) + { + if (node == null) { throw new ArgumentNullException(nameof(node)); } + + var doc = context.CurrentDocument; + var sm = doc.SourceMap; + var path = doc.PackageRelativePath; + + if (node.Kind != JsonValueKind.Object) + { + var loc = sm.GetLocation(node.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.ReferenceObjectInvalid( + path, jsonPointer, propertyName, + $"expected an object with a '$ref' property, got {DescribeKind(node.Kind)}", + loc.Line, loc.Column)); + return ReferenceSyntaxResult.Invalid; + } + + // Must have $ref + if (!node.TryGetProperty("$ref", out var refNode)) + { + var loc = sm.GetLocation(node.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.ReferenceObjectInvalid( + path, jsonPointer, propertyName, + "the object is missing the required '$ref' property", + loc.Line, loc.Column)); + return ReferenceSyntaxResult.Invalid; + } + + // $ref must be a string + if (refNode.Kind != JsonValueKind.String) + { + var loc = sm.GetLocation(refNode.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.ReferenceObjectInvalid( + path, jsonPointer, propertyName, + $"the '$ref' property must be a string, got {DescribeKind(refNode.Kind)}", + loc.Line, loc.Column)); + return ReferenceSyntaxResult.Invalid; + } + + string refValue = refNode.StringValue ?? string.Empty; + + // Validate the $ref string syntax + if (!ReferencePath.TryParse(refValue, out string packagePath, out int index)) + { + var loc = sm.GetLocation(refNode.ByteOffset); + string reason = DescribeRefSyntaxError(refValue); + context.Add(TypeValidationDiagnosticBuilder.ReferenceSyntaxInvalid( + path, jsonPointer + "/$ref", refValue, reason, loc.Line, loc.Column)); + return ReferenceSyntaxResult.Invalid; + } + + // Reject package paths that are not safe relative paths: rooted paths + // ("/tmp/...", "C:/...") or paths containing ".." traversal (via '/' or '\'). + if (IsUnsafePackagePath(packagePath)) + { + var loc = sm.GetLocation(refNode.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.ReferenceSyntaxInvalid( + path, jsonPointer + "/$ref", refValue, + "the package path must be a relative path without '..' segments", + loc.Line, loc.Column)); + return ReferenceSyntaxResult.Invalid; + } + + // Reject any property other than $ref on a reference object. A reference + // object has no documented legacy fields, so this applies in both modes. + foreach (var prop in node.Properties) + { + if (!string.Equals(prop.Name, "$ref", StringComparison.Ordinal)) + { + var loc = sm.GetLocation(prop.NameByteOffset); + context.Add(TypeValidationDiagnosticBuilder.UnknownProperty( + path, jsonPointer, prop.Name, loc.Line, loc.Column)); + } + } + + return ReferenceSyntaxResult.Valid(packagePath, index); + } + + private static bool IsUnsafePackagePath(string packagePath) + { + if (string.IsNullOrEmpty(packagePath)) { return false; } + + // Normalize Windows separators so both '/' and '\' traversal are checked. + string normalized = packagePath.Replace('\\', '/'); + + // Reject rooted paths: a leading '/' (POSIX / UNC) or a drive-qualified + // path such as "C:/temp/...". + if (normalized.StartsWith("/", StringComparison.Ordinal)) + { + return true; + } + if (normalized.Length >= 2 && normalized[1] == ':') + { + return true; + } + + // Reject any ".." traversal segment. + foreach (string segment in normalized.Split('/')) + { + if (segment == "..") { return true; } + } + return false; + } + + private static string DescribeKind(JsonValueKind kind) => JsonValueKindText.Describe(kind); + + private static string DescribeRefSyntaxError(string refValue) + { + if (string.IsNullOrEmpty(refValue)) + { + return "the reference string is empty"; + } + if (!refValue.Contains("#/")) + { + return "the reference string must contain a '#/' fragment separator"; + } + int sep = refValue.IndexOf("#/", StringComparison.Ordinal); + string indexText = refValue.Substring(sep + 2); + if (string.IsNullOrEmpty(indexText)) + { + return "the fragment index must be a non-negative integer"; + } + if (int.TryParse(indexText, out int idx) && idx < 0) + { + return "the fragment index must be non-negative"; + } + return "the reference string does not match the expected '#/' format"; + } + } +} diff --git a/src/Bicep.Types.Validation/Structural/ReferenceSyntaxResult.cs b/src/Bicep.Types.Validation/Structural/ReferenceSyntaxResult.cs new file mode 100644 index 00000000..8fe5474d --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/ReferenceSyntaxResult.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Parsed result from validating a single reference object. + /// Carries the package-relative path and integer index when valid. + /// + internal sealed class ReferenceSyntaxResult + { + private ReferenceSyntaxResult(bool isValid, string packageRelativePath, int index) + { + IsValid = isValid; + PackageRelativePath = packageRelativePath; + Index = index; + } + + /// true when the reference object is structurally valid. + public bool IsValid { get; } + + /// + /// Package-relative path of the referenced file. + /// Empty string for same-file references. + /// Only meaningful when is true. + /// + public string PackageRelativePath { get; } + + /// + /// 0-based integer index within the referenced file. + /// Only meaningful when is true. + /// + public int Index { get; } + + internal static ReferenceSyntaxResult Valid(string packageRelativePath, int index) => + new ReferenceSyntaxResult(true, packageRelativePath, index); + + internal static readonly ReferenceSyntaxResult Invalid = + new ReferenceSyntaxResult(false, string.Empty, -1); + } +} diff --git a/src/Bicep.Types.Validation/Structural/StructuralValidationContext.cs b/src/Bicep.Types.Validation/Structural/StructuralValidationContext.cs new file mode 100644 index 00000000..8f8431b4 --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/StructuralValidationContext.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Mutable context object threaded through all structural validators during a single + /// validation run. Accumulates diagnostics and provides read access to the current + /// document and validation options. + /// + internal sealed class StructuralValidationContext + { + private readonly List diagnostics = new List(); + private PackageDocument currentDocument = null!; + + public StructuralValidationContext(TypePackageValidationOptions options) + { + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// Validation options for this run. + public TypePackageValidationOptions Options { get; } + + /// Active validation mode. + public TypePackageValidationMode Mode => Options.Mode; + + /// true when the mode is . + public bool IsCanonicalWriter => Options.Mode == TypePackageValidationMode.CanonicalWriter; + + /// The document currently being validated. + public PackageDocument CurrentDocument => currentDocument; + + /// Sets the document currently being validated. + public void SetCurrentDocument(PackageDocument document) + { + currentDocument = document ?? throw new ArgumentNullException(nameof(document)); + } + + /// Adds a diagnostic to the collection. + public void Add(TypeValidationDiagnostic diagnostic) + { + if (diagnostic != null) + { + diagnostics.Add(diagnostic); + } + } + + /// Returns all accumulated diagnostics. + public IReadOnlyList GetDiagnostics() + { + return diagnostics; + } + } +} diff --git a/src/Bicep.Types.Validation/Structural/StructuralValidator.cs b/src/Bicep.Types.Validation/Structural/StructuralValidator.cs new file mode 100644 index 00000000..f83eaa13 --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/StructuralValidator.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Coordinates structural validation of a . + /// Validates the index document, then each type-file document. + /// Phase gates prevent cascading diagnostics when a document is unusable. + /// + internal static class StructuralValidator + { + public static IReadOnlyList Validate( + JsonDocumentSet documents, + TypePackageValidationOptions options) + { + if (documents == null) { throw new ArgumentNullException(nameof(documents)); } + if (options == null) { throw new ArgumentNullException(nameof(options)); } + + var context = new StructuralValidationContext(options); + var reader = new JsonShapeReader(context); + + // Phase gate: index document must be available and structurally usable + if (documents.IndexDocument == null) + { + return context.GetDiagnostics(); + } + + context.SetCurrentDocument(documents.IndexDocument); + IndexDocumentValidator.Validate(reader, context); + + // Phase gate: if index root was not an object, skip type-file validation + if (documents.IndexDocument.Root.Kind != JsonValueKind.Object) + { + return context.GetDiagnostics(); + } + + // Validate each type file + foreach (var typeFile in documents.TypeFiles) + { + context.SetCurrentDocument(typeFile); + TypeFileValidator.Validate(reader, context); + } + + return context.GetDiagnostics(); + } + + /// + /// Structurally validates a single type-file document in isolation and returns its + /// diagnostics. Used by the graph layer, which loads type files on demand and needs + /// each file validated exactly once as it is discovered. + /// + public static IReadOnlyList ValidateTypeFileDocument( + PackageDocument typeFile, + TypePackageValidationOptions options) + { + if (typeFile == null) { throw new ArgumentNullException(nameof(typeFile)); } + if (options == null) { throw new ArgumentNullException(nameof(options)); } + + var context = new StructuralValidationContext(options); + var reader = new JsonShapeReader(context); + + context.SetCurrentDocument(typeFile); + TypeFileValidator.Validate(reader, context); + + return context.GetDiagnostics(); + } + } +} diff --git a/src/Bicep.Types.Validation/Structural/TypeFileValidator.cs b/src/Bicep.Types.Validation/Structural/TypeFileValidator.cs new file mode 100644 index 00000000..80cd22cc --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/TypeFileValidator.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Validates the local shape of one type file (an array of type objects). + /// + internal static class TypeFileValidator + { + public static void Validate(JsonShapeReader reader, StructuralValidationContext context) + { + var doc = context.CurrentDocument; + var root = doc.Root; + var sm = doc.SourceMap; + var path = doc.PackageRelativePath; + + // root must be an array + if (!reader.RequireRootArray(root, out var elements)) + { + return; // phase gate + } + + for (int i = 0; i < elements.Count; i++) + { + var element = elements[i]; + string elementPointer = "/" + i; + + // each element must be an object + if (element.Kind != JsonValueKind.Object) + { + var loc = sm.GetLocation(element.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.TypeFileElementMustBeObject( + path, elementPointer, loc.Line, loc.Column)); + continue; // phase gate: do not inspect non-objects as type objects + } + + TypeObjectValidator.Validate(element, elementPointer, reader, context); + } + } + } +} diff --git a/src/Bicep.Types.Validation/Structural/TypeObjectValidator.cs b/src/Bicep.Types.Validation/Structural/TypeObjectValidator.cs new file mode 100644 index 00000000..360ceb46 --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/TypeObjectValidator.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Validates one type object (one element of a type-file array). + /// Checks the $type discriminator, required fields, field shapes, and + /// unknown properties. Delegates field-shape checking to + /// and . + /// + internal static class TypeObjectValidator + { + public static void Validate( + JsonValueNode obj, + string jsonPointer, + JsonShapeReader reader, + StructuralValidationContext context) + { + var doc = context.CurrentDocument; + var path = doc.PackageRelativePath; + var sm = doc.SourceMap; + + // $type must be present + if (!obj.TryGetProperty("$type", out var discriminatorNode)) + { + var loc = sm.GetLocation(obj.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.TypeObjectDiscriminatorMissing( + path, jsonPointer, loc.Line, loc.Column)); + return; // phase gate + } + + // $type must be a string + if (discriminatorNode.Kind != JsonValueKind.String) + { + var loc = sm.GetLocation(discriminatorNode.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.TypeObjectDiscriminatorMustBeString( + path, jsonPointer + "/$type", loc.Line, loc.Column)); + return; // phase gate + } + + string discriminator = discriminatorNode.StringValue ?? string.Empty; + + // $type must name a supported kind + var descriptor = TypeShapeCatalog.GetDescriptor(discriminator); + if (descriptor == null) + { + var loc = sm.GetLocation(discriminatorNode.ByteOffset); + context.Add(TypeValidationDiagnosticBuilder.TypeObjectDiscriminatorUnsupported( + path, jsonPointer + "/$type", discriminator, loc.Line, loc.Column)); + return; // phase gate + } + + // Validate each described field. + // knownNames drives the unknown-property check below. Documented legacy fields are + // known in both modes: the mode-policy layer (phase 4) owns their acceptance or + // rejection, so structural validation no longer reports them as unknown. + var knownNames = new List(descriptor.Fields.Count); + + // CompatibleReader narrow relaxation: when a ResourceType uses a legacy scope field, + // the modern scope pair (readableScopes/writableScopes) is no longer required, mirroring + // the reader which accepts either the modern pair or a documented legacy form. + bool relaxModernScopeRequirement = + !context.IsCanonicalWriter && + string.Equals(descriptor.Discriminator, "ResourceType", StringComparison.Ordinal) && + (obj.TryGetProperty("scopeType", out _) || + obj.TryGetProperty("readOnlyScopes", out _) || + obj.TryGetProperty("flags", out _)); + + foreach (var field in descriptor.Fields) + { + knownNames.Add(field.Name); + + bool required = field.Required; + if (relaxModernScopeRequirement && + (string.Equals(field.Name, "readableScopes", StringComparison.Ordinal) || + string.Equals(field.Name, "writableScopes", StringComparison.Ordinal))) + { + required = false; + } + + if (required) + { + if (!reader.RequireProperty(obj, jsonPointer, field.Name, out var fieldValue)) + { + continue; // already reported, skip shape check + } + reader.CheckFieldShape(fieldValue, jsonPointer + "/" + field.Name, field); + } + else + { + if (obj.TryGetProperty(field.Name, out var fieldValue)) + { + reader.CheckFieldShape(fieldValue, jsonPointer + "/" + field.Name, field); + } + } + } + + // Unknown property check. Runs in both modes: genuinely unknown fields are + // rejected everywhere. Documented legacy fields are always in knownNames and are + // therefore accepted structurally in both modes (policy classifies them). + var knownSet = new HashSet(knownNames, StringComparer.Ordinal); + knownSet.Add("$type"); + + foreach (var prop in obj.Properties) + { + if (!knownSet.Contains(prop.Name)) + { + var loc = sm.GetLocation(prop.NameByteOffset); + context.Add(TypeValidationDiagnosticBuilder.UnknownProperty( + path, jsonPointer, prop.Name, loc.Line, loc.Column)); + } + } + } + } +} diff --git a/src/Bicep.Types.Validation/Structural/TypeShapeCatalog.cs b/src/Bicep.Types.Validation/Structural/TypeShapeCatalog.cs new file mode 100644 index 00000000..c036a0f2 --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/TypeShapeCatalog.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Descriptor for one supported type-object kind, including all field metadata. + /// + internal sealed class TypeKindDescriptor + { + public TypeKindDescriptor(string discriminator, TypeFieldDescriptor[] fields) + { + Discriminator = discriminator; + Fields = fields ?? new TypeFieldDescriptor[0]; + } + + /// The $type discriminator string, e.g. "ResourceType". + public string Discriminator { get; } + + /// All known fields for this type kind (required and optional). + public IReadOnlyList Fields { get; } + } + + /// + /// Central catalog that maps $type discriminator values to their structural + /// shape descriptors. Matches the [JsonDerivedType] registrations on + /// TypeBase exactly; intentionally excludes enum values such as ScopeType + /// which are field values, not discriminators. + /// + internal static class TypeShapeCatalog + { + private static readonly Dictionary Catalog = BuildCatalog(); + + private static readonly string[] AllKeys; + + static TypeShapeCatalog() + { + AllKeys = new string[Catalog.Count]; + Catalog.Keys.CopyTo(AllKeys, 0); + } + + /// Returns the descriptor for a discriminator, or null if unknown. + public static TypeKindDescriptor? GetDescriptor(string discriminator) + { + return Catalog.TryGetValue(discriminator, out var d) ? d : null; + } + + /// All supported $type discriminator values. + public static IReadOnlyList AllDiscriminators => AllKeys; + + private static Dictionary BuildCatalog() + { + var catalog = new Dictionary(); + + // Simple scalar types with no fields beyond $type + Add(catalog, "AnyType"); + Add(catalog, "NullType"); + Add(catalog, "BooleanType"); + + // IntegerType: optional minValue/maxValue + Add(catalog, "IntegerType", + Opt("minValue", FieldShape.Integer), + Opt("maxValue", FieldShape.Integer)); + + // StringType: optional constraints + Add(catalog, "StringType", + Opt("sensitive", FieldShape.Bool), + Opt("minLength", FieldShape.Integer), + Opt("maxLength", FieldShape.Integer), + Opt("pattern", FieldShape.String)); + + // StringLiteralType: required value + Add(catalog, "StringLiteralType", + Req("value", FieldShape.String)); + + // ObjectType: required name + properties, optional additionalProperties + sensitive + Add(catalog, "ObjectType", + Req("name", FieldShape.String), + Req("properties", FieldShape.ObjectMap), + Opt("additionalProperties", FieldShape.Ref), + Opt("sensitive", FieldShape.Bool)); + + // ArrayType: required itemType, optional length constraints + Add(catalog, "ArrayType", + Req("itemType", FieldShape.Ref), + Opt("minLength", FieldShape.Integer), + Opt("maxLength", FieldShape.Integer)); + + // UnionType: required elements array of refs + Add(catalog, "UnionType", + Req("elements", FieldShape.ArrayOfRefs)); + + // DiscriminatedObjectType + Add(catalog, "DiscriminatedObjectType", + Req("name", FieldShape.String), + Req("discriminator", FieldShape.String), + Req("baseProperties", FieldShape.ObjectMap), + Req("elements", FieldShape.ObjectMap)); + + // FunctionType: required parameters (array of objects) and output (ref) + Add(catalog, "FunctionType", + Req("parameters", FieldShape.ArrayOfObjects), + Req("output", FieldShape.Ref)); + + // ResourceFunctionType + Add(catalog, "ResourceFunctionType", + Req("name", FieldShape.String), + Req("resourceType", FieldShape.String), + Req("apiVersion", FieldShape.String), + Req("output", FieldShape.Ref), + Opt("input", FieldShape.Ref)); + + // NamespaceFunctionType + Add(catalog, "NamespaceFunctionType", + Req("name", FieldShape.String), + Req("parameters", FieldShape.ArrayOfObjects), + Req("outputType", FieldShape.Ref), + Opt("description", FieldShape.String), + Opt("evaluatedLanguageExpression", FieldShape.String), + Opt("visibleInFileKind", FieldShape.Integer)); + + // ResourceType: modern scope fields required; legacy scope fields are compat-only + Add(catalog, "ResourceType", + Req("name", FieldShape.String), + Req("body", FieldShape.Ref), + Req("readableScopes", FieldShape.Integer), + Req("writableScopes", FieldShape.Integer), + Opt("functions", FieldShape.ObjectMap), + // legacy fields: accepted in CompatibleReader, rejected as unknown in CanonicalWriter + Legacy("scopeType", FieldShape.Integer), + Legacy("readOnlyScopes", FieldShape.Integer), + Legacy("flags", FieldShape.Integer)); + + // BuiltInType: required kind integer (enum values 1-8) + Add(catalog, "BuiltInType", + Req("kind", FieldShape.Integer)); + + return catalog; + } + + private static void Add(Dictionary catalog, string discriminator, params TypeFieldDescriptor[] fields) + { + catalog[discriminator] = new TypeKindDescriptor(discriminator, fields); + } + + private static TypeFieldDescriptor Req(string name, FieldShape shape) => + new TypeFieldDescriptor(name, shape, required: true); + + private static TypeFieldDescriptor Opt(string name, FieldShape shape) => + new TypeFieldDescriptor(name, shape, required: false); + + private static TypeFieldDescriptor Legacy(string name, FieldShape shape) => + new TypeFieldDescriptor(name, shape, required: false, legacyCompatOnly: true); + } +} diff --git a/src/Bicep.Types.Validation/Structural/TypeShapeRule.cs b/src/Bicep.Types.Validation/Structural/TypeShapeRule.cs new file mode 100644 index 00000000..22920696 --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/TypeShapeRule.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation.Structural +{ + /// + /// Expected JSON value shape for a type-object field. + /// + internal enum FieldShape + { + /// JSON string value. + String, + + /// JSON boolean value. + Bool, + + /// JSON number value (integer). + Integer, + + /// A reference object: {"$ref": "..."}. + Ref, + + /// JSON array where each element is a reference object. + ArrayOfRefs, + + /// JSON object (map); deep-validation of each value is deferred. + ObjectMap, + + /// JSON array of objects; deep-validation of elements is deferred. + ArrayOfObjects, + } + + /// + /// Descriptor for one field of a type object kind. + /// + internal sealed class TypeFieldDescriptor + { + public TypeFieldDescriptor(string name, FieldShape shape, bool required, bool legacyCompatOnly = false) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Shape = shape; + Required = required; + LegacyCompatOnly = legacyCompatOnly; + } + + /// JSON property name (camelCase). + public string Name { get; } + + /// Expected JSON shape for this field. + public FieldShape Shape { get; } + + /// true if the field must be present. + public bool Required { get; } + + /// + /// true if this field is a documented legacy-compatibility field accepted by + /// CompatibleReader but rejected as unknown in CanonicalWriter. + /// + public bool LegacyCompatOnly { get; } + } +} diff --git a/src/Bicep.Types.Validation/TypePackageFormatVersion.cs b/src/Bicep.Types.Validation/TypePackageFormatVersion.cs new file mode 100644 index 00000000..91fd8c09 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageFormatVersion.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Identifies the serialized Bicep Types package format that validation rules target. + /// + /// + /// Format version is independent of : the version selects + /// which serialized package format rules apply, while the mode selects canonical-writer versus + /// compatible-reader policy for that format. is the current and only + /// supported format. It must remain the zero value so default(TypePackageFormatVersion) + /// selects the supported current format; future additions must not reorder or renumber it. + /// + public enum TypePackageFormatVersion + { + /// The current serialized Bicep Types package format (bicep-types-v1). + BicepTypesV1 = 0, + } + + /// + /// Facts about values used by the validator. + /// + internal static class TypePackageFormatVersionFacts + { + /// Whether the validator can validate packages of the given format version. + public static bool IsSupported(TypePackageFormatVersion version) => + version == TypePackageFormatVersion.BicepTypesV1; + } +} diff --git a/src/Bicep.Types.Validation/TypePackageValidationInput.cs b/src/Bicep.Types.Validation/TypePackageValidationInput.cs new file mode 100644 index 00000000..0380ccd5 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageValidationInput.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using Azure.Bicep.Types.Validation.Packaging; + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Discriminated public input model describing where a type package is located. + /// + /// + /// The hierarchy is closed: instances are created through the static factory methods. + /// Directory, raw index.json, and gzip-compressed tar archive inputs are all validated. + /// + public abstract class TypePackageValidationInput + { + private protected TypePackageValidationInput(string displayPath) + { + DisplayPath = displayPath ?? throw new ArgumentNullException(nameof(displayPath)); + } + + /// Display path preserved for diagnostics and result output. + public string DisplayPath { get; } + + internal abstract PackageInputKind Kind { get; } + + /// Creates an input pointing at an extracted package directory. + public static TypePackageValidationInput ForDirectory(string path) => new DirectoryValidationInput(path); + + /// Creates an input pointing at a raw index.json file. + public static TypePackageValidationInput ForIndexFile(string path) => new IndexFileValidationInput(path); + + /// Creates an input pointing at a types.tgz archive file. + public static TypePackageValidationInput ForArchiveFile(string path) => new ArchiveFileValidationInput(path); + + /// Creates an input reading a types.tgz archive from a stream. + public static TypePackageValidationInput ForArchiveStream(Stream content, string displayPath) => + new ArchiveStreamValidationInput(content, displayPath); + } + + internal sealed class DirectoryValidationInput : TypePackageValidationInput + { + public DirectoryValidationInput(string path) + : base(path) + { + Path = path ?? throw new ArgumentNullException(nameof(path)); + } + + public string Path { get; } + + internal override PackageInputKind Kind => PackageInputKind.Directory; + } + + internal sealed class IndexFileValidationInput : TypePackageValidationInput + { + public IndexFileValidationInput(string path) + : base(path) + { + Path = path ?? throw new ArgumentNullException(nameof(path)); + } + + public string Path { get; } + + internal override PackageInputKind Kind => PackageInputKind.IndexFile; + } + + internal sealed class ArchiveFileValidationInput : TypePackageValidationInput + { + public ArchiveFileValidationInput(string path) + : base(path) + { + Path = path ?? throw new ArgumentNullException(nameof(path)); + } + + public string Path { get; } + + internal override PackageInputKind Kind => PackageInputKind.ArchiveFile; + } + + internal sealed class ArchiveStreamValidationInput : TypePackageValidationInput + { + public ArchiveStreamValidationInput(Stream content, string displayPath) + : base(displayPath) + { + Content = content ?? throw new ArgumentNullException(nameof(content)); + } + + public Stream Content { get; } + + internal override PackageInputKind Kind => PackageInputKind.ArchiveStream; + } +} diff --git a/src/Bicep.Types.Validation/TypePackageValidationMode.cs b/src/Bicep.Types.Validation/TypePackageValidationMode.cs new file mode 100644 index 00000000..025b1966 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageValidationMode.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Selects the validation policy applied to a type package. + /// + public enum TypePackageValidationMode + { + /// + /// Enforces the canonical serialized form that writers must emit. + /// + CanonicalWriter, + + /// + /// Accepts documented legacy forms that readers must continue to tolerate. + /// + CompatibleReader, + } +} diff --git a/src/Bicep.Types.Validation/TypePackageValidationOptions.cs b/src/Bicep.Types.Validation/TypePackageValidationOptions.cs new file mode 100644 index 00000000..a5ee8822 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageValidationOptions.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Options controlling a validation run. + /// + public sealed class TypePackageValidationOptions + { + private int? maxDiagnostics; + + /// Validation mode. Defaults to . + public TypePackageValidationMode Mode { get; set; } = TypePackageValidationMode.CanonicalWriter; + + /// + /// Serialized package format version to validate against. Defaults to + /// . Format version is independent of + /// . Selecting a version the validator does not support produces a single + /// BCPVT036 error before any package input is read. + /// + public TypePackageFormatVersion FormatVersion { get; set; } = TypePackageFormatVersion.BicepTypesV1; + + /// Whether warning diagnostics are returned. Defaults to true. + public bool IncludeWarnings { get; set; } = true; + + /// Whether informational diagnostics are returned. Defaults to false. + public bool IncludeInformationalDiagnostics { get; set; } + + /// Whether files unreachable from graph roots are validated. Defaults to false. + public bool ValidateUnreachableFiles { get; set; } + + /// + /// Optional cap on the number of returned diagnostics. null means no cap. + /// Truncation occurs only when this is a positive value and the number of + /// diagnostics to return exceeds it. Negative values are rejected. + /// + /// Thrown when set to a negative value. + public int? MaxDiagnostics + { + get => maxDiagnostics; + set + { + if (value.HasValue && value.Value < 0) + { + throw new ArgumentOutOfRangeException( + nameof(value), + value, + "MaxDiagnostics must be null (no cap) or a non-negative value."); + } + + maxDiagnostics = value; + } + } + } +} diff --git a/src/Bicep.Types.Validation/TypePackageValidationResult.cs b/src/Bicep.Types.Validation/TypePackageValidationResult.cs new file mode 100644 index 00000000..c5ae24d5 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageValidationResult.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Azure.Bicep.Types.Validation.Diagnostics; + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Result of a validation run. + /// + public sealed class TypePackageValidationResult + { + private static readonly IReadOnlyList NoDiagnostics = + new TypeValidationDiagnostic[0]; + + public TypePackageValidationResult( + bool isValid, + TypePackageValidationMode mode, + IReadOnlyList diagnostics, + bool diagnosticsTruncated, + TypePackageValidationSummary summary) + { + IsValid = isValid; + Mode = mode; + Diagnostics = diagnostics ?? NoDiagnostics; + DiagnosticsTruncated = diagnosticsTruncated; + Summary = summary ?? throw new ArgumentNullException(nameof(summary)); + } + + /// + /// Whether the package is valid. This is based on all detected error + /// diagnostics and is independent of filtering and truncation. + /// + public bool IsValid { get; } + + /// The mode the result was produced for. + public TypePackageValidationMode Mode { get; } + + /// The returned diagnostics after filtering and truncation. + public IReadOnlyList Diagnostics { get; } + + /// Whether the returned diagnostics were truncated. + public bool DiagnosticsTruncated { get; } + + /// Counts across all detected diagnostics, before filtering and truncation. + public TypePackageValidationSummary Summary { get; } + + /// + /// Composes a result from the full set of detected diagnostics and the run options. + /// Diagnostics are sorted deterministically; the summary counts all detected + /// diagnostics; reflects any detected error; and filtering + /// and truncation affect only the returned diagnostics list. + /// + public static TypePackageValidationResult Create( + TypePackageValidationMode mode, + IEnumerable detectedDiagnostics, + TypePackageValidationOptions options) + { + if (detectedDiagnostics is null) + { + throw new ArgumentNullException(nameof(detectedDiagnostics)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var all = detectedDiagnostics.ToList(); + all.Sort(TypeValidationDiagnosticComparer.Instance); + + var errorCount = all.Count(d => d.Severity == TypeValidationDiagnosticSeverity.Error); + var warningCount = all.Count(d => d.Severity == TypeValidationDiagnosticSeverity.Warning); + var infoCount = all.Count(d => d.Severity == TypeValidationDiagnosticSeverity.Info); + + var isValid = errorCount == 0; + + IEnumerable filtered = all; + if (!options.IncludeWarnings) + { + filtered = filtered.Where(d => d.Severity != TypeValidationDiagnosticSeverity.Warning); + } + + if (!options.IncludeInformationalDiagnostics) + { + filtered = filtered.Where(d => d.Severity != TypeValidationDiagnosticSeverity.Info); + } + + var returned = filtered.ToList(); + + var truncated = false; + if (options.MaxDiagnostics is int max && max > 0 && returned.Count > max) + { + returned = returned.Take(max).ToList(); + truncated = true; + } + + var summary = new TypePackageValidationSummary(errorCount, warningCount, infoCount); + return new TypePackageValidationResult(isValid, mode, returned, truncated, summary); + } + } +} diff --git a/src/Bicep.Types.Validation/TypePackageValidationSummary.cs b/src/Bicep.Types.Validation/TypePackageValidationSummary.cs new file mode 100644 index 00000000..b3050628 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageValidationSummary.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Diagnostic counts by severity across all detected diagnostics, before any + /// filtering or truncation is applied. + /// + public sealed class TypePackageValidationSummary + { + public TypePackageValidationSummary(int errorCount, int warningCount, int infoCount) + { + ErrorCount = errorCount; + WarningCount = warningCount; + InfoCount = infoCount; + } + + /// Total number of detected error diagnostics. + public int ErrorCount { get; } + + /// Total number of detected warning diagnostics. + public int WarningCount { get; } + + /// Total number of detected informational diagnostics. + public int InfoCount { get; } + } +} diff --git a/src/Bicep.Types.Validation/TypePackageValidator.cs b/src/Bicep.Types.Validation/TypePackageValidator.cs new file mode 100644 index 00000000..1b59c628 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageValidator.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Bicep.Types.Validation.Diagnostics; +using Azure.Bicep.Types.Validation.Graph; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Structural; + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Public entry point for validating a Bicep type package. + /// + /// + /// The validator reads real package files and runs structural, semantic, graph, and package-hygiene + /// validation for directory, raw index.json, and gzip-compressed tar archive + /// (types.tgz) inputs. Archive inputs are opened as an in-memory package file system so the + /// same validators run regardless of input form. The legacy BCPVT001 diagnostic is retained + /// for API stability but is no longer emitted. + /// + public sealed class TypePackageValidator + { + /// Validates the package described by using default options. + public TypePackageValidationResult Validate(TypePackageValidationInput input) => + Validate(input, options: null); + + /// Validates the package described by . + public TypePackageValidationResult Validate(TypePackageValidationInput input, TypePackageValidationOptions? options) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + var effectiveOptions = options ?? new TypePackageValidationOptions(); + + var diagnostics = new List(); + + // Phase 7: gate on the selected format version before touching any input. This must run + // before PackageInputResolver.Resolve because resolving an ArchiveStream input eagerly + // reads the caller's stream into memory; an unsupported version must not consume it. + if (!TypePackageFormatVersionFacts.IsSupported(effectiveOptions.FormatVersion)) + { + diagnostics.Add(TypeValidationDiagnosticBuilder.UnsupportedFormatVersion(effectiveOptions.FormatVersion)); + return TypePackageValidationResult.Create(effectiveOptions.Mode, diagnostics, effectiveOptions); + } + + var resolution = PackageInputResolver.Resolve(input); + diagnostics.AddRange(resolution.Diagnostics); + + // Read package files. Archive inputs are opened as an in-memory package file system; + // directory and index-file inputs are read from disk. Fatal container/read failures + // (including malformed archives and unsafe archive members) short-circuit here. + var readResult = PackageReader.Read(resolution, effectiveOptions); + diagnostics.AddRange(readResult.Diagnostics); + + if (readResult.HasFatalReadFailure) + { + return TypePackageValidationResult.Create(effectiveOptions.Mode, diagnostics, effectiveOptions); + } + + // Phase 2: structural validation + var structuralDiagnostics = StructuralValidator.Validate(readResult.Documents, effectiveOptions); + diagnostics.AddRange(structuralDiagnostics); + + // Phase 3: semantic graph validation. Requires the index document and a package + // file system; the graph layer loads and structurally validates type files on demand. + var indexDocument = readResult.Documents.IndexDocument; + if (indexDocument != null && readResult.FileSystem != null) + { + var provider = new PackageDocumentProvider(readResult.FileSystem, indexDocument, effectiveOptions); + var graphDiagnostics = SemanticGraphValidator.Validate( + provider, indexDocument, effectiveOptions, out var visited); + diagnostics.AddRange(graphDiagnostics); + + // Phase 5: scalar-semantic validation (value-domain constraints) over the type + // files graph traversal reached, before mode policy. + var scalarDiagnostics = Semantic.ScalarSemanticValidator.Validate( + provider.GetReachedUsableTypeFiles(), effectiveOptions); + diagnostics.AddRange(scalarDiagnostics); + + // Phase 4: mode-policy validation over the type files graph traversal reached. + var policyDiagnostics = Policy.PolicyValidator.Validate( + provider.GetReachedUsableTypeFiles(), effectiveOptions); + diagnostics.AddRange(policyDiagnostics); + + // Phase 6: strict package hygiene. Opt-in; validates files unreachable from + // index.json roots and reports unreachable/unexpected package members. + if (effectiveOptions.ValidateUnreachableFiles) + { + var hygieneDiagnostics = Hygiene.PackageHygieneValidator.Validate( + readResult.FileSystem, provider, effectiveOptions, visited); + diagnostics.AddRange(hygieneDiagnostics); + } + } + + return TypePackageValidationResult.Create(effectiveOptions.Mode, diagnostics, effectiveOptions); + } + } +} +