diff --git a/bicep-types.sln b/bicep-types.sln index 9a5ad3c2..f26f00d7 100644 --- a/bicep-types.sln +++ b/bicep-types.sln @@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types", "src\Bicep.Ty 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", "src\Bicep.Types.Validation\Bicep.Types.Validation.csproj", "{7A411E4E-3A69-470D-B40D-162EC37A7F2B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types.Validation.UnitTests", "src\Bicep.Types.Validation.UnitTests\Bicep.Types.Validation.UnitTests.csproj", "{B022427D-777D-4BF5-A500-EA4B964BB3CC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,9 +30,19 @@ Global {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 + {7A411E4E-3A69-470D-B40D-162EC37A7F2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A411E4E-3A69-470D-B40D-162EC37A7F2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A411E4E-3A69-470D-B40D-162EC37A7F2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A411E4E-3A69-470D-B40D-162EC37A7F2B}.Release|Any CPU.Build.0 = Release|Any CPU + {B022427D-777D-4BF5-A500-EA4B964BB3CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B022427D-777D-4BF5-A500-EA4B964BB3CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B022427D-777D-4BF5-A500-EA4B964BB3CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B022427D-777D-4BF5-A500-EA4B964BB3CC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {2C152898-5869-453F-8EE1-8B531A031B19} = {DD29C249-759E-4F30-B9B1-A47CB337F870} {E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF} = {DD29C249-759E-4F30-B9B1-A47CB337F870} + {7A411E4E-3A69-470D-B40D-162EC37A7F2B} = {DD29C249-759E-4F30-B9B1-A47CB337F870} + {B022427D-777D-4BF5-A500-EA4B964BB3CC} = {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/TypeValidationDiagnosticCodesTests.cs b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticCodesTests.cs new file mode 100644 index 00000000..04b82270 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticCodesTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using System.Reflection; +using Azure.Bicep.Types.Validation.Diagnostics; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Bicep.Types.Validation.UnitTests.Diagnostics; + +[TestClass] +public class TypeValidationDiagnosticCodesTests +{ + [TestMethod] + public void Active_codes_are_unique_and_contiguous() + { + var codes = typeof(TypeValidationDiagnosticCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.IsLiteral && !field.IsInitOnly && field.FieldType == typeof(string)) + .Select(field => (string)field.GetRawConstantValue()!) + .OrderBy(code => code) + .ToArray(); + + var expectedCodes = Enumerable.Range(1, 35) + .Select(number => $"BCPVT{number:D3}"); + + codes.Should().Equal(expectedCodes); + } +} \ No newline at end of file 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..f730942a --- /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": "BCPVT021", + "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..b8aa8d6e --- /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": "BCPVT022", + "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..f1b9aed8 --- /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": "BCPVT021", + "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..28f5f04b --- /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": "BCPVT022", + "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..ae6c8df5 --- /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": "BCPVT009", + "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": "BCPVT009", + "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": "BCPVT021", + "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..46500026 --- /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": "BCPVT022", + "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..f485ec30 --- /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": "BCPVT021", + "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..d80493ce --- /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": "BCPVT022", + "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..f00dda06 --- /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": "BCPVT009", + "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": "BCPVT009", + "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": "BCPVT021", + "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": "BCPVT021", + "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..887516a8 --- /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": "BCPVT022", + "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": "BCPVT022", + "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..63c70321 --- /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": "BCPVT009", + "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": "BCPVT009", + "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": "BCPVT021", + "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..90299462 --- /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": "BCPVT022", + "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..63c70321 --- /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": "BCPVT009", + "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": "BCPVT009", + "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": "BCPVT021", + "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..90299462 --- /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": "BCPVT022", + "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..2c4c41db --- /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": "BCPVT025", + "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..25961d8d --- /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": "BCPVT001", + "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..eff6d258 --- /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": "BCPVT016", + "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..911b2694 --- /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 BCPVT016. 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..c034d0f7 --- /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": "BCPVT019", + "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..db7742fb --- /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": "BCPVT020", + "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..8534bbc0 --- /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": "BCPVT019", + "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..00fb81bd --- /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": "BCPVT020", + "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": "BCPVT020", + "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..da3f424a --- /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 BCPVT020.", + "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..9377a3a5 --- /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": "BCPVT016", + "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..d62c92db --- /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": "BCPVT019", + "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..eed3e7b3 --- /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": "BCPVT020", + "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..c088365a --- /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": "BCPVT020", + "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..9bae6d51 --- /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": "BCPVT019", + "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..e723347d --- /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": "BCPVT018", + "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..90a65a1c --- /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": "BCPVT019", + "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..31c7a831 --- /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": "BCPVT034", + "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..834d458d --- /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 BCPVT034.", + "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..c13ea701 --- /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": "BCPVT033", + "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..6845b3de --- /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 BCPVT033.", + "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..cc3b76f6 --- /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": "BCPVT024", + "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..06fea6b4 --- /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": "BCPVT024", + "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..a205662f --- /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 (BCPVT024).","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..9bb5602a --- /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": "BCPVT023", + "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..42c482a5 --- /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": "BCPVT023", + "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..d6606417 --- /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 (BCPVT023).","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..f37bc4ec --- /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": "BCPVT023", + "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..ea4b6bd5 --- /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": "BCPVT023", + "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..60c8d264 --- /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 (BCPVT023).","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..a1e1e67a --- /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": "BCPVT025", + "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..6f724e16 --- /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 (BCPVT025); 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..a89920a9 --- /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": "BCPVT025", + "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..bb45254c --- /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 (BCPVT025); 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..1422b06c --- /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": "BCPVT028", + "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..a4f99923 --- /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": "BCPVT028", + "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..466048e0 --- /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 (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/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..eb0b765f --- /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": "BCPVT028", + "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..6d92276e --- /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": "BCPVT028", + "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..06d822e1 --- /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 (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/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..8f44a5a3 --- /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": "BCPVT026", + "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..368d5144 --- /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 (BCPVT026); 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..0142b6d3 --- /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": "BCPVT027", + "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..fb477491 --- /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": "BCPVT027", + "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..ac97c3fa --- /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 (BCPVT027): 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..a0523839 --- /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": "BCPVT011", + "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..73c3f5ab --- /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": "BCPVT011", + "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..3db51edc --- /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": "BCPVT010", + "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..4b266f71 --- /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": "BCPVT010", + "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..59b6d577 --- /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": "BCPVT003", + "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..ee8e32e9 --- /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": "BCPVT003", + "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..19fb9ff0 --- /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": "BCPVT013", + "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..d05bb12a --- /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": "BCPVT002", + "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..e05ba340 --- /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": "BCPVT002", + "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..b918678d --- /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": "BCPVT001", + "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..843b5bec --- /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": "BCPVT001", + "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..051b736c --- /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": "BCPVT013", + "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..15ae6577 --- /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": "BCPVT012", + "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..8c35c995 --- /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": "BCPVT012", + "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..b829c371 --- /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": "BCPVT011", + "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..d7c46d5e --- /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": "BCPVT011", + "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..6dc29d0c --- /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": "BCPVT011", + "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..3f2c3417 --- /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": "BCPVT011", + "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..820668c6 --- /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": "BCPVT005", + "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..702e6a65 --- /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": "BCPVT005", + "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..b61b4985 --- /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": "BCPVT017", + "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": "BCPVT004", + "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..ad99c61b --- /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": "BCPVT017", + "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": "BCPVT004", + "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..99dc49fe --- /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": "BCPVT006", + "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..03af7f80 --- /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": "BCPVT006", + "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..124c4fcb --- /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": "BCPVT019", + "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": "BCPVT009", + "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..602d4a36 --- /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": "BCPVT019", + "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": "BCPVT009", + "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..b7429682 --- /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": "BCPVT008", + "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..d48723a2 --- /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": "BCPVT008", + "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..743a1687 --- /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": "BCPVT019", + "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": "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..665ad821 --- /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": "BCPVT019", + "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": "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..e447982f --- /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_bcpvt016() + { + 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_bcpvt018() + { + 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_bcpvt019_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_bcpvt020() + { + 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 BCPVT019 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_bcpvt019() + { + 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_bcpvt019() + { + 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_bcpvt019() + { + 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_bcpvt019() + { + 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_bcpvt020() + { + 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_bcpvt020() + { + 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..e1bff857 --- /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 (BCPVT013); 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..207a5cb6 --- /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_bcpvt001() + { + 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_bcpvt029() + { + 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_bcpvt029() + { + // 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_bcpvt031() + { + 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_bcpvt032() + { + 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_bcpvt032() + { + 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_bcpvt030() + { + 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_bcpvt030() + { + 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_bcpvt030() + { + 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_bcpvt030() + { + 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_bcpvt016() + { + 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..974cffca --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Packaging/DirectoryPackageFileSystemTests.cs @@ -0,0 +1,165 @@ +// 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(); + + if (Path.DirectorySeparatorChar != '\\') + { + var misleadingWindowsPath = Path.Combine(dir.Path, "C:", "Windows", "System32"); + Directory.CreateDirectory(misleadingWindowsPath); + File.WriteAllText(Path.Combine(misleadingWindowsPath, "cmd.exe"), string.Empty); + } + + var fs = new DirectoryPackageFileSystem(dir.Path); + + // Package paths are portable, so both Unix and Windows absolute forms are always rejected. + 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(); + } + + [TestMethod] + public void Case_variant_sibling_does_not_pass_root_containment_on_case_sensitive_file_systems() + { + if (Path.DirectorySeparatorChar == '\\') + { + return; + } + + using var parent = new TempDir(); + var root = Path.Combine(parent.Path, "package"); + var sibling = Path.Combine(parent.Path, "PACKAGE"); + Directory.CreateDirectory(root); + Directory.CreateDirectory(sibling); + File.WriteAllText(Path.Combine(sibling, "secret.txt"), string.Empty); + + var fs = new DirectoryPackageFileSystem(root); + + fs.FileExists("../PACKAGE/secret.txt").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..7b9a17e9 --- /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 (BCPVT016). + 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..333edb8d --- /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_bcpvt021_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_bcpvt022_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_bcpvt021_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_bcpvt024_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_bcpvt024_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..624d9a78 --- /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_bcpvt021_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_bcpvt022_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_bcpvt021_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_bcpvt022_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 (BCPVT021), not BCPVT023. + 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_bcpvt023_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_bcpvt023_in_compatible() + { + // BCPVT023 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_bcpvt023_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..059cc366 --- /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\":[]}"; + + // ── BCPVT025: numeric range ordering ───────────────────────────────────── + + [TestMethod] + public void Integer_min_greater_than_max_reports_bcpvt025() + { + 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_bcpvt025() + { + 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_bcpvt025() + { + 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"); + } + + // ── BCPVT026: non-negative length ──────────────────────────────────────── + + [TestMethod] + public void Negative_string_length_reports_bcpvt026() + { + 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_bcpvt026() + { + 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"); + } + + // ── BCPVT028: scope flag domains ───────────────────────────────────────── + + [TestMethod] + public void Readable_scope_with_unknown_bits_reports_bcpvt028_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_bcpvt028_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 BCPVT028 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(); + } + + // ── BCPVT028: object/parameter flag domains ────────────────────────────── + + [TestMethod] + public void Object_property_flags_with_unknown_bits_reports_bcpvt028_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_bcpvt028_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); + } + + // ── BCPVT027: enum membership ──────────────────────────────────────────── + + [TestMethod] + public void Visible_in_file_kind_unknown_value_reports_bcpvt027_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/TypeDocumentValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Structural/TypeDocumentValidatorTests.cs new file mode 100644 index 00000000..34a62ed8 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/Structural/TypeDocumentValidatorTests.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 TypeDocumentValidatorTests +{ + 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}"); + TypeDocumentValidator.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]"); + TypeDocumentValidator.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]"); + TypeDocumentValidator.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\"}]"); + TypeDocumentValidator.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}]"); + TypeDocumentValidator.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\"}]"); + TypeDocumentValidator.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\"}]"); + TypeDocumentValidator.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}]"); + TypeDocumentValidator.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}]"); + TypeDocumentValidator.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); + TypeDocumentValidator.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); + TypeDocumentValidator.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); + TypeDocumentValidator.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}]"); + TypeDocumentValidator.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\"}]"); + TypeDocumentValidator.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\"}]"); + TypeDocumentValidator.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\"}]"); + TypeDocumentValidator.Validate(reader, ctx); + ctx.GetDiagnostics().Should().BeEmpty(); + } +} \ No newline at end of file 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..035f0bc2 --- /dev/null +++ b/src/Bicep.Types.Validation.UnitTests/TypePackageValidatorTests.cs @@ -0,0 +1,343 @@ +// 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 BCPVT022 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..a78d6c90 --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticBuilder.cs @@ -0,0 +1,532 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Azure.Bicep.Types.Validation.Diagnostics +{ + /// + /// Factory for validation diagnostics with stable codes and messages. + /// Every new diagnostic code should be added in the sequential order. + /// + public static class TypeValidationDiagnosticBuilder + { + // ── 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); + } + + // ── 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); + } + + // ── 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); + } + + // ── 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); + } + + // ── 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); + } + + // ── 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); + } + + // ── 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..3b727194 --- /dev/null +++ b/src/Bicep.Types.Validation/Diagnostics/TypeValidationDiagnosticCodes.cs @@ -0,0 +1,127 @@ +// 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 + { + /// The package root directory does not contain index.json. + public const string IndexFileMissing = "BCPVT001"; + + /// A package JSON file is not syntactically valid JSON. + public const string JsonSyntaxInvalid = "BCPVT002"; + + /// The root value of index.json is not a JSON object. + public const string IndexRootMustBeObject = "BCPVT003"; + + /// The root value of a type file is not a JSON array. + public const string TypeFileRootMustBeArray = "BCPVT004"; + + /// An element in a type-file array is not a JSON object. + public const string TypeFileElementMustBeObject = "BCPVT005"; + + /// A type object does not contain a $type discriminator field. + public const string TypeObjectDiscriminatorMissing = "BCPVT006"; + + /// The $type discriminator field is not a string. + public const string TypeObjectDiscriminatorMustBeString = "BCPVT007"; + + /// The $type discriminator names a type kind that is not supported. + public const string TypeObjectDiscriminatorUnsupported = "BCPVT008"; + + /// A required property is missing from the JSON object. + public const string RequiredPropertyMissing = "BCPVT009"; + + /// A property has the wrong JSON value type (e.g. string where integer expected). + public const string PropertyTypeMismatch = "BCPVT010"; + + /// A reference value is not a well-formed reference object. + public const string ReferenceObjectInvalid = "BCPVT011"; + + /// A reference string does not match the expected path#/index syntax. + public const string ReferenceSyntaxInvalid = "BCPVT012"; + + /// An unexpected property was found on a JSON object. + public const string UnknownProperty = "BCPVT013"; + + /// A package file named by a reference could not be read. + public const string PackageFileReadFailed = "BCPVT014"; + + /// The supplied package path does not point to a valid directory or file. + public const string PackagePathInvalid = "BCPVT015"; + + // ── Phase 3: semantic graph ────────────────────────────────────────────── + + /// A reference targets a type file that does not exist in the package. + public const string ReferencedTypeFileMissing = "BCPVT016"; + + /// A reference targets a type file that could not be parsed or is not a usable type-file array. + public const string ReferencedTypeFileUnusable = "BCPVT017"; + + /// A reference names a type-object index that is out of range for the target file. + public const string ReferenceIndexOutOfRange = "BCPVT018"; + + /// A top-level index.json root reference resolves to the wrong type-object kind. + public const string TopLevelTargetKindMismatch = "BCPVT019"; + + /// A nested type-object reference resolves to a kind not allowed for its role. + public const string NestedTargetKindMismatch = "BCPVT020"; + + // ── Phase 4: mode policy ───────────────────────────────────────────────── + + /// The package uses a documented legacy form that canonical writers must not emit. + public const string CanonicalFormViolation = "BCPVT021"; + + /// A compatible reader accepted a documented legacy form (warning). + public const string CompatibilityFormUsed = "BCPVT022"; + + /// A ResourceType mixes modern scope fields with effective legacy scope fields. + public const string ResourceScopeFormMixed = "BCPVT023"; + + /// BuiltInType.kind is outside the documented serialized enum range. + public const string BuiltInTypeKindInvalid = "BCPVT024"; + + // ── Phase 5: semantic constraints ──────────────────────────────────────── + + /// A numeric range constraint has its minimum greater than its maximum. + public const string NumericRangeInvalid = "BCPVT025"; + + /// A length constraint (minLength/maxLength) is negative. + public const string LengthConstraintNegative = "BCPVT026"; + + /// An enum-valued field is outside its documented value set for this validator version. + public const string EnumValueInvalid = "BCPVT027"; + + /// A flags-valued field contains bits outside its known mask for this validator version. + public const string FlagsValueInvalid = "BCPVT028"; + + // ── 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 = "BCPVT029"; + + /// An archive member has an invalid package-relative path or an unsupported entry type. + public const string ArchiveMemberPathInvalid = "BCPVT030"; + + /// The archive contains the same canonical package-relative file path more than once. + public const string ArchiveMemberDuplicate = "BCPVT031"; + + /// Two distinct archive member names collide after canonical path normalization. + public const string ArchiveMemberPathCollision = "BCPVT032"; + + /// A package file is not reachable from index.json roots under strict hygiene validation. + public const string UnreachablePackageFile = "BCPVT033"; + + /// A strict package scan found an unsupported package member, such as a non-JSON regular file. + public const string UnexpectedPackageFile = "BCPVT034"; + + // ── Phase 7: format version awareness ──────────────────────────────────── + + /// The selected package format version is not supported by this validator. + public const string UnsupportedFormatVersion = "BCPVT035"; + } +} 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..e49376eb --- /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..aad1444b --- /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 (BCPVT013); + // 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..54dfe571 --- /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 BCPVT016, + // BCPVT017, BCPVT018, 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/ITypePackageValidator.cs b/src/Bicep.Types.Validation/ITypePackageValidator.cs new file mode 100644 index 00000000..ac24d77b --- /dev/null +++ b/src/Bicep.Types.Validation/ITypePackageValidator.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Bicep.Types.Validation +{ + /// + /// Validates Bicep type packages. + /// + public interface ITypePackageValidator + { + /// Validates the package described by using default options. + TypePackageValidationResult Validate(TypePackageValidationInput input); + + /// Validates the package described by . + TypePackageValidationResult Validate(TypePackageValidationInput input, TypePackageValidationOptions? options); + } +} \ No newline at end of file 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..b03fd4f2 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/DirectoryPackageFileSystem.cs @@ -0,0 +1,147 @@ +// 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 static readonly StringComparison PhysicalPathComparison = + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + 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; + } + + packageRelativePath = NormalizeSeparators(packageRelativePath); + + // Reject absolute paths + if (IsPackagePathRooted(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, PhysicalPathComparison) && + !combined.StartsWith(rootFull + Path.AltDirectorySeparatorChar, PhysicalPathComparison) && + !string.Equals(combined, rootFull, PhysicalPathComparison)) + { + 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); + + private static bool IsPackagePathRooted(string path) => + Path.IsPathRooted(path) || + (path.Length >= 2 && path[1] == ':' && IsAsciiLetter(path[0])); + + private static bool IsAsciiLetter(char value) => + (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z'); + + /// Normalizes a package-relative path to use forward slashes. + public static string NormalizeSeparators(string path) => + path.Replace('\\', '/'); + } +} 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..a14d938b --- /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..634544ec --- /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. + /// + 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..1005f781 --- /dev/null +++ b/src/Bicep.Types.Validation/Packaging/PackageReader.cs @@ -0,0 +1,147 @@ +// 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..4c871ed5 --- /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 BCPVT029 + /// 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..cd0c0d55 --- /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 (BCPVT009/BCPVT010). + 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..692e87cc --- /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..a17da197 --- /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 (BCPVT021) + /// and accepted with a warning in CompatibleReader (BCPVT022). When a package mixes + /// the modern scope pair with an effective legacy scope field, a single BCPVT023 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..1c28949b --- /dev/null +++ b/src/Bicep.Types.Validation/README.md @@ -0,0 +1,92 @@ +# Azure.Bicep.Types.Validation + +`Azure.Bicep.Types.Validation` validates serialized Bicep type packages before they are published or consumed. It checks package layout, JSON structure, cross-file references, etc. Diagnostics are returned as structured data with stable codes, severity, message and source locations. + +The validator accepts: + +- An extracted package directory. +- A raw `index.json` file. +- A `types.tgz` archive from a file or stream. + +## Usage + +Reference the `Azure.Bicep.Types.Validation` package, create an input, and pass it to an `ITypePackageValidator`: + +```csharp +using Azure.Bicep.Types.Validation; + +var input = TypePackageValidationInput.ForArchiveFile("types.tgz"); +var options = new TypePackageValidationOptions +{ + Mode = TypePackageValidationMode.CanonicalWriter, + ValidateUnreachableFiles = true, +}; + +ITypePackageValidator validator = new TypePackageValidator(); +var result = validator.Validate(input, options); + +foreach (var diagnostic in result.Diagnostics) +{ + Console.Error.WriteLine( + $"{diagnostic.Path}{diagnostic.JsonPointer}: " + + $"{diagnostic.Code} {diagnostic.Severity}: {diagnostic.Message}"); +} + +if (!result.IsValid) +{ + Environment.ExitCode = 1; +} +``` + +Applications can register `TypePackageValidator` as the implementation of `ITypePackageValidator` for dependency injection. Callers that do not use dependency injection can continue to instantiate `TypePackageValidator` directly. + +Choose the input factory that matches the package source: + +```csharp +TypePackageValidationInput.ForDirectory("path/to/package"); +TypePackageValidationInput.ForIndexFile("path/to/package/index.json"); +TypePackageValidationInput.ForArchiveFile("path/to/types.tgz"); +TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"); +``` + +Directory and archive inputs must contain an `index.json` at the package root; otherwise validation reports `BCPVT001`. In both input forms, type files that are not reachable from `index.json` are ignored by default; set `ValidateUnreachableFiles` to report and validate them. + +### Validation modes + +- `CanonicalWriter` enforces the serialized form that package producers should emit. This is the default and the recommended mode for publishing workflows. +- `CompatibleReader` accepts documented legacy forms that readers continue to support. Some canonical errors are reported as warnings in this mode. + +Mode and format version are independent. `BicepTypesV1` is the current supported format and the default value of `TypePackageValidationOptions.FormatVersion`. + +### Options and results + +`TypePackageValidationOptions` also controls warning and informational diagnostic inclusion, validation of unreachable package files, and the maximum number of returned diagnostics. Package hygiene is opt-in through `ValidateUnreachableFiles` because it examines files outside the graph reachable from `index.json`. + +`TypePackageValidationResult` provides: + +- `IsValid`, based on every detected error before filtering or truncation. +- `Diagnostics`, sorted deterministically after the requested filtering and limit are applied. +- `DiagnosticsTruncated`, indicating that `MaxDiagnostics` shortened the returned list. +- `Summary`, containing error, warning, and informational counts from the complete validation run. + +## Project structure + +`TypePackageValidator` coordinates the validation pipeline. The main folders align with each stage: + +| Area | Responsibility | +| --- | --- | +| `Packaging/` | Resolves inputs, reads package files, and safely expands archive contents into an in-memory file system. | +| `Structural/` | Validates JSON document shapes, required fields, discriminators, and reference syntax. | +| `Graph/` | Loads referenced type files and validates cross-file targets and reachability. | +| `Semantic/` | Checks scalar values, ranges, flags, and other value-domain constraints. | +| `Policy/` | Applies canonical-writer and compatible-reader format policy. | +| `Hygiene/` | Validates unreachable files and unexpected package members when enabled. | +| `Diagnostics/` | Defines diagnostic codes, severities, locations, builders, and deterministic ordering. | + +Public input, option, mode, version, result, and summary types live at the project root. Tests and the structured sample corpus are maintained in `Bicep.Types.Validation.UnitTests`. + +## Validation behavior + +Validation collects diagnostics across independent stages when the package can be read safely. Fatal input or archive errors stop later stages because no usable package model is available. Archive member paths are checked before extraction, and archive validation uses the same structural and semantic pipeline as directory validation. + +Diagnostic codes are the stable integration surface for automation. Messages and source locations provide review context. diff --git a/src/Bicep.Types.Validation/Semantic/ScalarSemanticValidator.cs b/src/Bicep.Types.Validation/Semantic/ScalarSemanticValidator.cs new file mode 100644 index 00000000..5b8979c6 --- /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 (BCPVT025/BCPVT026) are unconditional errors. Enum and flags domain + /// rules (BCPVT027/BCPVT028) 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 BCPVT025 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 BCPVT026 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 BCPVT028 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 BCPVT028 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 BCPVT028 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 BCPVT027 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..4a054d0b --- /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 which 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..a948e8f1 --- /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..95562314 --- /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); + TypeDocumentValidator.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); + TypeDocumentValidator.Validate(reader, context); + + return context.GetDiagnostics(); + } + } +} diff --git a/src/Bicep.Types.Validation/Structural/TypeDocumentValidator.cs b/src/Bicep.Types.Validation/Structural/TypeDocumentValidator.cs new file mode 100644 index 00000000..188d1e72 --- /dev/null +++ b/src/Bicep.Types.Validation/Structural/TypeDocumentValidator.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 TypeDocumentValidator + { + 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); + } + } + } +} \ No newline at end of file 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..85f25aac --- /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. + /// + /// + /// 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..65d69e82 --- /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 + /// BCPVT035 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..3d8d9060 --- /dev/null +++ b/src/Bicep.Types.Validation/TypePackageValidator.cs @@ -0,0 +1,104 @@ +// 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.Hygiene; +using Azure.Bicep.Types.Validation.Packaging; +using Azure.Bicep.Types.Validation.Policy; +using Azure.Bicep.Types.Validation.Semantic; +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. + /// + public sealed class TypePackageValidator : ITypePackageValidator + { + /// 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(); + + // 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); + } + + // Structural validation + var structuralDiagnostics = StructuralValidator.Validate(readResult.Documents, effectiveOptions); + diagnostics.AddRange(structuralDiagnostics); + + // 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); + + // Scalar-semantic validation (value-domain constraints) over the type + // files graph traversal reached, before mode policy. + var scalarDiagnostics = ScalarSemanticValidator.Validate( + provider.GetReachedUsableTypeFiles(), effectiveOptions); + diagnostics.AddRange(scalarDiagnostics); + + // Mode-policy validation over the type files graph traversal reached. + var policyDiagnostics = 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 = PackageHygieneValidator.Validate( + readResult.FileSystem, provider, effectiveOptions, visited); + diagnostics.AddRange(hygieneDiagnostics); + } + } + + return TypePackageValidationResult.Create(effectiveOptions.Mode, diagnostics, effectiveOptions); + } + } +}