diff --git a/bicep-types.sln b/bicep-types.sln
index 9a5ad3c2..8a914816 100644
--- a/bicep-types.sln
+++ b/bicep-types.sln
@@ -7,8 +7,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DD29C249-759
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types", "src\Bicep.Types\Bicep.Types.csproj", "{2C152898-5869-453F-8EE1-8B531A031B19}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types.Validation", "src\Bicep.Types.Validation\Bicep.Types.Validation.csproj", "{6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types.UnitTests", "src\Bicep.Types.UnitTests\Bicep.Types.UnitTests.csproj", "{E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bicep.Types.Validation.UnitTests", "src\Bicep.Types.Validation.UnitTests\Bicep.Types.Validation.UnitTests.csproj", "{7E894B3C-B6EF-4B42-B616-076558A7C446}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -22,13 +26,23 @@ Global
{2C152898-5869-453F-8EE1-8B531A031B19}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C152898-5869-453F-8EE1-8B531A031B19}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C152898-5869-453F-8EE1-8B531A031B19}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B}.Release|Any CPU.Build.0 = Release|Any CPU
{E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7E894B3C-B6EF-4B42-B616-076558A7C446}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7E894B3C-B6EF-4B42-B616-076558A7C446}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7E894B3C-B6EF-4B42-B616-076558A7C446}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7E894B3C-B6EF-4B42-B616-076558A7C446}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{2C152898-5869-453F-8EE1-8B531A031B19} = {DD29C249-759E-4F30-B9B1-A47CB337F870}
+ {6F4E2A1B-9C3D-4A5E-8B7F-0D1C2E3F4A5B} = {DD29C249-759E-4F30-B9B1-A47CB337F870}
{E5EC02E2-C7BF-48C8-A2E9-DDB3C37F3DFF} = {DD29C249-759E-4F30-B9B1-A47CB337F870}
+ {7E894B3C-B6EF-4B42-B616-076558A7C446} = {DD29C249-759E-4F30-B9B1-A47CB337F870}
EndGlobalSection
EndGlobal
diff --git a/src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj b/src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj
new file mode 100644
index 00000000..1a407221
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypePackageValidationResultTests.cs b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypePackageValidationResultTests.cs
new file mode 100644
index 00000000..63af2c1c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypePackageValidationResultTests.cs
@@ -0,0 +1,146 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Diagnostics;
+
+[TestClass]
+public class TypePackageValidationResultTests
+{
+ [TestMethod]
+ public void Result_is_valid_when_there_are_no_error_diagnostics()
+ {
+ var result = Create(new[] { Warning() }, new TypePackageValidationOptions());
+
+ result.IsValid.Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void Result_is_invalid_when_an_error_diagnostic_exists()
+ {
+ var result = Create(new[] { Error() }, new TypePackageValidationOptions());
+
+ result.IsValid.Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void Summary_counts_all_detected_diagnostics_before_filtering_and_truncation()
+ {
+ var options = new TypePackageValidationOptions
+ {
+ IncludeWarnings = false,
+ IncludeInformationalDiagnostics = false,
+ MaxDiagnostics = 1,
+ };
+
+ var result = Create(new[] { Error(), Warning(), Information() }, options);
+
+ result.Summary.ErrorCount.Should().Be(1);
+ result.Summary.WarningCount.Should().Be(1);
+ result.Summary.InfoCount.Should().Be(1);
+ }
+
+ [TestMethod]
+ public void IsValid_reflects_detected_errors_even_when_filtered_or_truncated()
+ {
+ var options = new TypePackageValidationOptions { MaxDiagnostics = 1 };
+
+ var result = Create(new[] { Error(), Warning(), Warning() }, options);
+
+ result.IsValid.Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void Warning_filtering_affects_only_returned_diagnostics()
+ {
+ var options = new TypePackageValidationOptions { IncludeWarnings = false };
+
+ var result = Create(new[] { Warning(), Error() }, options);
+
+ result.Diagnostics.Should().OnlyContain(d => d.Severity != TypeValidationDiagnosticSeverity.Warning);
+ result.Summary.WarningCount.Should().Be(1);
+ }
+
+ [TestMethod]
+ public void Informational_diagnostics_are_excluded_by_default_but_counted()
+ {
+ var result = Create(new[] { Information() }, new TypePackageValidationOptions());
+
+ result.Diagnostics.Should().BeEmpty();
+ result.Summary.InfoCount.Should().Be(1);
+ }
+
+ [TestMethod]
+ public void Informational_diagnostics_are_returned_when_requested()
+ {
+ var options = new TypePackageValidationOptions { IncludeInformationalDiagnostics = true };
+
+ var result = Create(new[] { Information() }, options);
+
+ result.Diagnostics.Should().ContainSingle();
+ }
+
+ [TestMethod]
+ public void Null_max_diagnostics_applies_no_cap()
+ {
+ var options = new TypePackageValidationOptions { MaxDiagnostics = null };
+
+ var result = Create(new[] { Error("BCPVT100", "a.json"), Error("BCPVT101", "b.json"), Error("BCPVT102", "c.json") }, options);
+
+ result.DiagnosticsTruncated.Should().BeFalse();
+ result.Diagnostics.Should().HaveCount(3);
+ }
+
+ [TestMethod]
+ public void Positive_max_diagnostics_truncates_and_sets_flag()
+ {
+ var options = new TypePackageValidationOptions { MaxDiagnostics = 2 };
+
+ var result = Create(new[] { Error("BCPVT100", "a.json"), Error("BCPVT101", "b.json"), Error("BCPVT102", "c.json") }, options);
+
+ result.DiagnosticsTruncated.Should().BeTrue();
+ result.Diagnostics.Should().HaveCount(2);
+ result.Summary.ErrorCount.Should().Be(3);
+ }
+
+ [TestMethod]
+ public void Max_diagnostics_not_exceeded_leaves_truncation_false()
+ {
+ var options = new TypePackageValidationOptions { MaxDiagnostics = 5 };
+
+ var result = Create(new[] { Error("BCPVT100", "a.json"), Error("BCPVT101", "b.json") }, options);
+
+ result.DiagnosticsTruncated.Should().BeFalse();
+ result.Diagnostics.Should().HaveCount(2);
+ }
+
+ [TestMethod]
+ public void Returned_diagnostics_are_sorted()
+ {
+ var later = new TypeValidationDiagnostic("BCPVT200", TypeValidationDiagnosticSeverity.Error, "b", path: "types.json", line: 2, column: 1);
+ var earlier = new TypeValidationDiagnostic("BCPVT100", TypeValidationDiagnosticSeverity.Error, "a", path: "index.json", line: 1, column: 1);
+
+ var result = Create(new[] { later, earlier }, new TypePackageValidationOptions());
+
+ result.Diagnostics[0].Should().BeSameAs(earlier);
+ result.Diagnostics[1].Should().BeSameAs(later);
+ }
+
+ private static TypePackageValidationResult Create(
+ IEnumerable diagnostics,
+ TypePackageValidationOptions options)
+ => TypePackageValidationResult.Create(TypePackageValidationMode.CanonicalWriter, diagnostics, options);
+
+ private static TypeValidationDiagnostic Error(string code = "BCPVT001", string? path = null)
+ => new(code, TypeValidationDiagnosticSeverity.Error, "error", path: path);
+
+ private static TypeValidationDiagnostic Warning(string code = "BCPVT500", string? path = null)
+ => new(code, TypeValidationDiagnosticSeverity.Warning, "warning", path: path);
+
+ private static TypeValidationDiagnostic Information(string code = "BCPVT900", string? path = null)
+ => new(code, TypeValidationDiagnosticSeverity.Info, "info", path: path);
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticTests.cs b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticTests.cs
new file mode 100644
index 00000000..4c251a5d
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Diagnostics/TypeValidationDiagnosticTests.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Diagnostics;
+
+[TestClass]
+public class TypeValidationDiagnosticTests
+{
+ [TestMethod]
+ public void Diagnostics_sort_deterministically_by_path_line_column_and_code()
+ {
+ var typesLater = Diag("BCPVT200", path: "types.json", line: 5, column: 2);
+ var typesLaterHigherCode = Diag("BCPVT201", path: "types.json", line: 5, column: 2);
+ var indexFirst = Diag("BCPVT100", path: "index.json", line: 1, column: 1);
+
+ var list = new List { typesLater, typesLaterHigherCode, indexFirst };
+ list.Sort(TypeValidationDiagnosticComparer.Instance);
+
+ list.Should().ContainInOrder(indexFirst, typesLater, typesLaterHigherCode);
+ }
+
+ [TestMethod]
+ public void Input_level_diagnostics_sort_before_file_level_diagnostics()
+ {
+ var inputLevel = Diag("BCPVT001", path: null);
+ var fileLevel = Diag("BCPVT100", path: "index.json", line: 1, column: 1);
+
+ var list = new List { fileLevel, inputLevel };
+ list.Sort(TypeValidationDiagnosticComparer.Instance);
+
+ list[0].Should().BeSameAs(inputLevel);
+ list[1].Should().BeSameAs(fileLevel);
+ }
+
+ [TestMethod]
+ public void Related_locations_are_preserved()
+ {
+ var related = new TypeValidationDiagnosticRelatedLocation(
+ message: "declared here",
+ path: "types.json",
+ jsonPointer: "/0",
+ line: 3,
+ column: 1);
+
+ var diagnostic = new TypeValidationDiagnostic(
+ "BCPVT400",
+ TypeValidationDiagnosticSeverity.Error,
+ "wrong target kind",
+ path: "index.json",
+ relatedLocations: new[] { related });
+
+ diagnostic.RelatedLocations.Should().ContainSingle()
+ .Which.Message.Should().Be("declared here");
+ }
+
+ [TestMethod]
+ public void Diagnostic_defaults_to_no_related_locations()
+ {
+ var diagnostic = new TypeValidationDiagnostic(
+ "BCPVT100",
+ TypeValidationDiagnosticSeverity.Error,
+ "message");
+
+ diagnostic.RelatedLocations.Should().BeEmpty();
+ }
+
+ private static TypeValidationDiagnostic Diag(string code, string? path, int? line = null, int? column = null)
+ => new(code, TypeValidationDiagnosticSeverity.Error, $"message for {code}", path: path, line: line, column: column);
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..c2edcb35
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses reserved legacy built-in kind 8 (\u0027ResourceRef\u0027), which CanonicalWriter packages must not emit.",
+ "path": "types.json",
+ "jsonPointer": "/2/kind",
+ "line": 16,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/compatibleReader.result.json
new file mode 100644
index 00000000..c4616f90
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses reserved legacy built-in kind 8 (\u0027ResourceRef\u0027), accepted only for CompatibleReader mode.",
+ "path": "types.json",
+ "jsonPointer": "/2/kind",
+ "line": 16,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/types.json
new file mode 100644
index 00000000..24fdf2ab
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/package/types.json
@@ -0,0 +1,18 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ },
+ {
+ "$type": "BuiltInType",
+ "kind": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/scenario.json
new file mode 100644
index 00000000..edc2f73c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-resource-ref/scenario.json
@@ -0,0 +1 @@
+{"name":"builtin-type-resource-ref","description":"A BuiltInType uses the reserved legacy kind 8 (ResourceRef), which has no canonical replacement: rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..e0e8b8c8
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses legacy built-in kind 5 (\u0027String\u0027). CanonicalWriter packages must use \u0027StringType\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/2/kind",
+ "line": 16,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/compatibleReader.result.json
new file mode 100644
index 00000000..a6380e8f
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 uses legacy built-in kind 5 (\u0027String\u0027) accepted only for CompatibleReader mode. Prefer \u0027StringType\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/2/kind",
+ "line": 16,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/types.json
new file mode 100644
index 00000000..643037b3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/package/types.json
@@ -0,0 +1,18 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ },
+ {
+ "$type": "BuiltInType",
+ "kind": 5
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/scenario.json
new file mode 100644
index 00000000..677cc04a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/builtin-type-string/scenario.json
@@ -0,0 +1 @@
+{"name":"builtin-type-string","description":"A BuiltInType uses a documented legacy kind (String): rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..c3ed6a04
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/canonicalWriter.result.json
@@ -0,0 +1,39 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/flags",
+ "line": 6,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 3,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/compatibleReader.result.json
new file mode 100644
index 00000000..49cf98f2
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/flags",
+ "line": 6,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/types.json
new file mode 100644
index 00000000..dcff6f33
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/package/types.json
@@ -0,0 +1,13 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "flags": 1
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/scenario.json
new file mode 100644
index 00000000..206c9508
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-flags-read-only/scenario.json
@@ -0,0 +1 @@
+{"name":"resource-scope-flags-read-only","description":"A ResourceType uses only the legacy flags scope field: rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..cda4a6fb
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/flags",
+ "line": 8,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/compatibleReader.result.json
new file mode 100644
index 00000000..c9de5629
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "Property \u0027flags\u0027 at \u0027/0/flags\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/flags",
+ "line": 8,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/types.json
new file mode 100644
index 00000000..de4e3b4e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/package/types.json
@@ -0,0 +1,15 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "readableScopes": 8,
+ "writableScopes": 8,
+ "flags": 0
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/scenario.json
new file mode 100644
index 00000000..8170f349
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-modern-plus-flags-zero/scenario.json
@@ -0,0 +1 @@
+{"name":"resource-scope-modern-plus-flags-zero","description":"A ResourceType carries the modern scope pair plus flags:0. flags:0 is not effective-legacy so this is not mixing; the flags field is classified per-field (error in CanonicalWriter, warning in CompatibleReader).","category":"compatibility","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..25632b09
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/canonicalWriter.result.json
@@ -0,0 +1,48 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/scopeType",
+ "line": 6,
+ "column": 5
+ },
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "Property \u0027readOnlyScopes\u0027 at \u0027/0/readOnlyScopes\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/readOnlyScopes",
+ "line": 7,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 4,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/compatibleReader.result.json
new file mode 100644
index 00000000..8a81ea98
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/expected/compatibleReader.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/scopeType",
+ "line": 6,
+ "column": 5
+ },
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "Property \u0027readOnlyScopes\u0027 at \u0027/0/readOnlyScopes\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/readOnlyScopes",
+ "line": 7,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 2,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/types.json
new file mode 100644
index 00000000..62affd6d
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/package/types.json
@@ -0,0 +1,14 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "scopeType": 0,
+ "readOnlyScopes": 1
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/scenario.json
new file mode 100644
index 00000000..ff5c2b47
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-read-only-scopes/scenario.json
@@ -0,0 +1 @@
+{"name":"resource-scope-read-only-scopes","description":"A ResourceType uses the legacy scopeType plus readOnlyScopes scope fields (no modern pair): both are rejected in CanonicalWriter and accepted with warnings in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..1a5d9a12
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/canonicalWriter.result.json
@@ -0,0 +1,39 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/scopeType",
+ "line": 6,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 3,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/compatibleReader.result.json
new file mode 100644
index 00000000..8c64d115
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/scopeType",
+ "line": 6,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/types.json
new file mode 100644
index 00000000..b92f1b72
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/package/types.json
@@ -0,0 +1,13 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "scopeType": 4
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/scenario.json
new file mode 100644
index 00000000..26131f27
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-type/scenario.json
@@ -0,0 +1 @@
+{"name":"resource-scope-type","description":"A ResourceType uses only the legacy scopeType scope field: rejected in CanonicalWriter, accepted with a warning in CompatibleReader.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..1a5d9a12
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/canonicalWriter.result.json
@@ -0,0 +1,39 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027readableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027writableScopes\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ },
+ {
+ "code": "BCPVT022",
+ "severity": "error",
+ "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is a legacy ResourceType scope field. CanonicalWriter packages must use \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/scopeType",
+ "line": 6,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 3,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/compatibleReader.result.json
new file mode 100644
index 00000000..8c64d115
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT023",
+ "severity": "warning",
+ "message": "Property \u0027scopeType\u0027 at \u0027/0/scopeType\u0027 in \u0027types.json\u0027 is accepted only for CompatibleReader mode. Prefer canonical fields \u0027readableScopes\u0027 and \u0027writableScopes\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/scopeType",
+ "line": 6,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/types.json
new file mode 100644
index 00000000..3503d1a2
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/package/types.json
@@ -0,0 +1,13 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "scopeType": 0
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/scenario.json
new file mode 100644
index 00000000..f7a6fde2
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/compatibility/resource-scope-zero/scenario.json
@@ -0,0 +1 @@
+{"name":"resource-scope-zero","description":"A ResourceType uses only the legacy scopeType field set to 0: still classified (rejected in CanonicalWriter, warned in CompatibleReader), confirming scopeType:0 is effective-legacy unlike flags:0.","category":"compatibility","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..fa55bc48
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT026",
+ "severity": "error",
+ "message": "IntegerType at '/2' in 'types.json' has minValue 10 greater than maxValue 5.",
+ "path": "types.json",
+ "jsonPointer": "/2",
+ "line": 27,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/index.json
new file mode 100644
index 00000000..698ceb39
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/1"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/types.json
new file mode 100644
index 00000000..19d14c4a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/package/types.json
@@ -0,0 +1,29 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "size": {
+ "type": {
+ "$ref": "#/2"
+ },
+ "flags": 0,
+ "description": "The widget size."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/0"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "IntegerType",
+ "minValue": 10,
+ "maxValue": 5
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/scenario.json
new file mode 100644
index 00000000..bab399fe
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/diagnostic-quality/archive-member-location/scenario.json
@@ -0,0 +1,14 @@
+{
+ "name": "archive-member-location",
+ "description": "A reachable defect inside an archive member reports the package-relative member path (types.json) and json pointer, never a temp extraction path.",
+ "category": "diagnostic-quality",
+ "inputs": [
+ {
+ "kind": "archiveFile",
+ "path": "package.tgz"
+ }
+ ],
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..602000f9
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/expected/canonicalWriter.result.json
@@ -0,0 +1,17 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT002",
+ "severity": "error",
+ "message": "The package at '/package.tgz' does not contain an 'index.json' file at the package root."
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/package/types.json
new file mode 100644
index 00000000..196130a3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/package/types.json
@@ -0,0 +1,5 @@
+[
+ {
+ "$type": "StringType"
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/scenario.json
new file mode 100644
index 00000000..44d3a9fb
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/archive/archive-missing-index/scenario.json
@@ -0,0 +1,14 @@
+{
+ "name": "archive-missing-index",
+ "description": "A valid gzip/tar archive container that has no index.json at the package root reports the standard missing-index diagnostic.",
+ "category": "invalid.archive",
+ "inputs": [
+ {
+ "kind": "archiveFile",
+ "path": "package.tgz"
+ }
+ ],
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..e433ba25
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT017",
+ "severity": "error",
+ "message": "Reference at '/resourceFunctions/Sample.Provider~1widgets/2026-01-01/0/$ref' in 'index.json' targets missing type file 'functions.json'.",
+ "path": "index.json",
+ "jsonPointer": "/resourceFunctions/Sample.Provider~1widgets/2026-01-01/0/$ref",
+ "line": 11,
+ "column": 19
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/index.json
new file mode 100644
index 00000000..b54d655a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/index.json
@@ -0,0 +1,17 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/1"
+ }
+ },
+ "resourceFunctions": {
+ "Sample.Provider/widgets": {
+ "2026-01-01": [
+ {
+ "$ref": "functions.json#/0"
+ }
+ ]
+ }
+ },
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/types.json
new file mode 100644
index 00000000..e34d790d
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/package/types.json
@@ -0,0 +1,16 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {}
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/0"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/scenario.json
new file mode 100644
index 00000000..980c88ab
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/archive-resource-function-missing-type-file/scenario.json
@@ -0,0 +1,14 @@
+{
+ "name": "archive-resource-function-missing-type-file",
+ "description": "An archive whose index.json declares a resourceFunctions root that references a type file absent from the archive reports BCPVT017. Guards the known archive-writer gap where resource-function-only type files can be omitted.",
+ "category": "invalid.graph",
+ "inputs": [
+ {
+ "kind": "archiveFile",
+ "path": "package.tgz"
+ }
+ ],
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..87f1b148
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Settings configurationType must reference an object type (\u0027ObjectType\u0027 or \u0027DiscriminatedObjectType\u0027), but the target is \u0027StringType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/settings/configurationType/$ref",
+ "line": 10,
+ "column": 15,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/index.json
new file mode 100644
index 00000000..159fe66e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/index.json
@@ -0,0 +1,13 @@
+{
+ "resources": {},
+ "resourceFunctions": {},
+ "namespaceFunctions": [],
+ "settings": {
+ "name": "SampleConfig",
+ "isSingleton": true,
+ "version": "1.0.0",
+ "configurationType": {
+ "$ref": "types.json#/0"
+ }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/types.json
new file mode 100644
index 00000000..196130a3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/package/types.json
@@ -0,0 +1,5 @@
+[
+ {
+ "$type": "StringType"
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/scenario.json
new file mode 100644
index 00000000..e67e3b47
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/configuration-type-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "configuration-type-target-kind",
+ "description": "The settings.configurationType index entry references a non-object-like type.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..7be65b7e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT021",
+ "severity": "error",
+ "message": "Reference at \u0027/1/elements/a/$ref\u0027 in \u0027types.json\u0027 for role \u0027discriminated object element\u0027 must target an object type (\u0027ObjectType\u0027), but the target is \u0027StringType\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/1/elements/a/$ref",
+ "line": 18,
+ "column": 17,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/2",
+ "line": 22,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/types.json
new file mode 100644
index 00000000..5ce37ea5
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/package/types.json
@@ -0,0 +1,25 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "DiscriminatedObjectType",
+ "name": "disc",
+ "discriminator": "kind",
+ "baseProperties": {},
+ "elements": {
+ "a": {
+ "$ref": "#/2"
+ }
+ }
+ },
+ {
+ "$type": "StringType"
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/scenario.json
new file mode 100644
index 00000000..f6967c3a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/discriminated-object-element-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "discriminated-object-element-target-kind",
+ "description": "A DiscriminatedObjectType element references a non-ObjectType.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..38cce742
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Fallback resource type must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027ObjectType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/fallbackResourceType/$ref",
+ "line": 6,
+ "column": 13,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/index.json
new file mode 100644
index 00000000..8555fd57
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/index.json
@@ -0,0 +1,8 @@
+{
+ "resources": {},
+ "resourceFunctions": {},
+ "namespaceFunctions": [],
+ "fallbackResourceType": {
+ "$ref": "types.json#/0"
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/types.json
new file mode 100644
index 00000000..7c28dfc0
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/package/types.json
@@ -0,0 +1,7 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "notAResource",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/scenario.json
new file mode 100644
index 00000000..ed18adce
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/fallback-resource-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "fallback-resource-target-kind",
+ "description": "The fallbackResourceType index entry references a non-ResourceType object.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..ebab8792
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,48 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT021",
+ "severity": "error",
+ "message": "Reference at \u0027/2/parameters/0/type/$ref\u0027 in \u0027types.json\u0027 for role \u0027function parameter type\u0027 must target a value type, but the target is \u0027ResourceType\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/2/parameters/0/type/$ref",
+ "line": 29,
+ "column": 19,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ]
+ },
+ {
+ "code": "BCPVT021",
+ "severity": "error",
+ "message": "Reference at \u0027/2/output/$ref\u0027 in \u0027types.json\u0027 for role \u0027function output type\u0027 must target a value type, but the target is \u0027ResourceType\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/2/output/$ref",
+ "line": 34,
+ "column": 15,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 2,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/types.json
new file mode 100644
index 00000000..09c4be07
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/package/types.json
@@ -0,0 +1,37 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "functions": {
+ "compute": {
+ "type": {
+ "$ref": "#/2"
+ }
+ }
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ },
+ {
+ "$type": "FunctionType",
+ "parameters": [
+ {
+ "name": "arg",
+ "type": {
+ "$ref": "#/0"
+ }
+ }
+ ],
+ "output": {
+ "$ref": "#/0"
+ }
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/scenario.json
new file mode 100644
index 00000000..4ac1d05b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/function-signature-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "function-signature-target-kind",
+ "description": "Graph traversal descends into a resource-type FunctionType signature: both a parameter type and the output reference a ResourceType (not a value type), each reported as BCPVT021.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..6fa083e6
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT017",
+ "severity": "error",
+ "message": "Reference at \u0027/0/body/$ref\u0027 in \u0027types.json\u0027 targets missing type file \u0027missing.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/body/$ref",
+ "line": 6,
+ "column": 15
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/types.json
new file mode 100644
index 00000000..e7d685a3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/package/types.json
@@ -0,0 +1,11 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "missing.json#/0"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/scenario.json
new file mode 100644
index 00000000..c51d534d
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/missing-referenced-type-file/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "missing-referenced-type-file",
+ "description": "A reference targets a type file that does not exist in the package.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..6a50b767
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Namespace function [0] must reference a namespace function type (\u0027NamespaceFunctionType\u0027), but the target is \u0027ObjectType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/namespaceFunctions/0/$ref",
+ "line": 6,
+ "column": 15,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/index.json
new file mode 100644
index 00000000..ffd3a027
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {},
+ "resourceFunctions": {},
+ "namespaceFunctions": [
+ {
+ "$ref": "types.json#/0"
+ }
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/types.json
new file mode 100644
index 00000000..f656c082
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/package/types.json
@@ -0,0 +1,7 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "notAFunction",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/scenario.json
new file mode 100644
index 00000000..aad523af
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/namespace-function-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "namespace-function-target-kind",
+ "description": "A namespaceFunctions index entry references a non-NamespaceFunctionType object.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..2f1b4642
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT021",
+ "severity": "error",
+ "message": "Reference at \u0027/0/properties/self/type/$ref\u0027 in \u0027types.json\u0027 for role \u0027object property type\u0027 must target a value type, but the target is \u0027ResourceType\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/properties/self/type/$ref",
+ "line": 8,
+ "column": 19,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/1",
+ "line": 15,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/index.json
new file mode 100644
index 00000000..f6b37d47
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/1"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/types.json
new file mode 100644
index 00000000..1e540af2
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/package/types.json
@@ -0,0 +1,24 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "self": {
+ "type": {
+ "$ref": "#/1"
+ },
+ "flags": 0,
+ "description": "A property that incorrectly targets a resource type."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/0"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/scenario.json
new file mode 100644
index 00000000..49f8b4b6
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/object-property-targets-declaration-type/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "object-property-targets-declaration-type",
+ "description": "An object property type references a resource type, which is not a value type.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..ffdc6e0b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT021",
+ "severity": "error",
+ "message": "Reference at \u0027/0/body/$ref\u0027 in \u0027types.json\u0027 for role \u0027resource body\u0027 must target an object type (\u0027ObjectType\u0027 or \u0027DiscriminatedObjectType\u0027), but the target is \u0027StringType\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0/body/$ref",
+ "line": 6,
+ "column": 15,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/1",
+ "line": 11,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/types.json
new file mode 100644
index 00000000..01d1266a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/package/types.json
@@ -0,0 +1,14 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "StringType"
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/scenario.json
new file mode 100644
index 00000000..80a8c21f
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-body-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "resource-body-target-kind",
+ "description": "A ResourceType body references a non-object-like type.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..28c12b70
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Resource function \u0027My.Rp/things@2026-01-01[0]\u0027 must reference a resource function type (\u0027ResourceFunctionType\u0027), but the target is \u0027ObjectType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/resourceFunctions/My.Rp~1things/2026-01-01/0/$ref",
+ "line": 7,
+ "column": 19,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/index.json
new file mode 100644
index 00000000..043e3233
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/index.json
@@ -0,0 +1,13 @@
+{
+ "resources": {},
+ "resourceFunctions": {
+ "My.Rp/things": {
+ "2026-01-01": [
+ {
+ "$ref": "types.json#/0"
+ }
+ ]
+ }
+ },
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/types.json
new file mode 100644
index 00000000..f656c082
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/package/types.json
@@ -0,0 +1,7 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "notAFunction",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/scenario.json
new file mode 100644
index 00000000..0d754bcf
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/resource-function-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "resource-function-target-kind",
+ "description": "A resourceFunctions index entry references a non-ResourceFunctionType object.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..03ff5147
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT019",
+ "severity": "error",
+ "message": "Reference at \u0027/0/body/$ref\u0027 in \u0027types.json\u0027 targets index 99 in \u0027types.json\u0027, but the file contains 1 type objects.",
+ "path": "types.json",
+ "jsonPointer": "/0/body/$ref",
+ "line": 6,
+ "column": 15
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/types.json
new file mode 100644
index 00000000..d6dba4fa
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/package/types.json
@@ -0,0 +1,11 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/99"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/scenario.json
new file mode 100644
index 00000000..32e2e2ce
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/same-file-reference-out-of-range/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "same-file-reference-out-of-range",
+ "description": "A same-file reference names a type-object index that is out of range.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..3a73af9f
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/expected/canonicalWriter.result.json
@@ -0,0 +1,30 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Resource entry \u0027My.Rp/things@2026-01-01\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027ObjectType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/resources/My.Rp~1things@2026-01-01/$ref",
+ "line": 4,
+ "column": 15,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ]
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/types.json
new file mode 100644
index 00000000..7c28dfc0
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/package/types.json
@@ -0,0 +1,7 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "notAResource",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/scenario.json
new file mode 100644
index 00000000..214c16ef
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/graph/wrong-resource-target-kind/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "wrong-resource-target-kind",
+ "description": "A resource index entry references a non-resource type object.",
+ "category": "invalid.graph",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..a5f1a5ca
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/expected/canonicalWriter.result.json
@@ -0,0 +1,18 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT035",
+ "severity": "error",
+ "message": "Package file 'README.md' is not a supported Bicep Types package file.",
+ "path": "README.md"
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/README.md b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/README.md
new file mode 100644
index 00000000..61947d6a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/README.md
@@ -0,0 +1 @@
+Release notes for this package.
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/index.json
new file mode 100644
index 00000000..baf22b21
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": {},
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/scenario.json
new file mode 100644
index 00000000..5e735c15
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unexpected-package-file/scenario.json
@@ -0,0 +1,11 @@
+{
+ "name": "unexpected-package-file",
+ "description": "With strict hygiene enabled, a non-JSON regular file in the package reports BCPVT035.",
+ "category": "invalid.hygiene",
+ "options": {
+ "validateUnreachableFiles": true
+ },
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..241363b6
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/expected/canonicalWriter.result.json
@@ -0,0 +1,18 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT034",
+ "severity": "error",
+ "message": "Package file 'orphan.json' is not reachable from 'index.json' roots.",
+ "path": "orphan.json"
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/index.json
new file mode 100644
index 00000000..baf22b21
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": {},
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/orphan.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/orphan.json
new file mode 100644
index 00000000..196130a3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/package/orphan.json
@@ -0,0 +1,5 @@
+[
+ {
+ "$type": "StringType"
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/scenario.json
new file mode 100644
index 00000000..c8e239a0
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/hygiene/unreachable-type-file/scenario.json
@@ -0,0 +1,11 @@
+{
+ "name": "unreachable-type-file",
+ "description": "With strict hygiene enabled, a JSON type file that is not reachable from index.json roots reports BCPVT034.",
+ "category": "invalid.hygiene",
+ "options": {
+ "validateUnreachableFiles": true
+ },
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..c92e6674
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT025",
+ "severity": "error",
+ "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 must be one of 1..8, but got 9.",
+ "path": "types.json",
+ "jsonPointer": "/2/kind",
+ "line": 16,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/compatibleReader.result.json
new file mode 100644
index 00000000..5a05a203
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT025",
+ "severity": "error",
+ "message": "BuiltInType.kind at \u0027/2/kind\u0027 in \u0027types.json\u0027 must be one of 1..8, but got 9.",
+ "path": "types.json",
+ "jsonPointer": "/2/kind",
+ "line": 16,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/types.json
new file mode 100644
index 00000000..faef3ae6
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/package/types.json
@@ -0,0 +1,18 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ },
+ {
+ "$type": "BuiltInType",
+ "kind": 9
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/scenario.json
new file mode 100644
index 00000000..3e6560e1
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/builtin-type-kind-invalid/scenario.json
@@ -0,0 +1 @@
+{"name":"builtin-type-kind-invalid","description":"A BuiltInType.kind is outside the documented 1..8 range, which is an error in both modes (BCPVT025).","category":"invalid.policy","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..0518b3ff
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT024",
+ "severity": "error",
+ "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027flags\u0027. Use either the canonical modern pair or a documented legacy form, not both.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/compatibleReader.result.json
new file mode 100644
index 00000000..9026b91f
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT024",
+ "severity": "error",
+ "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027flags\u0027. Use either the canonical modern pair or a documented legacy form, not both.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/types.json
new file mode 100644
index 00000000..dc69772c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/package/types.json
@@ -0,0 +1,15 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "readableScopes": 8,
+ "writableScopes": 8,
+ "flags": 1
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/scenario.json
new file mode 100644
index 00000000..9c99df1c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-flags-read-only/scenario.json
@@ -0,0 +1 @@
+{"name":"resource-scope-mixed-modern-flags-read-only","description":"A ResourceType mixes the modern scope pair with the effective legacy flags:1 field, which is an error in both modes (BCPVT024).","category":"invalid.policy","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..0d31f252
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT024",
+ "severity": "error",
+ "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027scopeType\u0027. Use either the canonical modern pair or a documented legacy form, not both.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/compatibleReader.result.json
new file mode 100644
index 00000000..51e4c091
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT024",
+ "severity": "error",
+ "message": "ResourceType at \u0027/0\u0027 in \u0027types.json\u0027 mixes modern scope fields with legacy scope field \u0027scopeType\u0027. Use either the canonical modern pair or a documented legacy form, not both.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 2,
+ "column": 3
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/index.json
new file mode 100644
index 00000000..ae218123
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/index.json
@@ -0,0 +1,5 @@
+{
+ "resources": { "My.Rp/x@2026-01-01": { "$ref": "types.json#/0" } },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/types.json
new file mode 100644
index 00000000..dd3557b8
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/package/types.json
@@ -0,0 +1,15 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/x@2026-01-01",
+ "body": { "$ref": "#/1" },
+ "readableScopes": 8,
+ "writableScopes": 8,
+ "scopeType": 0
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/scenario.json
new file mode 100644
index 00000000..c313a11b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/policy/resource-scope-mixed-modern-legacy/scenario.json
@@ -0,0 +1 @@
+{"name":"resource-scope-mixed-modern-legacy","description":"A ResourceType mixes the modern scope pair with the legacy scopeType field, which is an error in both modes (BCPVT024).","category":"invalid.policy","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..d255500c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT026",
+ "severity": "error",
+ "message": "ArrayType at \u0027/2\u0027 in \u0027types.json\u0027 has minLength 10 greater than maxLength 5.",
+ "path": "types.json",
+ "jsonPointer": "/2",
+ "line": 22,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/types.json
new file mode 100644
index 00000000..3c28ee74
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/package/types.json
@@ -0,0 +1,24 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ },
+ {
+ "$type": "ArrayType",
+ "itemType": {
+ "$ref": "#/1"
+ },
+ "minLength": 10,
+ "maxLength": 5
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/scenario.json
new file mode 100644
index 00000000..d5907bcd
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/array-length-range-order/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "array-length-range-order",
+ "description": "An ArrayType has minLength greater than maxLength (BCPVT026); mode-independent.",
+ "category": "invalid.semantic",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..149e1cc5
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT026",
+ "severity": "error",
+ "message": "IntegerType at \u0027/2\u0027 in \u0027types.json\u0027 has minValue 10 greater than maxValue 5.",
+ "path": "types.json",
+ "jsonPointer": "/2",
+ "line": 19,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/types.json
new file mode 100644
index 00000000..533c7071
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/package/types.json
@@ -0,0 +1,21 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ },
+ {
+ "$type": "IntegerType",
+ "minValue": 10,
+ "maxValue": 5
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/scenario.json
new file mode 100644
index 00000000..c171bc9e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/integer-range-order/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "integer-range-order",
+ "description": "An IntegerType has minValue greater than maxValue (BCPVT026); mode-independent.",
+ "category": "invalid.semantic",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..9afc3323
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT029",
+ "severity": "error",
+ "message": "ObjectType property flags at \u0027/1/properties/name/flags\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.",
+ "path": "types.json",
+ "jsonPointer": "/1/properties/name/flags",
+ "line": 19,
+ "column": 9
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/compatibleReader.result.json
new file mode 100644
index 00000000..845e3a63
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT029",
+ "severity": "warning",
+ "message": "ObjectType property flags at \u0027/1/properties/name/flags\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.",
+ "path": "types.json",
+ "jsonPointer": "/1/properties/name/flags",
+ "line": 19,
+ "column": 9
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/types.json
new file mode 100644
index 00000000..6127f971
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/package/types.json
@@ -0,0 +1,26 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/2"
+ },
+ "flags": 32
+ }
+ }
+ },
+ {
+ "$type": "StringType"
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/scenario.json
new file mode 100644
index 00000000..c1e2e208
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/object-property-flags-invalid/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "object-property-flags-invalid",
+ "description": "An ObjectType property has flags with bits outside the known mask (BCPVT029): error in canonicalWriter, warning in compatibleReader.",
+ "category": "invalid.semantic",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..57be179a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT029",
+ "severity": "error",
+ "message": "ResourceType readableScopes at \u0027/0/readableScopes\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.",
+ "path": "types.json",
+ "jsonPointer": "/0/readableScopes",
+ "line": 8,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/compatibleReader.result.json
new file mode 100644
index 00000000..3ca0cbe3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT029",
+ "severity": "warning",
+ "message": "ResourceType readableScopes at \u0027/0/readableScopes\u0027 in \u0027types.json\u0027 contain unknown bits 32 for this validator version. Known mask is 31.",
+ "path": "types.json",
+ "jsonPointer": "/0/readableScopes",
+ "line": 8,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/types.json
new file mode 100644
index 00000000..990db980
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/package/types.json
@@ -0,0 +1,16 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 32,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/scenario.json
new file mode 100644
index 00000000..49e4de9f
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/readable-scope-bits-invalid/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "readable-scope-bits-invalid",
+ "description": "A ResourceType readableScopes value has bits outside the known ScopeType mask (BCPVT029): error in canonicalWriter, warning in compatibleReader.",
+ "category": "invalid.semantic",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..aef5d508
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT027",
+ "severity": "error",
+ "message": "StringType.minLength at \u0027/2/minLength\u0027 in \u0027types.json\u0027 must be non-negative, but got -1.",
+ "path": "types.json",
+ "jsonPointer": "/2/minLength",
+ "line": 18,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/index.json
new file mode 100644
index 00000000..d78f180a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "My.Rp/things@2026-01-01": {
+ "$ref": "types.json#/0"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/types.json
new file mode 100644
index 00000000..03d257f3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/package/types.json
@@ -0,0 +1,20 @@
+[
+ {
+ "$type": "ResourceType",
+ "name": "My.Rp/things@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "body",
+ "properties": {}
+ },
+ {
+ "$type": "StringType",
+ "minLength": -1
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/scenario.json
new file mode 100644
index 00000000..7030038f
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/string-length-negative/scenario.json
@@ -0,0 +1,8 @@
+{
+ "name": "string-length-negative",
+ "description": "A StringType has a negative minLength (BCPVT027); mode-independent.",
+ "category": "invalid.semantic",
+ "modes": [
+ "canonicalWriter"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..36c7fd34
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT028",
+ "severity": "error",
+ "message": "NamespaceFunctionType.visibleInFileKind at \u0027/1/visibleInFileKind\u0027 in \u0027types.json\u0027 must be one of 1 or 2 for this validator version, but got 99.",
+ "path": "types.json",
+ "jsonPointer": "/1/visibleInFileKind",
+ "line": 12,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/compatibleReader.result.json
new file mode 100644
index 00000000..2ad65a98
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT028",
+ "severity": "warning",
+ "message": "NamespaceFunctionType.visibleInFileKind at \u0027/1/visibleInFileKind\u0027 in \u0027types.json\u0027 must be one of 1 or 2 for this validator version, but got 99.",
+ "path": "types.json",
+ "jsonPointer": "/1/visibleInFileKind",
+ "line": 12,
+ "column": 5
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 1,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/index.json
new file mode 100644
index 00000000..a056c7dc
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {},
+ "resourceFunctions": {},
+ "namespaceFunctions": [
+ {
+ "$ref": "types.json#/1"
+ }
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/types.json
new file mode 100644
index 00000000..4f40675e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/package/types.json
@@ -0,0 +1,14 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "NamespaceFunctionType",
+ "name": "lookup",
+ "parameters": [],
+ "outputType": {
+ "$ref": "#/0"
+ },
+ "visibleInFileKind": 99
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/scenario.json
new file mode 100644
index 00000000..535c9b53
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/semantic/visible-in-file-kind-invalid/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "visible-in-file-kind-invalid",
+ "description": "A NamespaceFunctionType visibleInFileKind is outside the known enum domain (BCPVT028): error in canonicalWriter, warning in compatibleReader.",
+ "category": "invalid.semantic",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..62955ef4
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT012",
+ "severity": "error",
+ "message": "Property \u0027Sample/r@v1\u0027 at \u0027/resources/Sample~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): expected an object with a \u0027$ref\u0027 property, got string.",
+ "path": "index.json",
+ "jsonPointer": "/resources/Sample~1r@v1",
+ "line": 1,
+ "column": 29
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/compatibleReader.result.json
new file mode 100644
index 00000000..5a906021
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT012",
+ "severity": "error",
+ "message": "Property \u0027Sample/r@v1\u0027 at \u0027/resources/Sample~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): expected an object with a \u0027$ref\u0027 property, got string.",
+ "path": "index.json",
+ "jsonPointer": "/resources/Sample~1r@v1",
+ "line": 1,
+ "column": 29
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/package/index.json
new file mode 100644
index 00000000..411f59a6
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/package/index.json
@@ -0,0 +1 @@
+{"resources":{"Sample/r@v1":"not-a-ref"},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/scenario.json
new file mode 100644
index 00000000..7768bdeb
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resource-entry-not-reference/scenario.json
@@ -0,0 +1 @@
+{"name":"index-resource-entry-not-reference","description":"A resources entry value is not a reference object.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..4232fd0a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/canonicalWriter.result.json
@@ -0,0 +1,20 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT011",
+ "severity": "error",
+ "message": "Property \u0027resources\u0027 at \u0027\u0027 in \u0027index.json\u0027 must be a object, but got string.",
+ "path": "index.json",
+ "line": 1,
+ "column": 14
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/compatibleReader.result.json
new file mode 100644
index 00000000..feff8562
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/expected/compatibleReader.result.json
@@ -0,0 +1,20 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT011",
+ "severity": "error",
+ "message": "Property \u0027resources\u0027 at \u0027\u0027 in \u0027index.json\u0027 must be a object, but got string.",
+ "path": "index.json",
+ "line": 1,
+ "column": 14
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/package/index.json
new file mode 100644
index 00000000..ff042dcf
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/package/index.json
@@ -0,0 +1 @@
+{"resources":"bad","resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/scenario.json
new file mode 100644
index 00000000..371c39da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-resources-not-object/scenario.json
@@ -0,0 +1 @@
+{"name":"index-resources-not-object","description":"The resources field is not a JSON object.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..be1a2113
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/canonicalWriter.result.json
@@ -0,0 +1,20 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT004",
+ "severity": "error",
+ "message": "The root value of \u0027index.json\u0027 must be a JSON object.",
+ "path": "index.json",
+ "line": 1,
+ "column": 1
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/compatibleReader.result.json
new file mode 100644
index 00000000..bdf700df
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/expected/compatibleReader.result.json
@@ -0,0 +1,20 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT004",
+ "severity": "error",
+ "message": "The root value of \u0027index.json\u0027 must be a JSON object.",
+ "path": "index.json",
+ "line": 1,
+ "column": 1
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/package/index.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/package/index.json
@@ -0,0 +1 @@
+[]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/scenario.json
new file mode 100644
index 00000000..e7170d37
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-root-array/scenario.json
@@ -0,0 +1 @@
+{"name":"index-root-array","description":"index.json root is an array instead of an object.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..fbc2cd3b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/expected/canonicalWriter.result.json
@@ -0,0 +1,20 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT014",
+ "severity": "error",
+ "message": "Unexpected property \u0027unknownField\u0027 at \u0027\u0027 in \u0027index.json\u0027.",
+ "path": "index.json",
+ "line": 1,
+ "column": 64
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/package/index.json
new file mode 100644
index 00000000..d93568cd
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/package/index.json
@@ -0,0 +1 @@
+{"resources":{},"resourceFunctions":{},"namespaceFunctions":[],"unknownField":1}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/scenario.json
new file mode 100644
index 00000000..e4e65672
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/index-unknown-top-level-field/scenario.json
@@ -0,0 +1 @@
+{"name":"index-unknown-top-level-field","description":"index.json contains an unrecognized top-level field.","category":"structural","modes":["canonicalWriter"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..543232e5
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/canonicalWriter.result.json
@@ -0,0 +1,20 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT003",
+ "severity": "error",
+ "message": "JSON syntax error in \u0027index.json\u0027: \u0027t\u0027 is an invalid start of a property name. Expected a \u0027\u0022\u0027. LineNumber: 0 | BytePositionInLine: 2.",
+ "path": "index.json",
+ "line": 1,
+ "column": 3
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/compatibleReader.result.json
new file mode 100644
index 00000000..e973f0d8
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/expected/compatibleReader.result.json
@@ -0,0 +1,20 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT003",
+ "severity": "error",
+ "message": "JSON syntax error in \u0027index.json\u0027: \u0027t\u0027 is an invalid start of a property name. Expected a \u0027\u0022\u0027. LineNumber: 0 | BytePositionInLine: 2.",
+ "path": "index.json",
+ "line": 1,
+ "column": 3
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/package/index.json
new file mode 100644
index 00000000..82bfb0bf
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/package/index.json
@@ -0,0 +1 @@
+{ this is not valid JSON }
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/scenario.json
new file mode 100644
index 00000000..97fe6efd
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/malformed-index-json/scenario.json
@@ -0,0 +1 @@
+{"name":"malformed-index-json","description":"index.json contains a JSON syntax error.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..c294cd11
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/canonicalWriter.result.json
@@ -0,0 +1,17 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT002",
+ "severity": "error",
+ "message": "The package at \u0027\u003Csample-root\u003E/package\u0027 does not contain an \u0027index.json\u0027 file at the package root."
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/compatibleReader.result.json
new file mode 100644
index 00000000..25e91474
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/expected/compatibleReader.result.json
@@ -0,0 +1,17 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT002",
+ "severity": "error",
+ "message": "The package at \u0027\u003Csample-root\u003E/package\u0027 does not contain an \u0027index.json\u0027 file at the package root."
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/package/.placeholder b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/package/.placeholder
new file mode 100644
index 00000000..3ab0c48e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/package/.placeholder
@@ -0,0 +1,2 @@
+This file exists only to ensure the package directory is materialized as an empty folder.
+The validator will look for index.json, which is intentionally absent for this scenario.
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/scenario.json
new file mode 100644
index 00000000..69d97268
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/missing-index-file/scenario.json
@@ -0,0 +1 @@
+{"name":"missing-index-file","description":"A package directory with no index.json file.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..e9f874d3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT014",
+ "severity": "error",
+ "message": "Unexpected property \u0027extra\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1",
+ "line": 1,
+ "column": 48
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/index.json
new file mode 100644
index 00000000..d5730bc8
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"types.json#/0","extra":1}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/types.json
new file mode 100644
index 00000000..5a45d228
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/package/types.json
@@ -0,0 +1 @@
+[{"$type":"StringType"}]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/scenario.json
new file mode 100644
index 00000000..147500fb
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-extra-property/scenario.json
@@ -0,0 +1 @@
+{"name":"reference-extra-property","description":"A reference object contains an extra property besides $ref (canonical mode only).","category":"structural","modes":["canonicalWriter"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..6ae89a53
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT013",
+ "severity": "error",
+ "message": "Reference \u0027notavalidref\u0027 at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 has invalid syntax: the reference string must contain a \u0027#/\u0027 fragment separator.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/compatibleReader.result.json
new file mode 100644
index 00000000..9d9bda76
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT013",
+ "severity": "error",
+ "message": "Reference \u0027notavalidref\u0027 at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 has invalid syntax: the reference string must contain a \u0027#/\u0027 fragment separator.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/package/index.json
new file mode 100644
index 00000000..1dc8f586
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"notavalidref"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/scenario.json
new file mode 100644
index 00000000..78928586
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-invalid-syntax/scenario.json
@@ -0,0 +1 @@
+{"name":"reference-invalid-syntax","description":"A $ref string does not match the expected path#/index format.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..ae903eec
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT012",
+ "severity": "error",
+ "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the object is missing the required \u0027$ref\u0027 property.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1",
+ "line": 1,
+ "column": 24
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/compatibleReader.result.json
new file mode 100644
index 00000000..7609abaa
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT012",
+ "severity": "error",
+ "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the object is missing the required \u0027$ref\u0027 property.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1",
+ "line": 1,
+ "column": 24
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/package/index.json
new file mode 100644
index 00000000..2b3252da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"notRef":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/scenario.json
new file mode 100644
index 00000000..d0139d20
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-missing-ref/scenario.json
@@ -0,0 +1 @@
+{"name":"reference-missing-ref","description":"A reference object is missing the required $ref property.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..b1447287
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT012",
+ "severity": "error",
+ "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the \u0027$ref\u0027 property must be a string, got number.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1",
+ "line": 1,
+ "column": 32
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/compatibleReader.result.json
new file mode 100644
index 00000000..1f2e72ec
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT012",
+ "severity": "error",
+ "message": "Property \u0027S/r@v1\u0027 at \u0027/resources/S~1r@v1\u0027 in \u0027index.json\u0027 must be a reference object ({\u0022$ref\u0022: \u0022...\u0022}): the \u0027$ref\u0027 property must be a string, got number.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1",
+ "line": 1,
+ "column": 32
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/package/index.json
new file mode 100644
index 00000000..630d11c6
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":42}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/scenario.json
new file mode 100644
index 00000000..91a4bb33
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/reference-non-string-ref/scenario.json
@@ -0,0 +1 @@
+{"name":"reference-non-string-ref","description":"A reference object has a $ref property that is not a string.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..d165fe52
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT006",
+ "severity": "error",
+ "message": "Element \u0027/0\u0027 in type file \u0027types.json\u0027 must be a JSON object.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/compatibleReader.result.json
new file mode 100644
index 00000000..4a928eb3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT006",
+ "severity": "error",
+ "message": "Element \u0027/0\u0027 in type file \u0027types.json\u0027 must be a JSON object.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/index.json
new file mode 100644
index 00000000..77d942da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/types.json
new file mode 100644
index 00000000..e44b4bf7
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/package/types.json
@@ -0,0 +1 @@
+[42]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/scenario.json
new file mode 100644
index 00000000..01683eed
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-element-primitive/scenario.json
@@ -0,0 +1 @@
+{"name":"type-file-element-primitive","description":"A type-file array element is a primitive (number), not an object.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..406e4d7b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/canonicalWriter.result.json
@@ -0,0 +1,29 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT018",
+ "severity": "error",
+ "message": "Reference at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 targets type file \u0027types.json\u0027, which is not a usable type-file array.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32
+ },
+ {
+ "code": "BCPVT005",
+ "severity": "error",
+ "message": "The root value of type file \u0027types.json\u0027 must be a JSON array.",
+ "path": "types.json",
+ "line": 1,
+ "column": 1
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 2,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/compatibleReader.result.json
new file mode 100644
index 00000000..1bb2d8ee
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/expected/compatibleReader.result.json
@@ -0,0 +1,29 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT018",
+ "severity": "error",
+ "message": "Reference at \u0027/resources/S~1r@v1/$ref\u0027 in \u0027index.json\u0027 targets type file \u0027types.json\u0027, which is not a usable type-file array.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32
+ },
+ {
+ "code": "BCPVT005",
+ "severity": "error",
+ "message": "The root value of type file \u0027types.json\u0027 must be a JSON array.",
+ "path": "types.json",
+ "line": 1,
+ "column": 1
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 2,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/index.json
new file mode 100644
index 00000000..77d942da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/types.json
new file mode 100644
index 00000000..5b205a7a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/package/types.json
@@ -0,0 +1 @@
+{"$type":"StringType"}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/scenario.json
new file mode 100644
index 00000000..a1d9633c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-file-root-object/scenario.json
@@ -0,0 +1 @@
+{"name":"type-file-root-object","description":"A type file root is a JSON object instead of an array.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..2e3c6505
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT007",
+ "severity": "error",
+ "message": "Type object at \u0027/0\u0027 in \u0027types.json\u0027 is missing the required \u0027$type\u0027 discriminator field.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/compatibleReader.result.json
new file mode 100644
index 00000000..f708f5c0
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT007",
+ "severity": "error",
+ "message": "Type object at \u0027/0\u0027 in \u0027types.json\u0027 is missing the required \u0027$type\u0027 discriminator field.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/index.json
new file mode 100644
index 00000000..77d942da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/types.json
new file mode 100644
index 00000000..1b81e0e1
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/package/types.json
@@ -0,0 +1 @@
+[{"name":"x"}]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/scenario.json
new file mode 100644
index 00000000..bed6e3ad
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-discriminator/scenario.json
@@ -0,0 +1 @@
+{"name":"type-object-missing-discriminator","description":"A type object is missing the required $type field.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..a6cca28b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/canonicalWriter.result.json
@@ -0,0 +1,39 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ]
+ },
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027value\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 2,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/compatibleReader.result.json
new file mode 100644
index 00000000..e5511144
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/expected/compatibleReader.result.json
@@ -0,0 +1,39 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ]
+ },
+ {
+ "code": "BCPVT010",
+ "severity": "error",
+ "message": "Required property \u0027value\u0027 is missing at \u0027/0\u0027 in \u0027types.json\u0027.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 2,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/index.json
new file mode 100644
index 00000000..77d942da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/types.json
new file mode 100644
index 00000000..b32f763b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/package/types.json
@@ -0,0 +1 @@
+[{"$type":"StringLiteralType"}]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/scenario.json
new file mode 100644
index 00000000..078644f7
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-missing-required-field/scenario.json
@@ -0,0 +1 @@
+{"name":"type-object-missing-required-field","description":"A type object (StringLiteralType) is missing its required value field.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..c5b4ea49
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/canonicalWriter.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT009",
+ "severity": "error",
+ "message": "The \u0027$type\u0027 value \u0027NonExistentKind\u0027 at \u0027/0/$type\u0027 in \u0027types.json\u0027 is not a supported type kind.",
+ "path": "types.json",
+ "jsonPointer": "/0/$type",
+ "line": 1,
+ "column": 11
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/compatibleReader.result.json
new file mode 100644
index 00000000..48ac3680
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/expected/compatibleReader.result.json
@@ -0,0 +1,21 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT009",
+ "severity": "error",
+ "message": "The \u0027$type\u0027 value \u0027NonExistentKind\u0027 at \u0027/0/$type\u0027 in \u0027types.json\u0027 is not a supported type kind.",
+ "path": "types.json",
+ "jsonPointer": "/0/$type",
+ "line": 1,
+ "column": 11
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 1,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/index.json
new file mode 100644
index 00000000..77d942da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/types.json
new file mode 100644
index 00000000..ec0472aa
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/package/types.json
@@ -0,0 +1 @@
+[{"$type":"NonExistentKind"}]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/scenario.json
new file mode 100644
index 00000000..e7ebbdc9
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-unsupported-discriminator/scenario.json
@@ -0,0 +1 @@
+{"name":"type-object-unsupported-discriminator","description":"A type object has a $type value that is not a supported type kind.","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..bbd76948
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/canonicalWriter.result.json
@@ -0,0 +1,39 @@
+{
+ "isValid": false,
+ "mode": "canonicalWriter",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ]
+ },
+ {
+ "code": "BCPVT011",
+ "severity": "error",
+ "message": "Property \u0027value\u0027 at \u0027/0/value\u0027 in \u0027types.json\u0027 must be a string, but got number.",
+ "path": "types.json",
+ "jsonPointer": "/0/value",
+ "line": 1,
+ "column": 39
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 2,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/compatibleReader.result.json
new file mode 100644
index 00000000..598ed0a7
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/expected/compatibleReader.result.json
@@ -0,0 +1,39 @@
+{
+ "isValid": false,
+ "mode": "compatibleReader",
+ "diagnostics": [
+ {
+ "code": "BCPVT020",
+ "severity": "error",
+ "message": "Resource entry \u0027S/r@v1\u0027 must reference a resource type (\u0027ResourceType\u0027), but the target is \u0027StringLiteralType\u0027.",
+ "path": "index.json",
+ "jsonPointer": "/resources/S~1r@v1/$ref",
+ "line": 1,
+ "column": 32,
+ "relatedLocations": [
+ {
+ "message": "Target type is declared here.",
+ "path": "types.json",
+ "jsonPointer": "/0",
+ "line": 1,
+ "column": 2
+ }
+ ]
+ },
+ {
+ "code": "BCPVT011",
+ "severity": "error",
+ "message": "Property \u0027value\u0027 at \u0027/0/value\u0027 in \u0027types.json\u0027 must be a string, but got number.",
+ "path": "types.json",
+ "jsonPointer": "/0/value",
+ "line": 1,
+ "column": 39
+ }
+ ],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 2,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
\ No newline at end of file
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/index.json
new file mode 100644
index 00000000..77d942da
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/index.json
@@ -0,0 +1 @@
+{"resources":{"S/r@v1":{"$ref":"types.json#/0"}},"resourceFunctions":{},"namespaceFunctions":[]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/types.json
new file mode 100644
index 00000000..7cf2276e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/package/types.json
@@ -0,0 +1 @@
+[{"$type":"StringLiteralType","value":42}]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/scenario.json
new file mode 100644
index 00000000..3a187686
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/invalid/structural/type-object-wrong-primitive-field-shape/scenario.json
@@ -0,0 +1 @@
+{"name":"type-object-wrong-primitive-field-shape","description":"A type object field has the wrong JSON type (number where string expected).","category":"structural","modes":["canonicalWriter","compatibleReader"]}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/index.json
new file mode 100644
index 00000000..ee51f957
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/index.json
@@ -0,0 +1,12 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/2"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": [],
+ "fallbackResourceType": {
+ "$ref": "types.json#/4"
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/types.json
new file mode 100644
index 00000000..b2b26e78
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/package/types.json
@@ -0,0 +1,49 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The widget name."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ },
+ {
+ "$type": "ObjectType",
+ "name": "fallbackBody",
+ "properties": {
+ "type": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 0,
+ "description": "The unrecognized resource type name."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/*@2026-01-01",
+ "body": {
+ "$ref": "#/3"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/scenario.json
new file mode 100644
index 00000000..ac9d672c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/fallback-resource-type/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "fallback-resource-type",
+ "description": "fallbackResourceType can reference ResourceType.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/index.json
new file mode 100644
index 00000000..ac5c71f9
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/2"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/types.json
new file mode 100644
index 00000000..5b6e4b63
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/package/types.json
@@ -0,0 +1,27 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The widget name."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/scenario.json
new file mode 100644
index 00000000..86db6464
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/minimal-resource/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "minimal-resource",
+ "description": "A package with index.json, one type file, and one resource entry targeting ResourceType is valid.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/common/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/common/types.json
new file mode 100644
index 00000000..94fcbf5a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/common/types.json
@@ -0,0 +1,18 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "extensionConfiguration",
+ "properties": {
+ "endpoint": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The service endpoint used by the extension."
+ }
+ }
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/fallback/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/fallback/types.json
new file mode 100644
index 00000000..09aa5a7b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/fallback/types.json
@@ -0,0 +1,16 @@
+[
+ {
+ "$type": "ObjectType",
+ "name": "fallbackBody",
+ "properties": {}
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/*@2026-01-01",
+ "body": {
+ "$ref": "#/0"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/index.json
new file mode 100644
index 00000000..a80f0011
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/index.json
@@ -0,0 +1,22 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "resources/types.json#/2"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": [],
+ "settings": {
+ "name": "SampleMultiFile",
+ "isSingleton": true,
+ "isPreview": false,
+ "isDeprecated": false,
+ "version": "1.0.0",
+ "configurationType": {
+ "$ref": "common/types.json#/1"
+ }
+ },
+ "fallbackResourceType": {
+ "$ref": "fallback/types.json#/1"
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/resources/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/resources/types.json
new file mode 100644
index 00000000..5b6e4b63
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/package/resources/types.json
@@ -0,0 +1,27 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The widget name."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/scenario.json
new file mode 100644
index 00000000..f36c01e5
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/multi-file-package/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "multi-file-package",
+ "description": "Cross-file references work for positions where they are allowed.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/index.json
new file mode 100644
index 00000000..e8bbba23
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/index.json
@@ -0,0 +1,13 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/4"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": [
+ {
+ "$ref": "types.json#/3"
+ }
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/types.json
new file mode 100644
index 00000000..884a6aeb
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/package/types.json
@@ -0,0 +1,50 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "AnyType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The widget name."
+ }
+ }
+ },
+ {
+ "$type": "NamespaceFunctionType",
+ "name": "lookupWidget",
+ "description": "Looks up a widget by name.",
+ "evaluatedLanguageExpression": "[externalInput('lookupWidget', parameters('name'))]",
+ "parameters": [
+ {
+ "name": "name",
+ "type": {
+ "$ref": "#/0"
+ },
+ "description": "The widget name.",
+ "flags": 1
+ }
+ ],
+ "outputType": {
+ "$ref": "#/1"
+ },
+ "visibleInFileKind": 1
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/2"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/scenario.json
new file mode 100644
index 00000000..a44aa44c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/namespace-function/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "namespace-function",
+ "description": "namespaceFunctions can target NamespaceFunctionType.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/index.json
new file mode 100644
index 00000000..f5bb7ebf
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "Sample.Provider/trees@2026-01-01": {
+ "$ref": "types.json#/2"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/types.json
new file mode 100644
index 00000000..459acd9a
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/package/types.json
@@ -0,0 +1,34 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "treeNode",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The node name."
+ },
+ "child": {
+ "type": {
+ "$ref": "#/1"
+ },
+ "flags": 0,
+ "description": "The optional child node."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/trees@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/scenario.json
new file mode 100644
index 00000000..68020e24
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/recursive-object/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "recursive-object",
+ "description": "A valid cycle is accepted when all references resolve and target-role rules are satisfied.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/index.json
new file mode 100644
index 00000000..d3b35d52
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/index.json
@@ -0,0 +1,17 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/5"
+ }
+ },
+ "resourceFunctions": {
+ "Sample.Provider/widgets": {
+ "2026-01-01": [
+ {
+ "$ref": "types.json#/3"
+ }
+ ]
+ }
+ },
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/types.json
new file mode 100644
index 00000000..4cfdb76b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/package/types.json
@@ -0,0 +1,65 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "listSecretsInput",
+ "properties": {
+ "secretName": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The secret name."
+ }
+ }
+ },
+ {
+ "$type": "ObjectType",
+ "name": "listSecretsOutput",
+ "properties": {
+ "value": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 2,
+ "description": "The resolved secret value."
+ }
+ }
+ },
+ {
+ "$type": "ResourceFunctionType",
+ "name": "listSecrets",
+ "resourceType": "Sample.Provider/widgets",
+ "apiVersion": "2026-01-01",
+ "output": {
+ "$ref": "#/2"
+ },
+ "input": {
+ "$ref": "#/1"
+ }
+ },
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The widget name."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/4"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/scenario.json
new file mode 100644
index 00000000..f6335443
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-function/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "resource-function",
+ "description": "resourceFunctions can target ResourceFunctionType.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/index.json
new file mode 100644
index 00000000..db1cba53
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/4"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/types.json
new file mode 100644
index 00000000..14af0785
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/package/types.json
@@ -0,0 +1,49 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "IntegerType",
+ "minValue": 0,
+ "maxValue": 100
+ },
+ {
+ "$type": "BooleanType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The widget name."
+ },
+ "capacity": {
+ "type": {
+ "$ref": "#/1"
+ },
+ "flags": 0,
+ "description": "The requested widget capacity."
+ },
+ "enabled": {
+ "type": {
+ "$ref": "#/2"
+ },
+ "flags": 0,
+ "description": "Whether the widget is enabled."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/3"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/scenario.json
new file mode 100644
index 00000000..2b135626
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/resource-with-object-body/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "resource-with-object-body",
+ "description": "A resource body can reference an object type with primitive properties.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/index.json
new file mode 100644
index 00000000..9252ce3d
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/index.json
@@ -0,0 +1,19 @@
+{
+ "resources": {
+ "Sample.Provider/configuredWidgets@2026-01-01": {
+ "$ref": "types.json#/3"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": [],
+ "settings": {
+ "name": "SampleConfiguredWidgets",
+ "isSingleton": true,
+ "isPreview": false,
+ "isDeprecated": false,
+ "version": "1.0.0",
+ "configurationType": {
+ "$ref": "types.json#/1"
+ }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/types.json
new file mode 100644
index 00000000..39d552b6
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/package/types.json
@@ -0,0 +1,40 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "extensionConfiguration",
+ "properties": {
+ "endpoint": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The service endpoint used by the extension."
+ }
+ }
+ },
+ {
+ "$type": "ObjectType",
+ "name": "configuredWidgetBody",
+ "properties": {
+ "displayName": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 0,
+ "description": "The widget display name."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/configuredWidgets@2026-01-01",
+ "body": {
+ "$ref": "#/2"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/scenario.json
new file mode 100644
index 00000000..622697ff
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/canonical/settings-configuration-type/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "settings-configuration-type",
+ "description": "settings.configurationType can reference an object-like type.",
+ "category": "valid.canonical",
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/canonicalWriter.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/canonicalWriter.result.json
new file mode 100644
index 00000000..16371dac
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/canonicalWriter.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "canonicalWriter",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/compatibleReader.result.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/compatibleReader.result.json
new file mode 100644
index 00000000..24e80c2b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/expected/compatibleReader.result.json
@@ -0,0 +1,11 @@
+{
+ "isValid": true,
+ "mode": "compatibleReader",
+ "diagnostics": [],
+ "diagnosticsTruncated": false,
+ "summary": {
+ "errorCount": 0,
+ "warningCount": 0,
+ "infoCount": 0
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/index.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/index.json
new file mode 100644
index 00000000..ac5c71f9
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/index.json
@@ -0,0 +1,9 @@
+{
+ "resources": {
+ "Sample.Provider/widgets@2026-01-01": {
+ "$ref": "types.json#/2"
+ }
+ },
+ "resourceFunctions": {},
+ "namespaceFunctions": []
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/types.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/types.json
new file mode 100644
index 00000000..5b6e4b63
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/package/types.json
@@ -0,0 +1,27 @@
+[
+ {
+ "$type": "StringType"
+ },
+ {
+ "$type": "ObjectType",
+ "name": "widgetBody",
+ "properties": {
+ "name": {
+ "type": {
+ "$ref": "#/0"
+ },
+ "flags": 1,
+ "description": "The widget name."
+ }
+ }
+ },
+ {
+ "$type": "ResourceType",
+ "name": "Sample.Provider/widgets@2026-01-01",
+ "body": {
+ "$ref": "#/1"
+ },
+ "readableScopes": 8,
+ "writableScopes": 8
+ }
+]
diff --git a/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/scenario.json b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/scenario.json
new file mode 100644
index 00000000..0401027b
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Files/validation-samples/valid/input-forms/types-tgz-input/scenario.json
@@ -0,0 +1,15 @@
+{
+ "name": "types-tgz-input",
+ "description": "A tiny valid types.tgz archive validates successfully through the full pipeline, exercising archive input wiring.",
+ "category": "valid.input-forms",
+ "inputs": [
+ {
+ "kind": "archiveFile",
+ "path": "package.tgz"
+ }
+ ],
+ "modes": [
+ "canonicalWriter",
+ "compatibleReader"
+ ]
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/GraphTestHelpers.cs b/src/Bicep.Types.Validation.UnitTests/Graph/GraphTestHelpers.cs
new file mode 100644
index 00000000..a2ba5461
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Graph/GraphTestHelpers.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Azure.Bicep.Types.Validation.Packaging;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Graph;
+
+///
+/// Shared helpers for graph-layer tests: parsing JSON into s and
+/// an in-memory test double.
+///
+internal static class GraphTestHelpers
+{
+ public static PackageDocument Document(string packageRelativePath, string json)
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(json);
+ SourceMap.TryParse(bytes, packageRelativePath, out var root, out var sourceMap, out _);
+ var kind = packageRelativePath == "index.json"
+ ? PackageDocumentKind.Index
+ : PackageDocumentKind.TypeFile;
+ return new PackageDocument(packageRelativePath, kind, root!, sourceMap);
+ }
+}
+
+///
+/// An in-memory . A file mapped to null is treated as
+/// existing but unreadable (to exercise read-failure paths).
+///
+internal sealed class InMemoryPackageFileSystem : IPackageFileSystem
+{
+ private readonly Dictionary files =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ public InMemoryPackageFileSystem AddText(string packageRelativePath, string content)
+ {
+ files[packageRelativePath] = Encoding.UTF8.GetBytes(content);
+ return this;
+ }
+
+ public InMemoryPackageFileSystem AddUnreadable(string packageRelativePath)
+ {
+ files[packageRelativePath] = null;
+ return this;
+ }
+
+ public bool FileExists(string packageRelativePath) => files.ContainsKey(packageRelativePath);
+
+ public bool TryReadAllBytes(string packageRelativePath, out byte[] bytes, out string error)
+ {
+ if (files.TryGetValue(packageRelativePath, out var content) && content != null)
+ {
+ bytes = content;
+ error = string.Empty;
+ return true;
+ }
+
+ bytes = Array.Empty();
+ error = "simulated read failure";
+ return false;
+ }
+
+ public IEnumerable EnumerateFiles() => files.Keys;
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/SemanticGraphValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/SemanticGraphValidatorTests.cs
new file mode 100644
index 00000000..6d323acf
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Graph/SemanticGraphValidatorTests.cs
@@ -0,0 +1,269 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Linq;
+using Azure.Bicep.Types.Validation;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.Graph;
+using Azure.Bicep.Types.Validation.Packaging;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Graph;
+
+[TestClass]
+public class SemanticGraphValidatorTests
+{
+ private static System.Collections.Generic.IReadOnlyList Validate(
+ string indexJson, InMemoryPackageFileSystem fs)
+ {
+ var index = GraphTestHelpers.Document("index.json", indexJson);
+ var provider = new PackageDocumentProvider(fs, index, new TypePackageValidationOptions());
+ return SemanticGraphValidator.Validate(provider, index, new TypePackageValidationOptions());
+ }
+
+ private static string ResourceIndex(string refValue) =>
+ "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"" + refValue + "\"}}," +
+ "\"resourceFunctions\":{},\"namespaceFunctions\":[]}";
+
+ [TestMethod]
+ public void Valid_graph_produces_no_diagnostics()
+ {
+ const string types = @"[
+ { ""$type"": ""StringType"" },
+ { ""$type"": ""ObjectType"", ""name"": ""o"",
+ ""properties"": { ""p"": { ""type"": { ""$ref"": ""#/0"" }, ""flags"": 0 } } },
+ { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"",
+ ""body"": { ""$ref"": ""#/1"" }, ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+ Validate(ResourceIndex("types.json#/2"), fs).Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void Missing_referenced_file_reports_bcpvt017()
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json",
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"missing.json#/0\"},\"readableScopes\":8,\"writableScopes\":8}]");
+
+ var diagnostics = Validate(ResourceIndex("types.json#/0"), fs);
+
+ diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferencedTypeFileMissing);
+ }
+
+ [TestMethod]
+ public void Out_of_range_reference_reports_bcpvt019()
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json",
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/99\"},\"readableScopes\":8,\"writableScopes\":8}]");
+
+ Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferenceIndexOutOfRange);
+ }
+
+ [TestMethod]
+ public void Wrong_top_level_target_kind_reports_bcpvt020_with_related_location()
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json",
+ "[{\"$type\":\"ObjectType\",\"name\":\"notAResource\",\"properties\":{}}]");
+
+ var diagnostic = Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle().Subject;
+
+ diagnostic.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch);
+ diagnostic.RelatedLocations.Should().ContainSingle()
+ .Which.Message.Should().Be("Target type is declared here.");
+ }
+
+ [TestMethod]
+ public void Nested_wrong_target_kind_reports_bcpvt021()
+ {
+ const string types = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""o"",
+ ""properties"": { ""self"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } } },
+ { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"",
+ ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ Validate(ResourceIndex("types.json#/1"), fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.NestedTargetKindMismatch);
+ }
+
+ [TestMethod]
+ public void Every_edge_to_a_shared_node_is_kind_checked_once_per_edge()
+ {
+ // Two properties target the same ResourceType node; the node is traversed once but
+ // both edges must produce a nested-kind-mismatch diagnostic.
+ const string types = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""o"", ""properties"": {
+ ""a"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 },
+ ""b"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } } },
+ { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"",
+ ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ var diagnostics = Validate(ResourceIndex("types.json#/1"), fs);
+
+ diagnostics.Should().HaveCount(2);
+ diagnostics.Should().OnlyContain(d => d.Code == TypeValidationDiagnosticCodes.NestedTargetKindMismatch);
+ }
+
+ [TestMethod]
+ public void Cyclic_graph_terminates_without_overflow()
+ {
+ // Self-referential object property (ObjectType is a valid value type), plus a deep chain.
+ const string types = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""selfref"",
+ ""properties"": { ""self"": { ""type"": { ""$ref"": ""#/0"" }, ""flags"": 0 } } },
+ { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"",
+ ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ Validate(ResourceIndex("types.json#/1"), fs).Should().BeEmpty();
+ }
+
+ // ── Recovery: wrong-kind targets are not traversed ───────────────────────
+
+ [TestMethod]
+ public void Wrong_top_level_kind_does_not_traverse_into_target_edges()
+ {
+ // The resource root points at an ObjectType (wrong kind) whose property targets a
+ // ResourceType. Recovery must stop at the top-level mismatch and NOT descend into the
+ // ObjectType, so only the single BCPVT020 is reported.
+ const string types = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""wrongRoot"",
+ ""properties"": { ""p"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } } },
+ { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"",
+ ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch);
+ }
+
+ // ── Wrong-kind roots per root type ───────────────────────────────────────
+
+ [TestMethod]
+ public void Wrong_resource_function_root_kind_reports_bcpvt020()
+ {
+ const string index =
+ "{\"resources\":{},\"resourceFunctions\":{\"My.Rp/x\":{\"2026-01-01\":[{\"$ref\":\"types.json#/0\"}]}}," +
+ "\"namespaceFunctions\":[]}";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json",
+ "[{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]");
+
+ Validate(index, fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch);
+ }
+
+ [TestMethod]
+ public void Wrong_namespace_function_root_kind_reports_bcpvt020()
+ {
+ const string index =
+ "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[{\"$ref\":\"types.json#/0\"}]}";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json",
+ "[{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]");
+
+ Validate(index, fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch);
+ }
+
+ [TestMethod]
+ public void Wrong_configuration_type_kind_reports_bcpvt020()
+ {
+ const string index =
+ "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]," +
+ "\"settings\":{\"name\":\"s\",\"version\":\"1\",\"isSingleton\":true,\"configurationType\":{\"$ref\":\"types.json#/0\"}}}";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", "[{\"$type\":\"StringType\"}]");
+
+ Validate(index, fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch);
+ }
+
+ [TestMethod]
+ public void Wrong_fallback_resource_type_kind_reports_bcpvt020()
+ {
+ const string index =
+ "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]," +
+ "\"fallbackResourceType\":{\"$ref\":\"types.json#/0\"}}";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json",
+ "[{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]");
+
+ Validate(index, fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.TopLevelTargetKindMismatch);
+ }
+
+ // ── Nested wrong-kind targets per role ───────────────────────────────────
+
+ [TestMethod]
+ public void Resource_type_function_wrong_target_kind_reports_bcpvt021()
+ {
+ const string types = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""notAFunction"", ""properties"": {} },
+ { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", ""body"": { ""$ref"": ""#/0"" },
+ ""functions"": { ""list"": { ""type"": { ""$ref"": ""#/0"" } } },
+ ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ Validate(ResourceIndex("types.json#/1"), fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.NestedTargetKindMismatch);
+ }
+
+ [TestMethod]
+ public void Discriminated_object_base_property_wrong_target_kind_reports_bcpvt021()
+ {
+ const string types = @"[
+ { ""$type"": ""DiscriminatedObjectType"", ""name"": ""d"", ""discriminator"": ""kind"",
+ ""baseProperties"": { ""bad"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } },
+ ""elements"": {} },
+ { ""$type"": ""ResourceType"", ""name"": ""My.Rp/x@2026-01-01"", ""body"": { ""$ref"": ""#/0"" },
+ ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ Validate(ResourceIndex("types.json#/1"), fs).Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.NestedTargetKindMismatch);
+ }
+
+ // ── Duplicate missing-file references ────────────────────────────────────
+
+ [TestMethod]
+ public void Duplicate_missing_file_references_report_one_diagnostic_per_source_ref()
+ {
+ const string index =
+ "{\"resources\":{" +
+ "\"My.Rp/a@2026-01-01\":{\"$ref\":\"gone.json#/0\"}," +
+ "\"My.Rp/b@2026-01-01\":{\"$ref\":\"gone.json#/0\"}}," +
+ "\"resourceFunctions\":{},\"namespaceFunctions\":[]}";
+ var fs = new InMemoryPackageFileSystem();
+
+ var diagnostics = Validate(index, fs);
+
+ diagnostics.Should().HaveCount(2);
+ diagnostics.Should().OnlyContain(d => d.Code == TypeValidationDiagnosticCodes.ReferencedTypeFileMissing);
+ }
+
+ [TestMethod]
+ public void Graph_time_read_failure_reports_source_ref_location()
+ {
+ var fs = new InMemoryPackageFileSystem()
+ .AddText("types.json",
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"bad.json#/0\"},\"readableScopes\":8,\"writableScopes\":8}]")
+ .AddUnreadable("bad.json");
+
+ var diagnostic = Validate(ResourceIndex("types.json#/0"), fs).Should().ContainSingle().Subject;
+
+ diagnostic.Code.Should().Be(TypeValidationDiagnosticCodes.PackageFileReadFailed);
+ diagnostic.Path.Should().Be("types.json");
+ diagnostic.JsonPointer.Should().Be("/0/body/$ref");
+ diagnostic.Line.Should().NotBeNull();
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/TypeGraphBuilderTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/TypeGraphBuilderTests.cs
new file mode 100644
index 00000000..64c15031
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Graph/TypeGraphBuilderTests.cs
@@ -0,0 +1,283 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Linq;
+using Azure.Bicep.Types.Validation.Graph;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Graph;
+
+[TestClass]
+public class TypeGraphBuilderTests
+{
+ // ── Roots ────────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void ExtractRoots_reads_every_index_root_kind()
+ {
+ const string indexJson = @"{
+ ""resources"": { ""My.Rp/things@2026-01-01"": { ""$ref"": ""types.json#/0"" } },
+ ""resourceFunctions"": { ""My.Rp/things"": { ""2026-01-01"": [ { ""$ref"": ""types.json#/1"" } ] } },
+ ""namespaceFunctions"": [ { ""$ref"": ""types.json#/2"" } ],
+ ""settings"": { ""configurationType"": { ""$ref"": ""types.json#/3"" } },
+ ""fallbackResourceType"": { ""$ref"": ""types.json#/4"" }
+}";
+ var index = GraphTestHelpers.Document("index.json", indexJson);
+
+ var roots = TypeGraphBuilder.ExtractRoots(index);
+
+ roots.Select(r => r.Role).Should().BeEquivalentTo(new[]
+ {
+ TypeReferenceRole.ResourceRoot,
+ TypeReferenceRole.ResourceFunctionRoot,
+ TypeReferenceRole.NamespaceFunctionRoot,
+ TypeReferenceRole.ConfigurationType,
+ TypeReferenceRole.FallbackResourceType,
+ });
+ roots.Single(r => r.Role == TypeReferenceRole.ResourceRoot).Description
+ .Should().Be("Resource entry 'My.Rp/things@2026-01-01'");
+ roots.Single(r => r.Role == TypeReferenceRole.ResourceFunctionRoot).Description
+ .Should().Be("Resource function 'My.Rp/things@2026-01-01[0]'");
+ }
+
+ [TestMethod]
+ public void ExtractRoots_returns_empty_for_non_object_root()
+ {
+ var index = GraphTestHelpers.Document("index.json", "[]");
+ TypeGraphBuilder.ExtractRoots(index).Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ExtractRoots_skips_malformed_reference_objects()
+ {
+ const string indexJson = @"{
+ ""resources"": {
+ ""a"": { ""$ref"": 5 },
+ ""b"": { ""nope"": ""x"" },
+ ""c"": { ""$ref"": ""../escape.json#/0"" }
+ },
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+ var index = GraphTestHelpers.Document("index.json", indexJson);
+ TypeGraphBuilder.ExtractRoots(index).Should().BeEmpty();
+ }
+
+ // ── Nodes ────────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void BuildNodes_returns_null_for_non_array_root()
+ {
+ var doc = GraphTestHelpers.Document("types.json", "{\"$type\":\"StringType\"}");
+ TypeGraphBuilder.BuildNodes(doc).Should().BeNull();
+ }
+
+ [TestMethod]
+ public void BuildNodes_maps_index_and_marks_unusable_elements_null()
+ {
+ const string json = @"[
+ { ""$type"": ""StringType"" },
+ 42,
+ { ""name"": ""no-discriminator"" },
+ { ""$type"": ""NotARealType"" },
+ { ""$type"": ""ObjectType"", ""name"": ""o"", ""properties"": {} }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+
+ var nodes = TypeGraphBuilder.BuildNodes(doc);
+
+ nodes.Should().NotBeNull();
+ nodes!.Count.Should().Be(5);
+ nodes[0].Should().NotBeNull();
+ nodes[0]!.Discriminator.Should().Be("StringType");
+ nodes[1].Should().BeNull(); // primitive
+ nodes[2].Should().BeNull(); // no $type
+ nodes[3].Should().BeNull(); // unknown discriminator
+ nodes[4]!.Discriminator.Should().Be("ObjectType");
+ nodes[4]!.Id.Index.Should().Be(4);
+ nodes[4]!.JsonPointer.Should().Be("/4");
+ }
+
+ // ── Edges ────────────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void ExtractEdges_reads_resource_body_and_functions()
+ {
+ const string json = @"[
+ { ""$type"": ""ResourceType"", ""name"": ""r"", ""body"": { ""$ref"": ""#/1"" },
+ ""functions"": { ""list"": { ""type"": { ""$ref"": ""#/2"" } } },
+ ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var node = TypeGraphBuilder.BuildNodes(doc)![0]!;
+
+ var edges = TypeGraphBuilder.ExtractEdges(node);
+
+ edges.Select(e => e.Role).Should().BeEquivalentTo(new[]
+ {
+ TypeReferenceRole.ResourceBody,
+ TypeReferenceRole.ResourceTypeFunction,
+ });
+ edges.Single(e => e.Role == TypeReferenceRole.ResourceBody).Reference.Index.Should().Be(1);
+ }
+
+ [TestMethod]
+ public void ExtractEdges_reads_object_properties_and_additional_properties()
+ {
+ const string json = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""o"",
+ ""properties"": { ""p"": { ""type"": { ""$ref"": ""#/1"" }, ""flags"": 0 } },
+ ""additionalProperties"": { ""$ref"": ""#/2"" } }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var node = TypeGraphBuilder.BuildNodes(doc)![0]!;
+
+ var edges = TypeGraphBuilder.ExtractEdges(node);
+
+ edges.Select(e => e.Role).Should().BeEquivalentTo(new[]
+ {
+ TypeReferenceRole.ObjectPropertyType,
+ TypeReferenceRole.AdditionalProperties,
+ });
+ edges.Single(e => e.Role == TypeReferenceRole.ObjectPropertyType).MemberName.Should().Be("p");
+ }
+
+ [TestMethod]
+ public void ExtractEdges_reads_array_and_union_members()
+ {
+ const string json = @"[
+ { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""#/1"" } },
+ { ""$type"": ""UnionType"", ""elements"": [ { ""$ref"": ""#/0"" }, { ""$ref"": ""#/1"" } ] }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var nodes = TypeGraphBuilder.BuildNodes(doc)!;
+
+ TypeGraphBuilder.ExtractEdges(nodes[0]!).Should().ContainSingle()
+ .Which.Role.Should().Be(TypeReferenceRole.ArrayItem);
+ TypeGraphBuilder.ExtractEdges(nodes[1]!).Select(e => e.Role)
+ .Should().OnlyContain(r => r == TypeReferenceRole.UnionMember);
+ }
+
+ [TestMethod]
+ public void ExtractEdges_skips_malformed_references()
+ {
+ const string json = @"[
+ { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""/rooted.json#/0"" } }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var node = TypeGraphBuilder.BuildNodes(doc)![0]!;
+ TypeGraphBuilder.ExtractEdges(node).Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ExtractEdges_value_kinds_have_no_edges()
+ {
+ var doc = GraphTestHelpers.Document("types.json", "[{\"$type\":\"StringType\"}]");
+ var node = TypeGraphBuilder.BuildNodes(doc)![0]!;
+ TypeGraphBuilder.ExtractEdges(node).Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ExtractEdges_reads_discriminated_object_base_properties_and_elements()
+ {
+ const string json = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""a"", ""properties"": {} },
+ { ""$type"": ""DiscriminatedObjectType"", ""name"": ""d"", ""discriminator"": ""kind"",
+ ""baseProperties"": { ""id"": { ""type"": { ""$ref"": ""#/0"" }, ""flags"": 0 } },
+ ""elements"": { ""a"": { ""$ref"": ""#/0"" } } }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var node = TypeGraphBuilder.BuildNodes(doc)![1]!;
+
+ var edges = TypeGraphBuilder.ExtractEdges(node);
+
+ edges.Select(e => e.Role).Should().BeEquivalentTo(new[]
+ {
+ TypeReferenceRole.ObjectPropertyType,
+ TypeReferenceRole.DiscriminatedObjectElement,
+ });
+ edges.Single(e => e.Role == TypeReferenceRole.ObjectPropertyType).MemberName.Should().Be("id");
+ edges.Single(e => e.Role == TypeReferenceRole.DiscriminatedObjectElement).MemberName.Should().Be("a");
+ }
+
+ [TestMethod]
+ public void ExtractEdges_reads_function_type_parameters_and_output()
+ {
+ const string json = @"[
+ { ""$type"": ""FunctionType"",
+ ""parameters"": [
+ { ""name"": ""a"", ""type"": { ""$ref"": ""#/1"" } },
+ { ""name"": ""b"", ""type"": { ""$ref"": ""#/2"" } }
+ ],
+ ""output"": { ""$ref"": ""#/3"" } }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var node = TypeGraphBuilder.BuildNodes(doc)![0]!;
+
+ var edges = TypeGraphBuilder.ExtractEdges(node);
+
+ edges.Select(e => e.Role).Should().BeEquivalentTo(new[]
+ {
+ TypeReferenceRole.FunctionParameter,
+ TypeReferenceRole.FunctionParameter,
+ TypeReferenceRole.FunctionOutput,
+ });
+ edges.Where(e => e.Role == TypeReferenceRole.FunctionParameter)
+ .Select(e => e.Reference.Index).Should().BeEquivalentTo(new[] { 1, 2 });
+ edges.Where(e => e.Role == TypeReferenceRole.FunctionParameter)
+ .Select(e => e.MemberName).Should().BeEquivalentTo(new[] { "[0]", "[1]" });
+ edges.Single(e => e.Role == TypeReferenceRole.FunctionOutput).Reference.Index.Should().Be(3);
+ }
+
+ [TestMethod]
+ public void ExtractEdges_function_type_skips_parameters_without_type_reference()
+ {
+ // A parameter that is not an object, or lacks a well-formed 'type' reference, yields no
+ // edge; a missing 'output' yields no output edge. The structural layer owns those shapes.
+ const string json = @"[
+ { ""$type"": ""FunctionType"",
+ ""parameters"": [
+ 42,
+ { ""name"": ""noType"" },
+ { ""name"": ""bad"", ""type"": { ""$ref"": ""/rooted.json#/0"" } },
+ { ""name"": ""ok"", ""type"": { ""$ref"": ""#/1"" } }
+ ] }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var node = TypeGraphBuilder.BuildNodes(doc)![0]!;
+
+ var edges = TypeGraphBuilder.ExtractEdges(node);
+
+ edges.Should().ContainSingle()
+ .Which.Should().Match(e =>
+ e.Role == TypeReferenceRole.FunctionParameter && e.Reference.Index == 1 && e.MemberName == "[3]");
+ }
+
+ // ── Canonical reference-object shape ─────────────────────────────────────
+
+ [TestMethod]
+ public void ExtractRoots_skips_reference_object_with_extra_property()
+ {
+ // The structural layer reports the extra property (BCPVT014); the graph layer must not
+ // follow such a non-canonical reference (avoids a follow-on graph diagnostic).
+ const string indexJson = @"{
+ ""resources"": { ""a"": { ""$ref"": ""types.json#/0"", ""extra"": true } },
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+ var index = GraphTestHelpers.Document("index.json", indexJson);
+ TypeGraphBuilder.ExtractRoots(index).Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ExtractEdges_skips_reference_object_with_extra_property()
+ {
+ const string json = @"[
+ { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""#/0"", ""extra"": 1 } }
+]";
+ var doc = GraphTestHelpers.Document("types.json", json);
+ var node = TypeGraphBuilder.BuildNodes(doc)![0]!;
+ TypeGraphBuilder.ExtractEdges(node).Should().BeEmpty();
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/TypeReferenceResolverTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/TypeReferenceResolverTests.cs
new file mode 100644
index 00000000..6e284dcd
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Graph/TypeReferenceResolverTests.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Bicep.Types.Validation;
+using Azure.Bicep.Types.Validation.Graph;
+using Azure.Bicep.Types.Validation.Packaging;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Graph;
+
+[TestClass]
+public class TypeReferenceResolverTests
+{
+ private const string IndexJson =
+ "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]}";
+
+ private static TypeReferenceResolver CreateResolver(InMemoryPackageFileSystem fs)
+ {
+ var index = GraphTestHelpers.Document("index.json", IndexJson);
+ var provider = new PackageDocumentProvider(fs, index, new TypePackageValidationOptions());
+ return new TypeReferenceResolver(provider);
+ }
+
+ private static ParsedTypeReference Ref(string targetPath, int index, string sourcePath = "index.json") =>
+ new ParsedTypeReference(
+ $"{targetPath}#/{index}", targetPath, index, sourcePath, "/x/$ref", new SourceLocation(1, 1));
+
+ [TestMethod]
+ public void Resolve_returns_resolved_for_usable_target()
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", "[{\"$type\":\"StringType\"}]");
+ var resolution = CreateResolver(fs).Resolve(Ref("types.json", 0));
+
+ resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.Resolved);
+ resolution.TargetNode!.Discriminator.Should().Be("StringType");
+ }
+
+ [TestMethod]
+ public void Resolve_returns_resolved_for_same_file_reference()
+ {
+ var fs = new InMemoryPackageFileSystem()
+ .AddText("types.json", "[{\"$type\":\"StringType\"},{\"$type\":\"BooleanType\"}]");
+ // Empty target path + source types.json => same-file reference.
+ var resolution = CreateResolver(fs).Resolve(Ref(string.Empty, 1, sourcePath: "types.json"));
+
+ resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.Resolved);
+ resolution.TargetNode!.Discriminator.Should().Be("BooleanType");
+ }
+
+ [TestMethod]
+ public void Resolve_returns_missing_file_for_unknown_target()
+ {
+ var fs = new InMemoryPackageFileSystem();
+ var resolution = CreateResolver(fs).Resolve(Ref("nope.json", 0));
+
+ resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.MissingFile);
+ resolution.TargetPath.Should().Be("nope.json");
+ }
+
+ [TestMethod]
+ public void Resolve_returns_read_failed_for_unreadable_target()
+ {
+ var fs = new InMemoryPackageFileSystem().AddUnreadable("bad.json");
+ var resolution = CreateResolver(fs).Resolve(Ref("bad.json", 0));
+
+ resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.FileReadFailed);
+ resolution.ReadError.Should().NotBeNullOrEmpty();
+ }
+
+ [TestMethod]
+ public void Resolve_returns_unusable_for_non_array_target()
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("obj.json", "{}");
+ var resolution = CreateResolver(fs).Resolve(Ref("obj.json", 0));
+
+ resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.FileUnusable);
+ }
+
+ [TestMethod]
+ public void Resolve_returns_out_of_range_for_index_past_end()
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", "[{\"$type\":\"StringType\"}]");
+ var resolution = CreateResolver(fs).Resolve(Ref("types.json", 5));
+
+ resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.IndexOutOfRange);
+ resolution.TargetElementCount.Should().Be(1);
+ }
+
+ [TestMethod]
+ public void Resolve_returns_not_type_object_for_unusable_element()
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", "[42]");
+ var resolution = CreateResolver(fs).Resolve(Ref("types.json", 0));
+
+ resolution.Outcome.Should().Be(TypeReferenceResolutionOutcome.TargetNotTypeObject);
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Graph/TypeTargetKindValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Graph/TypeTargetKindValidatorTests.cs
new file mode 100644
index 00000000..c8cc0941
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Graph/TypeTargetKindValidatorTests.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using Azure.Bicep.Types.Validation.Graph;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Graph;
+
+[TestClass]
+public class TypeTargetKindValidatorTests
+{
+ // ── IsTopLevel ───────────────────────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("ResourceRoot", true)]
+ [DataRow("ResourceFunctionRoot", true)]
+ [DataRow("NamespaceFunctionRoot", true)]
+ [DataRow("FallbackResourceType", true)]
+ [DataRow("ConfigurationType", true)]
+ [DataRow("ResourceBody", false)]
+ [DataRow("ObjectPropertyType", false)]
+ [DataRow("ArrayItem", false)]
+ public void IsTopLevel_classifies_root_roles(string role, bool expected)
+ {
+ TypeTargetKindValidator.IsTopLevel(Role(role)).Should().Be(expected);
+ }
+
+ // ── IsAllowed: container roles ───────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("ResourceRoot", "ResourceType", true)]
+ [DataRow("ResourceRoot", "ObjectType", false)]
+ [DataRow("FallbackResourceType", "ResourceType", true)]
+ [DataRow("ResourceFunctionRoot", "ResourceFunctionType", true)]
+ [DataRow("ResourceFunctionRoot", "FunctionType", false)]
+ [DataRow("NamespaceFunctionRoot", "NamespaceFunctionType", true)]
+ [DataRow("ResourceBody", "ObjectType", true)]
+ [DataRow("ResourceBody", "DiscriminatedObjectType", true)]
+ [DataRow("ResourceBody", "StringType", false)]
+ [DataRow("ConfigurationType", "ObjectType", true)]
+ [DataRow("ResourceTypeFunction", "FunctionType", true)]
+ [DataRow("ResourceTypeFunction", "ObjectType", false)]
+ [DataRow("DiscriminatedObjectElement", "ObjectType", true)]
+ [DataRow("DiscriminatedObjectElement", "DiscriminatedObjectType", false)]
+ public void IsAllowed_enforces_container_role_targets(string role, string discriminator, bool expected)
+ {
+ TypeTargetKindValidator.IsAllowed(Role(role), discriminator).Should().Be(expected);
+ }
+
+ // ── IsAllowed: value-type roles ──────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("StringType", true)]
+ [DataRow("IntegerType", true)]
+ [DataRow("ObjectType", true)]
+ [DataRow("UnionType", true)]
+ [DataRow("BuiltInType", true)]
+ [DataRow("ResourceType", false)]
+ [DataRow("ResourceFunctionType", false)]
+ [DataRow("NamespaceFunctionType", false)]
+ [DataRow("FunctionType", false)]
+ public void IsAllowed_value_role_accepts_only_value_types(string discriminator, bool expected)
+ {
+ TypeTargetKindValidator.IsAllowed(TypeReferenceRole.ObjectPropertyType, discriminator)
+ .Should().Be(expected);
+ }
+
+ // ── ExpectedText ─────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void ExpectedText_describes_expected_kinds()
+ {
+ TypeTargetKindValidator.ExpectedText(TypeReferenceRole.ResourceRoot)
+ .Should().Be("a resource type ('ResourceType')");
+ TypeTargetKindValidator.ExpectedText(TypeReferenceRole.ResourceBody)
+ .Should().Be("an object type ('ObjectType' or 'DiscriminatedObjectType')");
+ TypeTargetKindValidator.ExpectedText(TypeReferenceRole.ObjectPropertyType)
+ .Should().Be("a value type");
+ }
+
+ private static TypeReferenceRole Role(string name) =>
+ (TypeReferenceRole)Enum.Parse(typeof(TypeReferenceRole), name);
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Hygiene/PackageHygieneTests.cs b/src/Bicep.Types.Validation.UnitTests/Hygiene/PackageHygieneTests.cs
new file mode 100644
index 00000000..c73c595e
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Hygiene/PackageHygieneTests.cs
@@ -0,0 +1,166 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using System.Linq;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Hygiene;
+
+[TestClass]
+public class PackageHygieneTests
+{
+ private const string MinimalIndexJson = @"{
+ ""resources"": {},
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+
+ private static readonly TypePackageValidator Validator = new();
+
+ [TestMethod]
+ public void Default_validation_ignores_unreachable_type_file()
+ {
+ using var dir = new TempDir();
+ WriteIndex(dir);
+ File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "[]");
+
+ var result = Validator.Validate(TypePackageValidationInput.ForDirectory(dir.Path));
+
+ result.IsValid.Should().BeTrue();
+ result.Diagnostics.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void ValidateUnreachableFiles_reports_unreachable_type_file()
+ {
+ using var dir = new TempDir();
+ WriteIndex(dir);
+ File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "[]");
+
+ var result = Validate(dir, validateUnreachable: true);
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnreachablePackageFile);
+ }
+
+ [TestMethod]
+ public void ValidateUnreachableFiles_reports_unexpected_non_json_file()
+ {
+ using var dir = new TempDir();
+ WriteIndex(dir);
+ File.WriteAllText(Path.Combine(dir.Path, "README.md"), "notes");
+
+ var result = Validate(dir, validateUnreachable: true);
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.UnexpectedPackageFile);
+ }
+
+ [TestMethod]
+ public void ValidateUnreachableFiles_validates_malformed_unreachable_type_file()
+ {
+ using var dir = new TempDir();
+ WriteIndex(dir);
+ File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "this is not json");
+
+ var result = Validate(dir, validateUnreachable: true);
+
+ result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.UnreachablePackageFile);
+ result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.JsonSyntaxInvalid);
+ }
+
+ [TestMethod]
+ public void ValidateUnreachableFiles_validates_semantic_defects_in_unreachable_type_file()
+ {
+ using var dir = new TempDir();
+ WriteIndex(dir);
+ File.WriteAllText(Path.Combine(dir.Path, "orphan.json"),
+ @"[ { ""$type"": ""IntegerType"", ""minValue"": 10, ""maxValue"": 5 } ]");
+
+ var result = Validate(dir, validateUnreachable: true);
+
+ result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.UnreachablePackageFile);
+ result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.NumericRangeInvalid);
+ }
+
+ [TestMethod]
+ public void ValidateUnreachableFiles_validates_graph_edges_in_unreachable_type_file()
+ {
+ using var dir = new TempDir();
+ WriteIndex(dir);
+ File.WriteAllText(Path.Combine(dir.Path, "orphan.json"),
+ @"[ { ""$type"": ""ArrayType"", ""itemType"": { ""$ref"": ""#/9"" } } ]");
+
+ var result = Validate(dir, validateUnreachable: true);
+
+ result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.UnreachablePackageFile);
+ result.Diagnostics.Select(d => d.Code).Should().Contain(TypeValidationDiagnosticCodes.ReferenceIndexOutOfRange);
+ }
+
+ [TestMethod]
+ public void ValidateUnreachableFiles_uses_deterministic_file_order()
+ {
+ using var dir = new TempDir();
+ WriteIndex(dir);
+ File.WriteAllText(Path.Combine(dir.Path, "b-orphan.json"), "[]");
+ File.WriteAllText(Path.Combine(dir.Path, "a-orphan.json"), "[]");
+
+ var result = Validate(dir, validateUnreachable: true);
+
+ var unreachablePaths = result.Diagnostics
+ .Where(d => d.Code == TypeValidationDiagnosticCodes.UnreachablePackageFile)
+ .Select(d => d.Path)
+ .ToList();
+
+ unreachablePaths.Should().Equal("a-orphan.json", "b-orphan.json");
+ }
+
+ [TestMethod]
+ public void ValidateUnreachableFiles_does_not_duplicate_reachable_file_diagnostics()
+ {
+ using var dir = new TempDir();
+ File.WriteAllText(Path.Combine(dir.Path, "index.json"), @"{
+ ""resources"": { ""Sample.Provider/widgets@2026-01-01"": { ""$ref"": ""types.json#/1"" } },
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}");
+ File.WriteAllText(Path.Combine(dir.Path, "types.json"), @"[
+ { ""$type"": ""ObjectType"", ""name"": ""body"", ""properties"": { ""size"": { ""type"": { ""$ref"": ""#/2"" }, ""flags"": 0 } } },
+ { ""$type"": ""ResourceType"", ""name"": ""Sample.Provider/widgets@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 },
+ { ""$type"": ""IntegerType"", ""minValue"": 10, ""maxValue"": 5 }
+]");
+ File.WriteAllText(Path.Combine(dir.Path, "orphan.json"), "[]");
+
+ var result = Validate(dir, validateUnreachable: true);
+
+ // The reachable defect is reported exactly once even with strict hygiene enabled.
+ result.Diagnostics.Count(d => d.Code == TypeValidationDiagnosticCodes.NumericRangeInvalid).Should().Be(1);
+ result.Diagnostics.Should().Contain(d => d.Code == TypeValidationDiagnosticCodes.UnreachablePackageFile);
+ }
+
+ private static TypePackageValidationResult Validate(TempDir dir, bool validateUnreachable)
+ {
+ var options = new TypePackageValidationOptions { ValidateUnreachableFiles = validateUnreachable };
+ return Validator.Validate(TypePackageValidationInput.ForDirectory(dir.Path), options);
+ }
+
+ private static void WriteIndex(TempDir dir) =>
+ File.WriteAllText(Path.Combine(dir.Path, "index.json"), MinimalIndexJson);
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), "bcpvt-hyg-" + System.IO.Path.GetRandomFileName());
+
+ public TempDir() => Directory.CreateDirectory(Path);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/ArchiveInputTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/ArchiveInputTests.cs
new file mode 100644
index 00000000..bd3de5f8
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Packaging/ArchiveInputTests.cs
@@ -0,0 +1,316 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Packaging;
+
+[TestClass]
+public class ArchiveInputTests
+{
+ private const string MinimalIndexJson = @"{
+ ""resources"": {},
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+
+ private static readonly TypePackageValidator Validator = new();
+
+ [TestMethod]
+ public void Archive_file_input_validates_minimal_package()
+ {
+ var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson));
+ using var file = new TempFile(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveFile(file.Path));
+
+ result.IsValid.Should().BeTrue();
+ result.Diagnostics.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void Archive_stream_input_validates_minimal_package()
+ {
+ var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.IsValid.Should().BeTrue();
+ // The caller's stream is read but not disposed.
+ stream.CanRead.Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void Archive_input_leading_dot_slash_prefix_is_accepted()
+ {
+ var archive = TarGzTestArchive.FromTextFiles(("./index.json", MinimalIndexJson));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.IsValid.Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void Archive_input_uses_member_paths_in_json_syntax_diagnostics()
+ {
+ var archive = TarGzTestArchive.FromTextFiles(("index.json", "this is not json"));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.JsonSyntaxInvalid)
+ .Which.Path.Should().Be("index.json");
+ }
+
+ [TestMethod]
+ public void Archive_input_missing_index_json_reports_bcpvt002()
+ {
+ var archive = TarGzTestArchive.FromTextFiles(("types.json", "[]"));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.IndexFileMissing);
+ }
+
+ [TestMethod]
+ public void Archive_input_malformed_gzip_reports_bcpvt030()
+ {
+ using var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 });
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchivePackageInvalid);
+ }
+
+ [TestMethod]
+ public void Archive_input_malformed_tar_reports_bcpvt030()
+ {
+ // Valid gzip container wrapping a header block with a corrupted ustar magic marker.
+ var tar = TarGzTestArchive.BuildTar(new[] { TarGzTestEntry.File("index.json", MinimalIndexJson) });
+ tar[257] = (byte)'X';
+ var archive = TarGzTestArchive.GzipCompress(tar);
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchivePackageInvalid);
+ }
+
+ [TestMethod]
+ public void Archive_input_duplicate_member_reports_bcpvt032()
+ {
+ var archive = TarGzTestArchive.Build(new[]
+ {
+ TarGzTestEntry.File("index.json", MinimalIndexJson),
+ TarGzTestEntry.File("types.json", "[]"),
+ TarGzTestEntry.File("types.json", "[]"),
+ });
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberDuplicate);
+ }
+
+ [TestMethod]
+ public void Archive_input_normalized_path_collision_reports_bcpvt033()
+ {
+ var archive = TarGzTestArchive.Build(new[]
+ {
+ TarGzTestEntry.File("index.json", MinimalIndexJson),
+ TarGzTestEntry.File("a/types.json", "[]"),
+ TarGzTestEntry.File("./a/types.json", "[]"),
+ });
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathCollision);
+ }
+
+ [TestMethod]
+ public void Archive_input_case_only_path_collision_reports_bcpvt033()
+ {
+ var archive = TarGzTestArchive.Build(new[]
+ {
+ TarGzTestEntry.File("index.json", MinimalIndexJson),
+ TarGzTestEntry.File("A/types.json", "[]"),
+ TarGzTestEntry.File("a/types.json", "[]"),
+ });
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathCollision);
+ }
+
+ [TestMethod]
+ public void Archive_input_absolute_member_path_reports_bcpvt031()
+ {
+ var archive = TarGzTestArchive.Build(new[]
+ {
+ TarGzTestEntry.File("index.json", MinimalIndexJson),
+ TarGzTestEntry.File("/etc/types.json", "[]"),
+ });
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid);
+ }
+
+ [TestMethod]
+ public void Archive_input_dotdot_member_path_reports_bcpvt031()
+ {
+ var archive = TarGzTestArchive.Build(new[]
+ {
+ TarGzTestEntry.File("index.json", MinimalIndexJson),
+ TarGzTestEntry.File("../types.json", "[]"),
+ });
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid);
+ }
+
+ [TestMethod]
+ public void Archive_input_backslash_member_path_reports_bcpvt031()
+ {
+ var archive = TarGzTestArchive.Build(new[]
+ {
+ TarGzTestEntry.File("index.json", MinimalIndexJson),
+ TarGzTestEntry.File("a\\types.json", "[]"),
+ });
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid);
+ }
+
+ [TestMethod]
+ public void Archive_input_symlink_member_reports_bcpvt031()
+ {
+ var archive = TarGzTestArchive.Build(new[]
+ {
+ TarGzTestEntry.File("index.json", MinimalIndexJson),
+ TarGzTestEntry.Symlink("link.json"),
+ });
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid);
+ }
+
+ [TestMethod]
+ public void Archive_input_with_resource_function_only_type_file_validates_when_member_present()
+ {
+ const string indexJson = @"{
+ ""resources"": { ""Sample.Provider/widgets@2026-01-01"": { ""$ref"": ""types.json#/1"" } },
+ ""resourceFunctions"": { ""Sample.Provider/widgets"": { ""2026-01-01"": [ { ""$ref"": ""functions.json#/0"" } ] } },
+ ""namespaceFunctions"": []
+}";
+ const string typesJson = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""body"", ""properties"": {} },
+ { ""$type"": ""ResourceType"", ""name"": ""Sample.Provider/widgets@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ const string functionsJson = @"[
+ { ""$type"": ""ResourceFunctionType"", ""name"": ""listSecrets"", ""resourceType"": ""Sample.Provider/widgets"", ""apiVersion"": ""2026-01-01"", ""output"": { ""$ref"": ""#/1"" }, ""input"": { ""$ref"": ""#/2"" } },
+ { ""$type"": ""ObjectType"", ""name"": ""listSecretsOutput"", ""properties"": {} },
+ { ""$type"": ""ObjectType"", ""name"": ""listSecretsInput"", ""properties"": {} }
+]";
+ var archive = TarGzTestArchive.FromTextFiles(
+ ("index.json", indexJson),
+ ("types.json", typesJson),
+ ("functions.json", functionsJson));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.IsValid.Should().BeTrue();
+ result.Diagnostics.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void Archive_input_missing_resource_function_only_type_file_reports_bcpvt017()
+ {
+ const string indexJson = @"{
+ ""resources"": { ""Sample.Provider/widgets@2026-01-01"": { ""$ref"": ""types.json#/1"" } },
+ ""resourceFunctions"": { ""Sample.Provider/widgets"": { ""2026-01-01"": [ { ""$ref"": ""functions.json#/0"" } ] } },
+ ""namespaceFunctions"": []
+}";
+ const string typesJson = @"[
+ { ""$type"": ""ObjectType"", ""name"": ""body"", ""properties"": {} },
+ { ""$type"": ""ResourceType"", ""name"": ""Sample.Provider/widgets@2026-01-01"", ""body"": { ""$ref"": ""#/0"" }, ""readableScopes"": 8, ""writableScopes"": 8 }
+]";
+ var archive = TarGzTestArchive.FromTextFiles(
+ ("index.json", indexJson),
+ ("types.json", typesJson));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ReferencedTypeFileMissing);
+ }
+
+ [TestMethod]
+ public void Archive_and_equivalent_directory_produce_same_result()
+ {
+ var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson));
+ using var file = new TempFile(archive);
+ using var dir = new TempDir();
+ File.WriteAllText(Path.Combine(dir.Path, "index.json"), MinimalIndexJson);
+
+ var archiveResult = Validator.Validate(TypePackageValidationInput.ForArchiveFile(file.Path));
+ var directoryResult = Validator.Validate(TypePackageValidationInput.ForDirectory(dir.Path));
+
+ archiveResult.IsValid.Should().Be(directoryResult.IsValid);
+ archiveResult.Diagnostics.Count.Should().Be(directoryResult.Diagnostics.Count);
+ }
+
+ private sealed class TempFile : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), "bcpvt-arc-" + System.IO.Path.GetRandomFileName() + ".tgz");
+
+ public TempFile(byte[] content) => File.WriteAllBytes(Path, content);
+
+ public void Dispose()
+ {
+ try { File.Delete(Path); } catch { /* best-effort */ }
+ }
+ }
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), "bcpvt-arc-" + System.IO.Path.GetRandomFileName());
+
+ public TempDir() => Directory.CreateDirectory(Path);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/DirectoryPackageFileSystemTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/DirectoryPackageFileSystemTests.cs
new file mode 100644
index 00000000..410bb026
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Packaging/DirectoryPackageFileSystemTests.cs
@@ -0,0 +1,137 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using Azure.Bicep.Types.Validation.Packaging;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Packaging;
+
+[TestClass]
+public class DirectoryPackageFileSystemTests
+{
+ // ── Nonexistent root ────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Nonexistent_root_FileExists_returns_false()
+ {
+ var fs = new DirectoryPackageFileSystem("/path/does-not-exist-xyz");
+ fs.FileExists("index.json").Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void Nonexistent_root_TryReadAllBytes_returns_false_with_error()
+ {
+ var fs = new DirectoryPackageFileSystem("/path/does-not-exist-xyz");
+ var ok = fs.TryReadAllBytes("index.json", out _, out string error);
+ ok.Should().BeFalse();
+ error.Should().NotBeNullOrEmpty();
+ }
+
+ // ── Absolute package-relative path rejection ────────────────────────────
+
+ [TestMethod]
+ public void Absolute_package_relative_path_FileExists_returns_false()
+ {
+ using var dir = new TempDir();
+ var fs = new DirectoryPackageFileSystem(dir.Path);
+
+ // platform-absolute paths are rejected before touching the file system
+ fs.FileExists("/etc/passwd").Should().BeFalse();
+ fs.FileExists("C:\\Windows\\System32\\cmd.exe").Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void Absolute_package_relative_path_TryReadAllBytes_returns_false()
+ {
+ using var dir = new TempDir();
+ var fs = new DirectoryPackageFileSystem(dir.Path);
+ var ok = fs.TryReadAllBytes("/etc/passwd", out _, out _);
+ ok.Should().BeFalse();
+ }
+
+ // ── Path traversal rejection ────────────────────────────────────────────
+
+ [TestMethod]
+ public void DotDot_traversal_FileExists_returns_false()
+ {
+ using var dir = new TempDir();
+ var fs = new DirectoryPackageFileSystem(dir.Path);
+ fs.FileExists("../outside-root.txt").Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void DotDot_traversal_TryReadAllBytes_returns_false()
+ {
+ using var dir = new TempDir();
+ var fs = new DirectoryPackageFileSystem(dir.Path);
+ var ok = fs.TryReadAllBytes("../../secret.txt", out _, out _);
+ ok.Should().BeFalse();
+ }
+
+ // ── Separator normalization ─────────────────────────────────────────────
+
+ [TestMethod]
+ public void Backslash_and_slash_separators_normalize_for_package_identity()
+ {
+ using var dir = new TempDir();
+ Directory.CreateDirectory(Path.Combine(dir.Path, "sub"));
+ File.WriteAllText(Path.Combine(dir.Path, "sub", "types.json"), "[]");
+
+ var fs = new DirectoryPackageFileSystem(dir.Path);
+
+ // Both slash variants should resolve to the same file
+ fs.FileExists("sub/types.json").Should().BeTrue();
+ fs.FileExists(@"sub\types.json").Should().BeTrue();
+ }
+
+ // ── Normal reads ────────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Existing_file_is_read_with_deterministic_utf8_bytes()
+ {
+ using var dir = new TempDir();
+ const string content = "{\"hello\":\"wörld\"}";
+ byte[] expected = System.Text.Encoding.UTF8.GetBytes(content);
+ File.WriteAllBytes(Path.Combine(dir.Path, "data.json"), expected);
+
+ var fs = new DirectoryPackageFileSystem(dir.Path);
+ var ok = fs.TryReadAllBytes("data.json", out byte[] actual, out _);
+
+ ok.Should().BeTrue();
+ actual.Should().Equal(expected);
+ }
+
+ // ── Trailing separator on root ──────────────────────────────────────────
+
+ [TestMethod]
+ public void Root_with_trailing_separator_still_resolves_child_files()
+ {
+ using var dir = new TempDir();
+ File.WriteAllText(Path.Combine(dir.Path, "index.json"), "{}");
+
+ // Supply the root with a trailing directory separator.
+ var rootWithSlash = dir.Path + Path.DirectorySeparatorChar;
+ var fs = new DirectoryPackageFileSystem(rootWithSlash);
+
+ fs.FileExists("index.json").Should().BeTrue();
+ fs.TryReadAllBytes("index.json", out _, out _).Should().BeTrue();
+ }
+
+ // ── Helper ──────────────────────────────────────────────────────────────
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), "bcpvt-fstest-" + System.IO.Path.GetRandomFileName());
+
+ public TempDir() => Directory.CreateDirectory(Path);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/PackageArchiveReaderTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageArchiveReaderTests.cs
new file mode 100644
index 00000000..d07ad315
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageArchiveReaderTests.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.IO;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.Packaging;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Packaging;
+
+[TestClass]
+public class PackageArchiveReaderTests
+{
+ private const string MinimalIndexJson = @"{
+ ""resources"": {},
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+
+ [TestMethod]
+ public void PackageInputResolver_archive_file_preserves_display_path_and_physical_path()
+ {
+ var resolution = PackageInputResolver.Resolve(
+ TypePackageValidationInput.ForArchiveFile("some/dir/types.tgz"));
+
+ resolution.Kind.Should().Be(PackageInputKind.ArchiveFile);
+ resolution.ArchiveFilePath.Should().Be("some/dir/types.tgz");
+ resolution.DisplayPath.Should().Contain("types.tgz");
+ resolution.ArchiveBytes.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void PackageInputResolver_archive_stream_preserves_display_path_and_stream()
+ {
+ var payload = new byte[] { 1, 2, 3, 4 };
+ using var stream = new MemoryStream(payload);
+
+ var resolution = PackageInputResolver.Resolve(
+ TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ resolution.Kind.Should().Be(PackageInputKind.ArchiveStream);
+ resolution.DisplayPath.Should().Be("types.tgz");
+ resolution.ArchiveBytes.Should().Equal(payload);
+ // The caller's stream is read fully but not disposed.
+ stream.CanRead.Should().BeTrue();
+ }
+
+ [TestMethod]
+ public void PackageReader_archive_input_returns_archive_file_system()
+ {
+ var archive = TarGzTestArchive.FromTextFiles(("index.json", MinimalIndexJson));
+ using var stream = new MemoryStream(archive);
+ var resolution = PackageInputResolver.Resolve(
+ TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ var result = PackageReader.Read(resolution, new TypePackageValidationOptions());
+
+ result.HasFatalReadFailure.Should().BeFalse();
+ result.FileSystem.Should().BeOfType();
+ result.Documents.IndexDocument.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public void PackageReader_archive_read_failure_is_fatal()
+ {
+ using var stream = new MemoryStream(new byte[] { 9, 9, 9, 9 });
+ var resolution = PackageInputResolver.Resolve(
+ TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ var result = PackageReader.Read(resolution, new TypePackageValidationOptions());
+
+ result.HasFatalReadFailure.Should().BeTrue();
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ArchivePackageInvalid);
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/PackageReaderTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageReaderTests.cs
new file mode 100644
index 00000000..4ed39c97
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Packaging/PackageReaderTests.cs
@@ -0,0 +1,167 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using Azure.Bicep.Types.Validation;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.Packaging;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Packaging;
+
+[TestClass]
+public class PackageReaderTests
+{
+ private static readonly TypePackageValidationOptions DefaultOptions = new();
+
+ // ── Directory input ─────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Directory_input_reads_package_root_index_json()
+ {
+ using var pkg = CreateMinimalPackage();
+ var resolution = Resolve(pkg.Path, isDirectory: true);
+ var result = PackageReader.Read(resolution, DefaultOptions);
+
+ result.HasFatalReadFailure.Should().BeFalse();
+ result.Documents.IndexDocument.Should().NotBeNull();
+ result.Documents.IndexDocument!.PackageRelativePath.Should().Be("index.json");
+ }
+
+ [TestMethod]
+ public void Index_file_input_treats_containing_directory_as_package_root()
+ {
+ using var pkg = CreateMinimalPackage();
+ string indexPath = Path.Combine(pkg.Path, "index.json");
+ var resolution = Resolve(indexPath, isDirectory: false);
+ var result = PackageReader.Read(resolution, DefaultOptions);
+
+ result.HasFatalReadFailure.Should().BeFalse();
+ result.Documents.IndexDocument.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public void Nonexistent_directory_input_returns_package_path_invalid()
+ {
+ var resolution = Resolve("C:\\does-not-exist-xyz-abc", isDirectory: true);
+ var result = PackageReader.Read(resolution, DefaultOptions);
+
+ result.HasFatalReadFailure.Should().BeTrue();
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PackagePathInvalid);
+ }
+
+ [TestMethod]
+ public void Directory_input_with_missing_index_json_returns_index_file_missing()
+ {
+ using var emptyDir = new TempDir();
+ var resolution = Resolve(emptyDir.Path, isDirectory: true);
+ var result = PackageReader.Read(resolution, DefaultOptions);
+
+ result.HasFatalReadFailure.Should().BeTrue();
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.IndexFileMissing);
+ }
+
+ // ── Type files are not loaded by the reader (owned by the graph provider) ─
+
+ [TestMethod]
+ public void Reader_does_not_load_type_files()
+ {
+ // Type-file loading and transitive closure are owned by the graph layer's
+ // package document provider; the reader materializes only index.json.
+ using var pkg = CreatePackageWithTypes();
+ var resolution = Resolve(pkg.Path, isDirectory: true);
+ var result = PackageReader.Read(resolution, DefaultOptions);
+
+ result.HasFatalReadFailure.Should().BeFalse();
+ result.Documents.TypeFiles.Should().BeEmpty();
+ result.FileSystem.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public void Missing_referenced_type_file_is_not_a_reader_read_failure()
+ {
+ // index.json references types.json but that file doesn't exist. The reader only
+ // parses index.json, so it reports no read failure; the missing file is reported
+ // later by graph validation (BCPVT017).
+ const string indexJson = @"{
+ ""resources"": { ""S/r@2026-01-01"": { ""$ref"": ""types.json#/0"" } },
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+ using var pkg = new TempDir();
+ File.WriteAllText(Path.Combine(pkg.Path, "index.json"), indexJson);
+ // types.json intentionally NOT created
+
+ var resolution = Resolve(pkg.Path, isDirectory: true);
+ var result = PackageReader.Read(resolution, DefaultOptions);
+
+ result.HasFatalReadFailure.Should().BeFalse(); // index read succeeded
+ result.Diagnostics.Should().BeEmpty();
+ }
+
+ // ── Archive input pass-through (handled upstream) ────────────────────────
+
+ [TestMethod]
+ public void Null_package_root_path_returns_package_path_invalid()
+ {
+ // A resolution with null PackageRootPath shouldn't crash the reader
+ var resolution = new PackageInputResolution(
+ PackageInputKind.Directory, "display",
+ packageRootPath: null, indexFilePath: null,
+ diagnostics: new TypeValidationDiagnostic[0]);
+
+ var result = PackageReader.Read(resolution, DefaultOptions);
+
+ result.HasFatalReadFailure.Should().BeTrue();
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.PackagePathInvalid);
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private static PackageInputResolution Resolve(string path, bool isDirectory)
+ {
+ var input = isDirectory
+ ? TypePackageValidationInput.ForDirectory(path)
+ : TypePackageValidationInput.ForIndexFile(path);
+ return PackageInputResolver.Resolve(input);
+ }
+
+ private static TempDir CreateMinimalPackage()
+ {
+ var dir = new TempDir();
+ File.WriteAllText(Path.Combine(dir.Path, "index.json"),
+ "{\"resources\":{},\"resourceFunctions\":{},\"namespaceFunctions\":[]}");
+ return dir;
+ }
+
+ private static TempDir CreatePackageWithTypes()
+ {
+ var dir = new TempDir();
+ const string indexJson = @"{
+ ""resources"": { ""S/r@2026-01-01"": { ""$ref"": ""types.json#/0"" } },
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+ File.WriteAllText(Path.Combine(dir.Path, "index.json"), indexJson);
+ File.WriteAllText(Path.Combine(dir.Path, "types.json"), "[{\"$type\":\"StringType\"}]");
+ return dir;
+ }
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), "bcpvt-rdr-" + System.IO.Path.GetRandomFileName());
+
+ public TempDir() => Directory.CreateDirectory(Path);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/PaxArchiveInteropTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/PaxArchiveInteropTests.cs
new file mode 100644
index 00000000..720b27d4
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Packaging/PaxArchiveInteropTests.cs
@@ -0,0 +1,156 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Formats.Tar;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.Packaging;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Packaging;
+
+///
+/// Interop coverage proving that gzip-compressed tar archives produced the way Bicep's own tgz writer
+/// produces them — via in using
+/// — validate successfully. .NET emits a PAX extended-header entry (typeflag
+/// 'x') before every file entry; earlier the reader forwarded those headers as unsupported members
+/// and rejected real Bicep archives. These tests use the framework's own tar writer (available on
+/// net8.0) rather than the hand-rolled ustar helper so a regression is caught against the real
+/// producer format.
+///
+[TestClass]
+public class PaxArchiveInteropTests
+{
+ private const string MinimalIndexJson = @"{
+ ""resources"": {},
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+
+ private const string ResourceIndexJson = @"{
+ ""resources"": {
+ ""Sample.Provider/widgets@2026-01-01"": {
+ ""$ref"": ""types.json#/2""
+ }
+ },
+ ""resourceFunctions"": {},
+ ""namespaceFunctions"": []
+}";
+
+ private const string ResourceTypesJson = @"[
+ { ""$type"": ""StringType"" },
+ {
+ ""$type"": ""ObjectType"",
+ ""name"": ""widgetBody"",
+ ""properties"": {
+ ""name"": {
+ ""type"": { ""$ref"": ""#/0"" },
+ ""flags"": 1,
+ ""description"": ""The widget name.""
+ }
+ }
+ },
+ {
+ ""$type"": ""ResourceType"",
+ ""name"": ""Sample.Provider/widgets@2026-01-01"",
+ ""body"": { ""$ref"": ""#/1"" },
+ ""readableScopes"": 8,
+ ""writableScopes"": 8
+ }
+]";
+
+ private static readonly TypePackageValidator Validator = new();
+
+ [TestMethod]
+ public void Pax_archive_stream_validates_minimal_package()
+ {
+ var archive = BuildPaxArchive(("index.json", MinimalIndexJson));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.IsValid.Should().BeTrue();
+ result.Diagnostics.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void Pax_archive_stream_validates_multi_file_resource_package()
+ {
+ var archive = BuildPaxArchive(
+ ("index.json", ResourceIndexJson),
+ ("types.json", ResourceTypesJson));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.IsValid.Should().BeTrue();
+ result.Diagnostics.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void Pax_archive_does_not_report_extended_header_as_unsupported_member()
+ {
+ var archive = BuildPaxArchive(("index.json", MinimalIndexJson));
+ using var stream = new MemoryStream(archive);
+
+ var result = Validator.Validate(TypePackageValidationInput.ForArchiveStream(stream, "types.tgz"));
+
+ result.Diagnostics.Should().NotContain(d =>
+ d.Code == TypeValidationDiagnosticCodes.ArchivePackageInvalid ||
+ d.Code == TypeValidationDiagnosticCodes.ArchiveMemberPathInvalid);
+ }
+
+ [TestMethod]
+ public void Pax_archive_reader_consumes_extended_headers_and_returns_only_file_members()
+ {
+ var archive = BuildPaxArchive(
+ ("index.json", MinimalIndexJson),
+ ("types.json", "[]"));
+
+ var readResult = TarGzArchiveReader.Read(archive);
+
+ readResult.Success.Should().BeTrue();
+ readResult.Entries.Should().OnlyContain(e => e.TypeFlag == (byte)'0' || e.TypeFlag == 0);
+ readResult.Entries.Select(e => e.RawName).Should().BeEquivalentTo(new[] { "index.json", "types.json" });
+ }
+
+ [TestMethod]
+ public void Pax_archive_with_long_member_name_uses_the_extended_path()
+ {
+ // Names longer than the 100-byte ustar name field force .NET to emit the real path only in the
+ // PAX "path" extended attribute, exercising the reader's override handling.
+ var longName = "deeply/nested/" + new string('a', 120) + "/types.json";
+ var archive = BuildPaxArchive(
+ ("index.json", MinimalIndexJson),
+ (longName, "[]"));
+
+ var readResult = TarGzArchiveReader.Read(archive);
+
+ readResult.Success.Should().BeTrue();
+ readResult.Entries.Select(e => e.RawName).Should().Contain(longName);
+ }
+
+ private static byte[] BuildPaxArchive(params (string Name, string Text)[] files)
+ {
+ using var outer = new MemoryStream();
+ using (var gzip = new GZipStream(outer, CompressionLevel.Optimal, leaveOpen: true))
+ using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
+ {
+ foreach (var (name, text) in files)
+ {
+ var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
+ {
+ DataStream = new MemoryStream(Encoding.UTF8.GetBytes(text)),
+ };
+ tar.WriteEntry(entry);
+ }
+ }
+
+ return outer.ToArray();
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/SourceMapTests.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/SourceMapTests.cs
new file mode 100644
index 00000000..c514f16f
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Packaging/SourceMapTests.cs
@@ -0,0 +1,162 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using System.Text;
+using Azure.Bicep.Types.Validation.Packaging;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Packaging;
+
+[TestClass]
+public class SourceMapTests
+{
+ // ── Root value location ─────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Root_value_location_is_1_based()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("{\"x\":1}");
+ var sm = SourceMap.Create(json);
+ var loc = sm.GetLocation(0);
+ loc.Line.Should().Be(1);
+ loc.Column.Should().Be(1);
+ }
+
+ // ── Object property locations ───────────────────────────────────────────
+
+ [TestMethod]
+ public void Object_property_value_location_is_available_via_byte_offset()
+ {
+ // {"x":42}
+ // offset 0 = { (line 1 col 1)
+ // offset 1 = " (start of "x")
+ // offset 5 = 4 (start of 42)
+ byte[] json = Encoding.UTF8.GetBytes("{\"x\":42}");
+ SourceMap.TryParse(json, "test.json", out var root, out var sm, out _);
+ root.Should().NotBeNull();
+ root!.TryGetProperty("x", out var xNode).Should().BeTrue();
+ var loc = sm.GetLocation(xNode.ByteOffset);
+ loc.Line.Should().Be(1);
+ loc.Column.Should().BePositive();
+ }
+
+ // ── Array element location ──────────────────────────────────────────────
+
+ [TestMethod]
+ public void Array_element_location_is_available()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("[1, 2, 3]");
+ SourceMap.TryParse(json, "test.json", out var root, out var sm, out _);
+ root!.Elements.Count.Should().Be(3);
+ var elem1Loc = sm.GetLocation(root.Elements[0].ByteOffset);
+ var elem2Loc = sm.GetLocation(root.Elements[1].ByteOffset);
+ elem1Loc.Column.Should().BeLessThan(elem2Loc.Column);
+ }
+
+ // ── Newline handling ────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void LF_newline_resets_column_to_1_on_next_line()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("{\n\"x\":1\n}");
+ SourceMap.TryParse(json, "test.json", out var root, out var sm, out _);
+ root!.TryGetProperty("x", out var xNode).Should().BeTrue();
+ // "x" key is on line 2
+ var keyProp = root.Properties[0];
+ var keyLoc = sm.GetLocation(keyProp.NameByteOffset);
+ keyLoc.Line.Should().Be(2);
+ keyLoc.Column.Should().Be(1);
+ }
+
+ [TestMethod]
+ public void CRLF_newline_treated_as_single_line_break()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("{\r\n\"x\":1\r\n}");
+ SourceMap.TryParse(json, "test.json", out var root, out var sm, out _);
+ root!.TryGetProperty("x", out var xNode).Should().BeTrue();
+ var keyLoc = sm.GetLocation(root.Properties[0].NameByteOffset);
+ keyLoc.Line.Should().Be(2);
+ keyLoc.Column.Should().Be(1);
+ }
+
+ // ── UTF-16 column counting ──────────────────────────────────────────────
+
+ [TestMethod]
+ public void Columns_are_1_based_utf16_code_units_including_non_ascii()
+ {
+ // Line with a 3-byte UTF-8 character (€ = U+20AC) before the property
+ // "€": 1 → key starts at UTF-16 column 2 (after the "{")
+ string line = "{\"" + "\u20AC" + "\":1}";
+ byte[] json = Encoding.UTF8.GetBytes(line);
+ SourceMap.TryParse(json, "test.json", out var root, out var sm, out _);
+ root!.Properties.Count.Should().Be(1);
+ // Key byte offset is 1 (the quote after {)
+ var keyLoc = sm.GetLocation(root.Properties[0].NameByteOffset);
+ keyLoc.Line.Should().Be(1);
+ // Column should reflect UTF-16 units: "{" takes column 1, the quote is column 2
+ keyLoc.Column.Should().Be(2);
+ }
+
+ // ── Single-pass build ───────────────────────────────────────────────────
+
+ [TestMethod]
+ public void TryParse_builds_value_nodes_and_source_map_in_one_call()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("{\"a\":1,\"b\":\"hello\"}");
+ var success = SourceMap.TryParse(json, "test.json", out var root, out var sm, out var err);
+ success.Should().BeTrue();
+ err.Should().BeNull();
+ root.Should().NotBeNull();
+ sm.Should().NotBeNull();
+ root!.Properties.Count.Should().Be(2);
+ }
+
+ // ── Malformed JSON ──────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Malformed_json_returns_false_with_line_column()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("{ invalid }");
+ var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err);
+ success.Should().BeFalse();
+ root.Should().BeNull();
+ err.Should().NotBeNull();
+ err!.Value.line.Should().BeGreaterThan(0);
+ err.Value.column.Should().BeGreaterThan(0);
+ }
+
+ // ── Trailing content after root value ───────────────────────────────────
+
+ [TestMethod]
+ public void Trailing_garbage_after_object_root_is_rejected()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("{\"x\":1} garbage");
+ var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err);
+ success.Should().BeFalse();
+ root.Should().BeNull();
+ err.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public void Second_top_level_value_after_array_root_is_rejected()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("[1,2] [3,4]");
+ var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err);
+ success.Should().BeFalse();
+ root.Should().BeNull();
+ err.Should().NotBeNull();
+ }
+
+ [TestMethod]
+ public void Trailing_whitespace_after_root_value_is_accepted()
+ {
+ byte[] json = Encoding.UTF8.GetBytes("{\"x\":1}\r\n \n");
+ var success = SourceMap.TryParse(json, "test.json", out var root, out _, out var err);
+ success.Should().BeTrue();
+ err.Should().BeNull();
+ root.Should().NotBeNull();
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Packaging/TarGzTestArchive.cs b/src/Bicep.Types.Validation.UnitTests/Packaging/TarGzTestArchive.cs
new file mode 100644
index 00000000..fb60aaba
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Packaging/TarGzTestArchive.cs
@@ -0,0 +1,156 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Text;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Packaging;
+
+///
+/// A single entry to place into a test tar archive.
+///
+internal readonly struct TarGzTestEntry
+{
+ public TarGzTestEntry(string name, byte[] content, byte typeFlag = (byte)'0')
+ {
+ Name = name;
+ Content = content;
+ TypeFlag = typeFlag;
+ }
+
+ public string Name { get; }
+
+ public byte[] Content { get; }
+
+ public byte TypeFlag { get; }
+
+ public static TarGzTestEntry File(string name, string content) =>
+ new TarGzTestEntry(name, Encoding.UTF8.GetBytes(content));
+
+ public static TarGzTestEntry Directory(string name) =>
+ new TarGzTestEntry(name, Array.Empty(), (byte)'5');
+
+ public static TarGzTestEntry Symlink(string name) =>
+ new TarGzTestEntry(name, Array.Empty(), (byte)'2');
+}
+
+///
+/// Builds tiny gzip-compressed ustar archives for archive-input tests, producing correct headers,
+/// octal size fields, and checksums so the product reader accepts valid archives.
+///
+internal static class TarGzTestArchive
+{
+ private const int BlockSize = 512;
+
+ /// Builds a gzip-compressed tar from text file entries.
+ public static byte[] FromTextFiles(params (string name, string content)[] files)
+ {
+ var entries = new List();
+ foreach (var (name, content) in files)
+ {
+ entries.Add(TarGzTestEntry.File(name, content));
+ }
+ return Build(entries);
+ }
+
+ /// Builds a gzip-compressed tar from arbitrary entries.
+ public static byte[] Build(IEnumerable entries)
+ {
+ byte[] tar = BuildTar(entries);
+ return GzipCompress(tar);
+ }
+
+ /// Builds an uncompressed tar (for producing malformed-container fixtures).
+ public static byte[] BuildTar(IEnumerable entries)
+ {
+ using var stream = new MemoryStream();
+ foreach (var entry in entries)
+ {
+ WriteEntry(stream, entry);
+ }
+
+ // Two zero blocks terminate the archive.
+ stream.Write(new byte[BlockSize * 2], 0, BlockSize * 2);
+ return stream.ToArray();
+ }
+
+ /// Gzip-compresses raw bytes.
+ public static byte[] GzipCompress(byte[] bytes)
+ {
+ using var output = new MemoryStream();
+ using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ gzip.Write(bytes, 0, bytes.Length);
+ }
+ return output.ToArray();
+ }
+
+ private static void WriteEntry(Stream stream, TarGzTestEntry entry)
+ {
+ var header = new byte[BlockSize];
+ byte[] nameBytes = Encoding.ASCII.GetBytes(entry.Name);
+ Array.Copy(nameBytes, 0, header, 0, Math.Min(nameBytes.Length, 100));
+
+ WriteOctal(header, 100, 8, 0b_110_100_100); // mode 0644
+ WriteOctal(header, 108, 8, 0); // uid
+ WriteOctal(header, 116, 8, 0); // gid
+ WriteOctal(header, 124, 12, entry.Content.Length); // size
+ WriteOctal(header, 136, 12, 0); // mtime
+
+ header[156] = entry.TypeFlag;
+
+ // ustar magic + version.
+ byte[] magic = Encoding.ASCII.GetBytes("ustar");
+ Array.Copy(magic, 0, header, 257, magic.Length);
+ header[263] = (byte)'0';
+ header[264] = (byte)'0';
+
+ WriteChecksum(header);
+
+ stream.Write(header, 0, BlockSize);
+
+ if (entry.Content.Length > 0)
+ {
+ stream.Write(entry.Content, 0, entry.Content.Length);
+ int remainder = entry.Content.Length % BlockSize;
+ if (remainder != 0)
+ {
+ stream.Write(new byte[BlockSize - remainder], 0, BlockSize - remainder);
+ }
+ }
+ }
+
+ private static void WriteOctal(byte[] header, int offset, int length, long value)
+ {
+ // length-1 octal digits, zero-padded, followed by a NUL terminator.
+ string text = Convert.ToString(value, 8).PadLeft(length - 1, '0');
+ byte[] bytes = Encoding.ASCII.GetBytes(text);
+ Array.Copy(bytes, 0, header, offset, length - 1);
+ header[offset + length - 1] = 0;
+ }
+
+ private static void WriteChecksum(byte[] header)
+ {
+ // Checksum is computed with the checksum field treated as 8 spaces.
+ for (int i = 148; i < 156; i++)
+ {
+ header[i] = (byte)' ';
+ }
+
+ long sum = 0;
+ for (int i = 0; i < BlockSize; i++)
+ {
+ sum += header[i];
+ }
+
+ // Six octal digits, NUL, then a space.
+ string text = Convert.ToString(sum, 8).PadLeft(6, '0');
+ byte[] bytes = Encoding.ASCII.GetBytes(text);
+ Array.Copy(bytes, 0, header, 148, 6);
+ header[154] = 0;
+ header[155] = (byte)' ';
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/BuiltInTypePolicyValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Policy/BuiltInTypePolicyValidatorTests.cs
new file mode 100644
index 00000000..df1912a3
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Policy/BuiltInTypePolicyValidatorTests.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Linq;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.UnitTests.Graph;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Policy;
+
+[TestClass]
+public class BuiltInTypePolicyValidatorTests
+{
+ // A reached type file: /0 ResourceType (modern scopes only) whose body reaches the
+ // BuiltInType under test at /1.
+ private static string Package(string builtInKindJson) =>
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," +
+ "{\"$type\":\"BuiltInType\"," + builtInKindJson + "}]";
+
+ private static System.Collections.Generic.IReadOnlyList Run(
+ string builtInKindJson, TypePackageValidationMode mode)
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", Package(builtInKindJson));
+ return PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs, mode);
+ }
+
+ [TestMethod]
+ public void Documented_builtin_kind_reports_bcpvt022_in_canonical()
+ {
+ Run("\"kind\":5", TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation)
+ .Which.JsonPointer.Should().Be("/1/kind");
+ }
+
+ [TestMethod]
+ public void Documented_builtin_kind_reports_bcpvt023_in_compatible()
+ {
+ var diagnostics = Run("\"kind\":5", TypePackageValidationMode.CompatibleReader);
+
+ diagnostics.Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CompatibilityFormUsed)
+ .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning);
+ }
+
+ [TestMethod]
+ public void Reserved_builtin_kind_8_reports_bcpvt022_in_canonical()
+ {
+ // Kind 8 (ResourceRef) has no canonical replacement but is still rejected in canonical.
+ Run("\"kind\":8", TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.CanonicalFormViolation);
+ }
+
+ [TestMethod]
+ [DataRow(0)]
+ [DataRow(9)]
+ [DataRow(-1)]
+ public void Out_of_range_builtin_kind_reports_bcpvt025_in_canonical(int kind)
+ {
+ Run("\"kind\":" + kind, TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.BuiltInTypeKindInvalid);
+ }
+
+ [TestMethod]
+ public void Out_of_range_builtin_kind_reports_bcpvt025_in_compatible()
+ {
+ Run("\"kind\":42", TypePackageValidationMode.CompatibleReader)
+ .Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.BuiltInTypeKindInvalid);
+ }
+
+ [TestMethod]
+ public void Missing_builtin_kind_is_owned_by_structural_layer()
+ {
+ // No 'kind' property: the structural layer owns the required-property diagnostic; policy is silent.
+ Run("\"name\":\"unused\"", TypePackageValidationMode.CanonicalWriter)
+ .Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void Non_integer_builtin_kind_is_owned_by_structural_layer()
+ {
+ Run("\"kind\":\"five\"", TypePackageValidationMode.CanonicalWriter)
+ .Should().BeEmpty();
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/PolicyTestHelpers.cs b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyTestHelpers.cs
new file mode 100644
index 00000000..51b0086c
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyTestHelpers.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.Graph;
+using Azure.Bicep.Types.Validation.Policy;
+using Azure.Bicep.Types.Validation.UnitTests.Graph;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Policy;
+
+///
+/// Shared helpers for policy-layer tests. Mirrors the driver: the graph traversal populates the
+/// provider cache with reached type files, then the mode-policy layer classifies them.
+///
+internal static class PolicyTestHelpers
+{
+ public static IReadOnlyList RunPolicy(
+ string indexJson, InMemoryPackageFileSystem fs, TypePackageValidationMode mode)
+ {
+ var options = new TypePackageValidationOptions { Mode = mode };
+ var index = GraphTestHelpers.Document("index.json", indexJson);
+ var provider = new PackageDocumentProvider(fs, index, options);
+
+ // Trigger graph traversal so the provider loads and caches the reached type files.
+ SemanticGraphValidator.Validate(provider, index, options);
+
+ return PolicyValidator.Validate(provider.GetReachedUsableTypeFiles(), options);
+ }
+
+ /// An index that routes a single resource type to .
+ public static string ResourceIndex(string refValue) =>
+ "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"" + refValue + "\"}}," +
+ "\"resourceFunctions\":{},\"namespaceFunctions\":[]}";
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/PolicyValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyValidatorTests.cs
new file mode 100644
index 00000000..b18b4b84
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Policy/PolicyValidatorTests.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using System.Linq;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.UnitTests.Graph;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Policy;
+
+[TestClass]
+public class PolicyValidatorTests
+{
+ private static readonly TypePackageValidator Validator = new();
+
+ [TestMethod]
+ public void Policy_scans_unreferenced_nodes_in_reached_type_files()
+ {
+ // The index reaches /0; /1 is a BuiltInType that no reference points to. Policy inspects
+ // every structurally usable element of a reached file, so /1 is still classified.
+ const string types =
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/2\"},\"readableScopes\":8,\"writableScopes\":8}," +
+ "{\"$type\":\"BuiltInType\",\"kind\":5}," +
+ "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs,
+ TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation)
+ .Which.JsonPointer.Should().Be("/1/kind");
+ }
+
+ [TestMethod]
+ public void Unreached_type_files_are_not_scanned_by_policy()
+ {
+ // types.json is reached and clean; other.json contains a legacy field but is never
+ // referenced, so policy must not scan it.
+ const string types =
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," +
+ "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]";
+ const string other =
+ "[{\"$type\":\"ResourceType\",\"name\":\"Other/y@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/1\"},\"scopeType\":0}," +
+ "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]";
+ var fs = new InMemoryPackageFileSystem()
+ .AddText("types.json", types)
+ .AddText("other.json", other);
+
+ PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs,
+ TypePackageValidationMode.CanonicalWriter)
+ .Should().BeEmpty();
+ }
+
+ // ── Result-shaping lock-in tests (through the full validator) ─────────────
+
+ [TestMethod]
+ public void Compatibility_legacy_field_produces_single_warning_and_valid_result()
+ {
+ using var pkg = LegacyScopePackage();
+ var options = new TypePackageValidationOptions { Mode = TypePackageValidationMode.CompatibleReader };
+
+ var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options);
+
+ result.IsValid.Should().BeTrue();
+ result.Diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.CompatibilityFormUsed);
+ result.Summary.WarningCount.Should().Be(1);
+ result.Summary.ErrorCount.Should().Be(0);
+ }
+
+ [TestMethod]
+ public void Compatibility_warning_excluded_when_include_warnings_false_but_result_stays_valid()
+ {
+ using var pkg = LegacyScopePackage();
+ var options = new TypePackageValidationOptions
+ {
+ Mode = TypePackageValidationMode.CompatibleReader,
+ IncludeWarnings = false,
+ };
+
+ var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options);
+
+ result.IsValid.Should().BeTrue();
+ result.Diagnostics.Should().BeEmpty();
+ // Summary still counts the suppressed warning.
+ result.Summary.WarningCount.Should().Be(1);
+ }
+
+ [TestMethod]
+ public void Canonical_legacy_field_produces_policy_error()
+ {
+ using var pkg = LegacyScopePackage();
+ var options = new TypePackageValidationOptions { Mode = TypePackageValidationMode.CanonicalWriter };
+
+ var result = Validator.Validate(TypePackageValidationInput.ForDirectory(pkg.Path), options);
+
+ result.IsValid.Should().BeFalse();
+ result.Diagnostics.Should().Contain(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation);
+ }
+
+ ///
+ /// A package whose single resource type uses only the legacy scopeType scope field.
+ /// In CompatibleReader the modern pair is not required, yielding a clean single-warning result.
+ ///
+ private static TempDir LegacyScopePackage()
+ {
+ var dir = new TempDir();
+ File.WriteAllText(Path.Combine(dir.Path, "index.json"),
+ "{\"resources\":{\"My.Rp/x@2026-01-01\":{\"$ref\":\"types.json#/0\"}}," +
+ "\"resourceFunctions\":{},\"namespaceFunctions\":[]}");
+ File.WriteAllText(Path.Combine(dir.Path, "types.json"),
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/1\"},\"scopeType\":0}," +
+ "{\"$type\":\"ObjectType\",\"name\":\"o\",\"properties\":{}}]");
+ return dir;
+ }
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), "bcpvt-policy-test-" + System.IO.Path.GetRandomFileName());
+
+ public TempDir() => Directory.CreateDirectory(Path);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Policy/ResourceScopePolicyValidatorTests.cs b/src/Bicep.Types.Validation.UnitTests/Policy/ResourceScopePolicyValidatorTests.cs
new file mode 100644
index 00000000..c09d4bfe
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Policy/ResourceScopePolicyValidatorTests.cs
@@ -0,0 +1,126 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Linq;
+using Azure.Bicep.Types.Validation.Diagnostics;
+using Azure.Bicep.Types.Validation.UnitTests.Graph;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Policy;
+
+[TestClass]
+public class ResourceScopePolicyValidatorTests
+{
+ // A reached type file whose single ResourceType at /0 carries the given scope fields and a
+ // body that resolves to the StringType at /1.
+ private static string Package(string scopeFieldsJson) =>
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/1\"}," + scopeFieldsJson + "}," +
+ "{\"$type\":\"StringType\"}]";
+
+ private static System.Collections.Generic.IReadOnlyList Run(
+ string scopeFieldsJson, TypePackageValidationMode mode)
+ {
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", Package(scopeFieldsJson));
+ return PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs, mode);
+ }
+
+ [TestMethod]
+ public void Legacy_scope_type_reports_bcpvt022_in_canonical()
+ {
+ Run("\"scopeType\":0", TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation)
+ .Which.JsonPointer.Should().Be("/0/scopeType");
+ }
+
+ [TestMethod]
+ public void Legacy_scope_type_reports_bcpvt023_in_compatible()
+ {
+ Run("\"scopeType\":0", TypePackageValidationMode.CompatibleReader)
+ .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CompatibilityFormUsed)
+ .Which.Severity.Should().Be(TypeValidationDiagnosticSeverity.Warning);
+ }
+
+ [TestMethod]
+ public void Legacy_read_only_scopes_reports_bcpvt022_in_canonical()
+ {
+ Run("\"readOnlyScopes\":4", TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation)
+ .Which.JsonPointer.Should().Be("/0/readOnlyScopes");
+ }
+
+ [TestMethod]
+ public void Legacy_nonzero_flags_reports_bcpvt023_in_compatible()
+ {
+ Run("\"flags\":1", TypePackageValidationMode.CompatibleReader)
+ .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CompatibilityFormUsed)
+ .Which.JsonPointer.Should().Be("/0/flags");
+ }
+
+ [TestMethod]
+ public void Flags_zero_with_modern_pair_reports_per_field_policy_not_mixing()
+ {
+ // flags:0 is not an effective legacy value, so pairing it with the modern scopes is not a
+ // mix: the field is still classified per-field (BCPVT022), not BCPVT024.
+ Run("\"readableScopes\":8,\"writableScopes\":8,\"flags\":0", TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle(d => d.Code == TypeValidationDiagnosticCodes.CanonicalFormViolation)
+ .Which.JsonPointer.Should().Be("/0/flags");
+ }
+
+ [TestMethod]
+ public void Mixed_modern_and_legacy_scope_type_reports_single_bcpvt024_in_canonical()
+ {
+ Run("\"readableScopes\":8,\"writableScopes\":8,\"scopeType\":0", TypePackageValidationMode.CanonicalWriter)
+ .Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ResourceScopeFormMixed);
+ }
+
+ [TestMethod]
+ public void Mixed_modern_and_nonzero_flags_reports_single_bcpvt024_in_compatible()
+ {
+ // BCPVT024 is emitted in both modes; mixing is never merely a compatibility warning.
+ Run("\"readableScopes\":8,\"writableScopes\":8,\"flags\":2", TypePackageValidationMode.CompatibleReader)
+ .Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ResourceScopeFormMixed);
+ }
+
+ [TestMethod]
+ public void Mixed_scope_form_reports_single_bcpvt024_and_suppresses_per_field_policy_diagnostics()
+ {
+ var diagnostics = Run(
+ "\"readableScopes\":8,\"writableScopes\":8,\"scopeType\":0,\"readOnlyScopes\":4",
+ TypePackageValidationMode.CanonicalWriter);
+
+ diagnostics.Should().ContainSingle()
+ .Which.Code.Should().Be(TypeValidationDiagnosticCodes.ResourceScopeFormMixed);
+ // The single diagnostic names the first effective legacy field.
+ diagnostics.Single().Message.Should().Contain("scopeType");
+ }
+
+ [TestMethod]
+ public void Resource_scope_policy_reads_only_resource_type_root_flags()
+ {
+ // The ResourceType has no root-level legacy scope fields. A property-level 'flags' on a
+ // reached ObjectType must not be read by the ResourceType scope policy.
+ const string types =
+ "[{\"$type\":\"ResourceType\",\"name\":\"My.Rp/x@2026-01-01\"," +
+ "\"body\":{\"$ref\":\"#/1\"},\"readableScopes\":8,\"writableScopes\":8}," +
+ "{\"$type\":\"ObjectType\",\"name\":\"o\"," +
+ "\"properties\":{\"p\":{\"type\":{\"$ref\":\"#/2\"},\"flags\":1}}}," +
+ "{\"$type\":\"StringType\"}]";
+ var fs = new InMemoryPackageFileSystem().AddText("types.json", types);
+
+ PolicyTestHelpers.RunPolicy(PolicyTestHelpers.ResourceIndex("types.json#/0"), fs,
+ TypePackageValidationMode.CanonicalWriter)
+ .Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public void Wrong_shape_legacy_field_is_owned_by_structural_layer()
+ {
+ // A non-integer legacy field is a structural primitive-shape error; policy stays silent.
+ Run("\"scopeType\":\"all\"", TypePackageValidationMode.CanonicalWriter)
+ .Should().BeEmpty();
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineTests.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineTests.cs
new file mode 100644
index 00000000..a3f0f673
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineTests.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Samples;
+
+///
+/// Opt-in baseline maintenance. These tests never write source files during a normal run; the
+/// update test only writes when SetBaseLine=true is passed as a VSTest run parameter or the
+/// environment variable is set.
+///
+///
+/// To update baselines locally:
+///
+/// dotnet test src/Bicep.Types.Validation.UnitTests/Bicep.Types.Validation.UnitTests.csproj `
+/// --filter "TestCategory=Baseline" `
+/// -- 'TestRunParameters.Parameter(name="SetBaseLine", value="true")'
+///
+/// or set BICEP_TYPES_VALIDATION_SET_BASELINE=true. The samples source root is discovered by
+/// walking up to the test project, or can be overridden with
+/// BICEP_TYPES_VALIDATION_SAMPLES_ROOT. A stale/relocated build must be rebuilt after an
+/// update so the embedded baselines are refreshed before the comparison tests run.
+///
+[TestClass]
+public class ValidationSampleBaselineTests
+{
+ public TestContext TestContext { get; set; } = null!;
+
+ [TestMethod]
+ [TestCategory("Baseline")]
+ public void Update_baselines_when_requested()
+ {
+ var runParameter = TestContext.Properties.Contains("SetBaseLine")
+ ? TestContext.Properties["SetBaseLine"] as string
+ : null;
+
+ if (!ValidationSampleBaselineUpdater.IsUpdateRequested(runParameter))
+ {
+ Assert.Inconclusive(
+ "Baseline update is opt-in. Pass -- 'TestRunParameters.Parameter(name=\"SetBaseLine\", value=\"true\")' " +
+ $"or set {ValidationSampleBaselineUpdater.SetBaselineEnvVar}=true to update baselines.");
+ return;
+ }
+
+ var samplesRoot = ValidationSampleBaselineUpdater.ResolveSamplesRoot();
+ var summary = ValidationSampleBaselineUpdater.UpdateCorpus(samplesRoot);
+
+ foreach (var written in summary.Written)
+ {
+ TestContext.WriteLine($"UPDATED {written}");
+ }
+
+ foreach (var mismatch in summary.Mismatches)
+ {
+ TestContext.WriteLine($"MISMATCH {mismatch}");
+ }
+
+ summary.Mismatches.Should().BeEmpty(
+ "multi-input scenarios must produce identical normalized results before a baseline is written.");
+ }
+
+ [TestMethod]
+ public void Coverage_report_is_emitted_to_the_test_output_directory()
+ {
+ var markdown = ValidationSampleCoverageReport.ToMarkdown(ValidationSampleCoverageReport.Build());
+
+ var reportPath = Path.Combine(AppContext.BaseDirectory, "validation-sample-coverage.md");
+ File.WriteAllText(reportPath, markdown);
+
+ TestContext.WriteLine($"Coverage report written to {reportPath}");
+ File.Exists(reportPath).Should().BeTrue();
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineUpdater.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineUpdater.cs
new file mode 100644
index 00000000..a41979f2
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleBaselineUpdater.cs
@@ -0,0 +1,327 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Samples;
+
+///
+/// Explicit, opt-in baseline update workflow for the validation sample corpus. Normal test runs
+/// never write source files. Update mode writes normalized results back to the source
+/// expected/<mode>.result.json files, grouped per (scenario, mode) so a
+/// multi-input scenario cannot silently overwrite one baseline with differing input results.
+///
+public static class ValidationSampleBaselineUpdater
+{
+ /// Environment variable that enables baseline update mode when set to true.
+ public const string SetBaselineEnvVar = "BICEP_TYPES_VALIDATION_SET_BASELINE";
+
+ /// Environment variable that overrides the samples source root used for writes.
+ public const string SamplesRootEnvVar = "BICEP_TYPES_VALIDATION_SAMPLES_ROOT";
+
+ private const string ProjectFileName = "Bicep.Types.Validation.UnitTests.csproj";
+
+ ///
+ /// Whether a baseline update was explicitly requested, via the
+ /// environment variable or the supplied VSTest SetBaseLine run-parameter value.
+ ///
+ public static bool IsUpdateRequested(string? runParameterValue)
+ => IsTrue(Environment.GetEnvironmentVariable(SetBaselineEnvVar)) || IsTrue(runParameterValue);
+
+ private static bool IsTrue(string? value)
+ => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Resolves the samples source root deterministically without depending on absolute paths
+ /// embedded in the build. Order: an explicit root, the
+ /// environment variable, then a walk upward from the test binaries to the project directory.
+ /// Throws a clear, actionable error when the source tree cannot be located.
+ ///
+ public static string ResolveSamplesRoot(string? explicitRoot = null)
+ {
+ var candidate = !string.IsNullOrEmpty(explicitRoot)
+ ? explicitRoot
+ : Environment.GetEnvironmentVariable(SamplesRootEnvVar);
+
+ if (!string.IsNullOrEmpty(candidate))
+ {
+ var full = Path.GetFullPath(candidate!);
+ if (!Directory.Exists(full))
+ {
+ throw new DirectoryNotFoundException(
+ $"Samples root '{full}' (from an explicit value or {SamplesRootEnvVar}) does not exist.");
+ }
+
+ return full;
+ }
+
+ var projectDir = FindProjectDirectory(AppContext.BaseDirectory);
+ if (projectDir is null)
+ {
+ throw new InvalidOperationException(
+ $"Could not locate the samples source tree. Set the {SamplesRootEnvVar} environment variable to " +
+ $"'/src/Bicep.Types.Validation.UnitTests/Files/validation-samples' and retry the baseline update.");
+ }
+
+ return Path.GetFullPath(Path.Combine(projectDir, "Files", "validation-samples"));
+ }
+
+ private static string? FindProjectDirectory(string startDirectory)
+ {
+ var dir = new DirectoryInfo(startDirectory);
+ while (dir is not null)
+ {
+ if (File.Exists(Path.Combine(dir.FullName, ProjectFileName)))
+ {
+ return dir.FullName;
+ }
+
+ dir = dir.Parent;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Computes the source-tree write target for a scenario/mode baseline and verifies it stays
+ /// under the canonical samples root. Rejects any resource prefix that escapes the root.
+ ///
+ public static string ComputeWriteTarget(string samplesRoot, string resourcePrefix, string mode)
+ {
+ var rootFull = Path.GetFullPath(samplesRoot);
+ var relative = ToScenarioRelativePath(resourcePrefix);
+
+ var target = Path.GetFullPath(Path.Combine(
+ rootFull,
+ relative.Replace('/', Path.DirectorySeparatorChar),
+ "expected",
+ $"{mode}.result.json"));
+
+ var rootWithSeparator = rootFull.EndsWith(Path.DirectorySeparatorChar)
+ ? rootFull
+ : rootFull + Path.DirectorySeparatorChar;
+
+ if (!target.StartsWith(rootWithSeparator, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Refusing to write baseline outside the samples root. Target '{target}' is not under '{rootFull}'.");
+ }
+
+ return target;
+ }
+
+ private static string ToScenarioRelativePath(string resourcePrefix)
+ {
+ if (resourcePrefix.StartsWith(ValidationSampleData.SampleRootResourcePrefix, StringComparison.Ordinal))
+ {
+ return resourcePrefix.Substring(ValidationSampleData.SampleRootResourcePrefix.Length);
+ }
+
+ return resourcePrefix;
+ }
+
+ ///
+ /// Reduces per-input normalized results to a single agreed baseline. Returns true and the
+ /// shared content when every input agrees; returns false and the index of the first
+ /// divergent input otherwise. Throws when the input list is empty.
+ ///
+ public static bool TryReconcileInputs(
+ IReadOnlyList normalizedResults, out string agreed, out int firstDivergentIndex)
+ {
+ if (normalizedResults.Count == 0)
+ {
+ throw new ArgumentException("At least one input result is required.", nameof(normalizedResults));
+ }
+
+ agreed = normalizedResults[0];
+ for (var i = 1; i < normalizedResults.Count; i++)
+ {
+ if (!string.Equals(normalizedResults[i], agreed, StringComparison.Ordinal))
+ {
+ firstDivergentIndex = i;
+ return false;
+ }
+ }
+
+ firstDivergentIndex = -1;
+ return true;
+ }
+
+ ///
+ /// Writes to only when it differs from
+ /// the existing file (comparing canonicalized JSON), creating the directory if needed. Returns
+ /// true when a write occurred.
+ ///
+ public static bool WriteBaselineIfChanged(string fullPath, string content)
+ {
+ if (File.Exists(fullPath))
+ {
+ var existing = ValidationSampleResultNormalizer.Canonicalize(File.ReadAllText(fullPath));
+ if (string.Equals(existing, content, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+ File.WriteAllText(fullPath, content);
+ return true;
+ }
+
+ ///
+ /// Runs the full corpus update against . Each declared
+ /// (scenario, mode) runs every input, requires identical normalized output, and writes
+ /// the baseline exactly once. Returns a deterministic, human-readable summary of changes and
+ /// mismatches.
+ ///
+ public static BaselineUpdateSummary UpdateCorpus(string samplesRoot)
+ {
+ var written = new List();
+ var unchanged = new List();
+ var mismatches = new List();
+
+ foreach (var scenario in ValidationSampleData.EnumerateScenarios())
+ {
+ foreach (var mode in scenario.Modes)
+ {
+ var parsedMode = ValidationSampleData.ParseMode(mode);
+ var outcome = UpdateGroup(
+ samplesRoot,
+ scenario.ResourcePrefix,
+ scenario.Name,
+ mode,
+ scenario.Inputs,
+ input => ValidationSampleData.RunScenarioNormalized(
+ scenario.ResourcePrefix, input.Kind, input.Path, parsedMode, scenario.ValidateUnreachableFiles),
+ WriteBaselineIfChanged);
+
+ switch (outcome.Kind)
+ {
+ case GroupUpdateKind.Written:
+ written.Add(outcome.RelativeTarget!);
+ break;
+ case GroupUpdateKind.Unchanged:
+ unchanged.Add(outcome.RelativeTarget!);
+ break;
+ case GroupUpdateKind.Mismatch:
+ mismatches.Add(outcome.MismatchMessage!);
+ break;
+ }
+ }
+ }
+
+ written.Sort(StringComparer.Ordinal);
+ unchanged.Sort(StringComparer.Ordinal);
+ mismatches.Sort(StringComparer.Ordinal);
+
+ return new BaselineUpdateSummary(written, unchanged, mismatches);
+ }
+
+ ///
+ /// Updates a single (scenario, mode) group. Runs for every
+ /// input, requires all normalized results to be identical, and writes the baseline exactly once
+ /// through . Execution and writing are injected so the write-once,
+ /// no-write-on-mismatch, and mismatch-propagation guarantees can be tested without the real corpus.
+ ///
+ /// Produces the normalized result for one input.
+ /// Persists (fullPath, content); returns whether a write occurred.
+ public static GroupUpdateResult UpdateGroup(
+ string samplesRoot,
+ string resourcePrefix,
+ string scenarioName,
+ string mode,
+ IReadOnlyList inputs,
+ Func runInput,
+ Func writeIfChanged)
+ {
+ if (inputs.Count == 0)
+ {
+ throw new ArgumentException("At least one input is required.", nameof(inputs));
+ }
+
+ var relativeTarget = $"{ToScenarioRelativePath(resourcePrefix)}/expected/{mode}.result.json";
+ var results = inputs.Select(runInput).ToList();
+
+ if (!TryReconcileInputs(results, out var agreed, out var divergentIndex))
+ {
+ var first = inputs[0];
+ var divergent = inputs[divergentIndex];
+ return GroupUpdateResult.Mismatch(
+ $"{scenarioName} [{mode}]: input '{divergent.Kind}:{divergent.Path}' produced a different " +
+ $"normalized result than first input '{first.Kind}:{first.Path}'; baseline not written.");
+ }
+
+ var target = ComputeWriteTarget(samplesRoot, resourcePrefix, mode);
+ return writeIfChanged(target, agreed)
+ ? GroupUpdateResult.Written(relativeTarget)
+ : GroupUpdateResult.Unchanged(relativeTarget);
+ }
+
+ /// Kind of outcome produced by .
+ public enum GroupUpdateKind
+ {
+ /// The baseline content changed and was rewritten.
+ Written,
+
+ /// The baseline was already up to date.
+ Unchanged,
+
+ /// Inputs disagreed; nothing was written.
+ Mismatch,
+ }
+
+ /// Outcome of updating a single (scenario, mode) group.
+ public sealed class GroupUpdateResult
+ {
+ private GroupUpdateResult(GroupUpdateKind kind, string? relativeTarget, string? mismatchMessage)
+ {
+ Kind = kind;
+ RelativeTarget = relativeTarget;
+ MismatchMessage = mismatchMessage;
+ }
+
+ /// The outcome kind.
+ public GroupUpdateKind Kind { get; }
+
+ /// Package-relative baseline path, for /.
+ public string? RelativeTarget { get; }
+
+ /// Mismatch description, for .
+ public string? MismatchMessage { get; }
+
+ internal static GroupUpdateResult Written(string relativeTarget)
+ => new(GroupUpdateKind.Written, relativeTarget, null);
+
+ internal static GroupUpdateResult Unchanged(string relativeTarget)
+ => new(GroupUpdateKind.Unchanged, relativeTarget, null);
+
+ internal static GroupUpdateResult Mismatch(string message)
+ => new(GroupUpdateKind.Mismatch, null, message);
+ }
+
+ /// Deterministic outcome of a corpus baseline update.
+ public sealed class BaselineUpdateSummary
+ {
+ public BaselineUpdateSummary(
+ IReadOnlyList written,
+ IReadOnlyList unchanged,
+ IReadOnlyList mismatches)
+ {
+ Written = written;
+ Unchanged = unchanged;
+ Mismatches = mismatches;
+ }
+
+ /// Baselines that were rewritten because their content changed.
+ public IReadOnlyList Written { get; }
+
+ /// Baselines that were already up to date.
+ public IReadOnlyList Unchanged { get; }
+
+ /// Scenario/mode combinations skipped because their inputs disagreed.
+ public IReadOnlyList Mismatches { get; }
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleCoverageReport.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleCoverageReport.cs
new file mode 100644
index 00000000..258175b4
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleCoverageReport.cs
@@ -0,0 +1,234 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Samples;
+
+///
+/// Builds a deterministic coverage report over the validation sample corpus: which diagnostic
+/// codes each baseline produces (keyed by scenario, mode, and severity), and counts by category,
+/// mode, and input kind, plus simple corpus gaps. The report is derived only from observable
+/// scenario metadata and checked-in expected result baselines; it does not run the validator.
+///
+public static class ValidationSampleCoverageReport
+{
+ /// Categories the corpus is expected to cover (see phase-8 plan §6.1).
+ public static readonly IReadOnlyList KnownCategories = new[]
+ {
+ "valid.canonical",
+ "valid.input-forms",
+ "structural",
+ "invalid.graph",
+ "invalid.semantic",
+ "invalid.policy",
+ "invalid.archive",
+ "invalid.hygiene",
+ "compatibility",
+ "diagnostic-quality",
+ };
+
+ /// One diagnostic-code occurrence in a baseline, with its mode and severity context.
+ public readonly struct DiagnosticCoverageRow
+ {
+ public DiagnosticCoverageRow(string code, string scenario, string mode, string severity)
+ {
+ Code = code;
+ Scenario = scenario;
+ Mode = mode;
+ Severity = severity;
+ }
+
+ public string Code { get; }
+ public string Scenario { get; }
+ public string Mode { get; }
+ public string Severity { get; }
+ }
+
+ /// The full coverage model, with deterministically ordered rows and counts.
+ public sealed class CoverageModel
+ {
+ public CoverageModel(
+ IReadOnlyList diagnosticRows,
+ IReadOnlyList> categoryCounts,
+ IReadOnlyList> modeCounts,
+ IReadOnlyList> inputKindCounts,
+ IReadOnlyList gaps)
+ {
+ DiagnosticRows = diagnosticRows;
+ CategoryCounts = categoryCounts;
+ ModeCounts = modeCounts;
+ InputKindCounts = inputKindCounts;
+ Gaps = gaps;
+ }
+
+ public IReadOnlyList DiagnosticRows { get; }
+ public IReadOnlyList> CategoryCounts { get; }
+ public IReadOnlyList> ModeCounts { get; }
+ public IReadOnlyList> InputKindCounts { get; }
+ public IReadOnlyList Gaps { get; }
+ }
+
+ /// Builds the coverage model from discovered scenarios and their baselines.
+ public static CoverageModel Build()
+ {
+ var scenarios = ValidationSampleData.EnumerateScenarios().ToList();
+
+ var diagnosticRows = new List();
+ var categoryCounts = new SortedDictionary(StringComparer.Ordinal);
+ var modeCounts = new SortedDictionary(StringComparer.Ordinal);
+ var inputKindCounts = new SortedDictionary(StringComparer.Ordinal);
+ var categoriesSeen = new HashSet(StringComparer.Ordinal);
+ var gaps = new List();
+
+ foreach (var scenario in scenarios)
+ {
+ var category = scenario.Category ?? "(none)";
+ categoriesSeen.Add(category);
+ Increment(categoryCounts, category);
+
+ foreach (var input in scenario.Inputs)
+ {
+ Increment(inputKindCounts, input.Kind.ToString());
+ }
+
+ foreach (var mode in scenario.Modes)
+ {
+ Increment(modeCounts, mode);
+
+ var expectedResource = ValidationSampleData.GetExpectedResultResourceName(scenario.ResourcePrefix, mode);
+ if (!ValidationSampleData.ResourceExists(expectedResource))
+ {
+ continue;
+ }
+
+ diagnosticRows.AddRange(ReadBaselineDiagnostics(expectedResource, scenario.Name, mode));
+ }
+ }
+
+ // Corpus gap: a known category with no scenarios at all. This is a coverage-presence check,
+ // not a validity assertion: it does not infer expected diagnostics from a scenario's
+ // category. Per-scenario validity is enforced by the baseline-internal invariants in the
+ // sample health tests, not here.
+ foreach (var known in KnownCategories)
+ {
+ if (!categoriesSeen.Contains(known))
+ {
+ gaps.Add($"category '{known}' has no scenarios.");
+ }
+ }
+
+ diagnosticRows.Sort(CompareDiagnosticRows);
+ gaps.Sort(StringComparer.Ordinal);
+
+ return new CoverageModel(
+ diagnosticRows,
+ categoryCounts.ToList(),
+ modeCounts.ToList(),
+ inputKindCounts.ToList(),
+ gaps);
+ }
+
+ /// Renders the coverage model as deterministic Markdown.
+ public static string ToMarkdown(CoverageModel model)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("# Validation Sample Coverage");
+ sb.AppendLine();
+
+ sb.AppendLine("## By Diagnostic Code");
+ sb.AppendLine();
+ sb.AppendLine("| Code | Scenario | Mode | Severity |");
+ sb.AppendLine("| --- | --- | --- | --- |");
+ foreach (var row in model.DiagnosticRows)
+ {
+ sb.AppendLine(FormattableString.Invariant($"| {row.Code} | {row.Scenario} | {row.Mode} | {row.Severity} |"));
+ }
+ sb.AppendLine();
+
+ AppendCountSection(sb, "By Category", "Category", model.CategoryCounts);
+ AppendCountSection(sb, "By Mode", "Mode", model.ModeCounts);
+ AppendCountSection(sb, "By Input Kind", "Input Kind", model.InputKindCounts);
+
+ sb.AppendLine("## Corpus Gaps");
+ sb.AppendLine();
+ if (model.Gaps.Count == 0)
+ {
+ sb.AppendLine("None.");
+ }
+ else
+ {
+ sb.AppendLine("| Gap |");
+ sb.AppendLine("| --- |");
+ foreach (var gap in model.Gaps)
+ {
+ sb.AppendLine(FormattableString.Invariant($"| {gap} |"));
+ }
+ }
+ sb.AppendLine();
+
+ return sb.ToString();
+ }
+
+ private static void AppendCountSection(
+ StringBuilder sb, string title, string header, IReadOnlyList> counts)
+ {
+ sb.AppendLine(FormattableString.Invariant($"## {title}"));
+ sb.AppendLine();
+ sb.AppendLine(FormattableString.Invariant($"| {header} | Count |"));
+ sb.AppendLine("| --- | --- |");
+ foreach (var pair in counts)
+ {
+ sb.AppendLine(FormattableString.Invariant($"| {pair.Key} | {pair.Value} |"));
+ }
+ sb.AppendLine();
+ }
+
+ private static IReadOnlyList ReadBaselineDiagnostics(
+ string expectedResource, string scenarioName, string mode)
+ {
+ using var document = JsonDocument.Parse(ValidationSampleData.ReadResource(expectedResource));
+ var rows = new List();
+
+ if (document.RootElement.TryGetProperty("diagnostics", out var diagnostics)
+ && diagnostics.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var diagnostic in diagnostics.EnumerateArray())
+ {
+ var code = diagnostic.TryGetProperty("code", out var codeElement)
+ ? codeElement.GetString() ?? "(none)"
+ : "(none)";
+ var severity = diagnostic.TryGetProperty("severity", out var severityElement)
+ ? severityElement.GetString() ?? "(none)"
+ : "(none)";
+
+ rows.Add(new DiagnosticCoverageRow(code, scenarioName, mode, severity));
+ }
+ }
+
+ return rows;
+ }
+
+ private static int CompareDiagnosticRows(DiagnosticCoverageRow x, DiagnosticCoverageRow y)
+ {
+ var byCode = string.CompareOrdinal(x.Code, y.Code);
+ if (byCode != 0) { return byCode; }
+
+ var byScenario = string.CompareOrdinal(x.Scenario, y.Scenario);
+ if (byScenario != 0) { return byScenario; }
+
+ var byMode = string.CompareOrdinal(x.Mode, y.Mode);
+ if (byMode != 0) { return byMode; }
+
+ return string.CompareOrdinal(x.Severity, y.Severity);
+ }
+
+ private static void Increment(IDictionary counts, string key)
+ {
+ counts[key] = counts.TryGetValue(key, out var current) ? current + 1 : 1;
+ }
+}
diff --git a/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleData.cs b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleData.cs
new file mode 100644
index 00000000..36e7b658
--- /dev/null
+++ b/src/Bicep.Types.Validation.UnitTests/Samples/ValidationSampleData.cs
@@ -0,0 +1,333 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using Azure.Bicep.Types.Validation;
+
+namespace Azure.Bicep.Types.Validation.UnitTests.Samples;
+
+///
+/// Discovers validation sample scenarios from embedded resources and provides IO helpers
+/// for the sample baseline harness.
+///
+public static class ValidationSampleData
+{
+ private const string SampleRoot = "Files/validation-samples/";
+ private const string ScenarioFileSuffix = "/scenario.json";
+ private const string ExpectedResultSuffix = ".result.json";
+
+ /// Embedded-resource prefix (with trailing slash) under which all samples live.
+ public const string SampleRootResourcePrefix = SampleRoot;
+
+ private static Assembly SampleAssembly => typeof(ValidationSampleData).Assembly;
+
+ /// Enumerates every discovered scenario, ordered deterministically.
+ public static IEnumerable EnumerateScenarios()
+ {
+ var scenarioResources = SampleAssembly.GetManifestResourceNames()
+ .Where(n => n.StartsWith(SampleRoot, StringComparison.Ordinal)
+ && n.EndsWith(ScenarioFileSuffix, StringComparison.Ordinal))
+ .OrderBy(n => n, StringComparer.Ordinal);
+
+ foreach (var resourceName in scenarioResources)
+ {
+ var prefix = resourceName.Substring(0, resourceName.Length - ScenarioFileSuffix.Length);
+ var folderName = prefix.Substring(prefix.LastIndexOf('/') + 1);
+
+ yield return ParseScenario(prefix, folderName, ReadResource(resourceName));
+ }
+ }
+
+ ///
+ /// Parses a single scenario.json document into a .
+ /// When no explicit inputs are declared, the scenario defaults to a single
+ /// directory input pointing at package/.
+ ///
+ public static ValidationSampleScenario ParseScenario(string resourcePrefix, string folderName, string scenarioJson)
+ {
+ using var document = JsonDocument.Parse(scenarioJson);
+ var root = document.RootElement;
+
+ var name = root.TryGetProperty("name", out var nameElement)
+ ? nameElement.GetString() ?? folderName
+ : folderName;
+ var description = root.TryGetProperty("description", out var descriptionElement)
+ ? descriptionElement.GetString()
+ : null;
+ var category = root.TryGetProperty("category", out var categoryElement)
+ ? categoryElement.GetString()
+ : null;
+
+ var inputs = ParseInputs(folderName, root);
+
+ var modes = new List();
+ if (root.TryGetProperty("modes", out var modesElement)
+ && modesElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var modeElement in modesElement.EnumerateArray())
+ {
+ var mode = modeElement.GetString();
+ if (!string.IsNullOrEmpty(mode))
+ {
+ modes.Add(mode!);
+ }
+ }
+ }
+
+ return new ValidationSampleScenario(
+ resourcePrefix, folderName, name, description, category, inputs, modes,
+ ParseValidateUnreachableFiles(root));
+ }
+
+ private static bool ParseValidateUnreachableFiles(JsonElement root)
+ {
+ return root.TryGetProperty("options", out var optionsElement)
+ && optionsElement.ValueKind == JsonValueKind.Object
+ && optionsElement.TryGetProperty("validateUnreachableFiles", out var flagElement)
+ && flagElement.ValueKind == JsonValueKind.True;
+ }
+
+ private static IReadOnlyList ParseInputs(string folderName, JsonElement root)
+ {
+ var inputs = new List();
+
+ if (root.TryGetProperty("inputs", out var inputsElement)
+ && inputsElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var inputElement in inputsElement.EnumerateArray())
+ {
+ var kind = inputElement.TryGetProperty("kind", out var kindElement)
+ ? kindElement.GetString()
+ : null;
+ var path = inputElement.TryGetProperty("path", out var pathElement)
+ ? pathElement.GetString()
+ : null;
+
+ if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(path))
+ {
+ throw new InvalidOperationException(
+ $"Scenario '{folderName}' declares an input missing 'kind' or 'path'.");
+ }
+
+ inputs.Add(new ValidationSampleInput(ParseInputKind(folderName, kind!), path!));
+ }
+ }
+
+ if (inputs.Count == 0)
+ {
+ inputs.Add(ValidationSampleInput.DefaultDirectory);
+ }
+
+ return inputs;
+ }
+
+ private static ValidationSampleInputKind ParseInputKind(string folderName, string kind) => kind switch
+ {
+ "directory" => ValidationSampleInputKind.Directory,
+ "indexFile" => ValidationSampleInputKind.IndexFile,
+ "archiveFile" => ValidationSampleInputKind.ArchiveFile,
+ _ => throw new InvalidOperationException(
+ $"Scenario '{folderName}' declares an unsupported input kind '{kind}'."),
+ };
+
+ ///
+ /// Builds a for a sample input, resolving its
+ /// declared path relative to the materialized scenario .
+ ///
+ public static TypePackageValidationInput CreateValidationInput(
+ ValidationSampleInputKind kind,
+ string inputPath,
+ string materializedRoot)
+ {
+ var resolvedPath = Path.Combine(
+ materializedRoot,
+ inputPath.Replace('/', Path.DirectorySeparatorChar));
+
+ return kind switch
+ {
+ ValidationSampleInputKind.Directory => TypePackageValidationInput.ForDirectory(resolvedPath),
+ ValidationSampleInputKind.IndexFile => TypePackageValidationInput.ForIndexFile(resolvedPath),
+ ValidationSampleInputKind.ArchiveFile => TypePackageValidationInput.ForArchiveFile(resolvedPath),
+ _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported sample input kind."),
+ };
+ }
+
+ /// Parses a sample mode string into a .
+ public static TypePackageValidationMode ParseMode(string mode) => mode switch
+ {
+ "canonicalWriter" => TypePackageValidationMode.CanonicalWriter,
+ "compatibleReader" => TypePackageValidationMode.CompatibleReader,
+ _ => throw new InvalidOperationException($"Unknown sample mode '{mode}'."),
+ };
+
+ ///
+ /// Runs a single sample case through the full pipeline (materialize package, build the input,
+ /// validate, normalize) and returns the normalized baseline JSON. This is the shared path used
+ /// by both baseline comparison and baseline update so they can never diverge.
+ ///
+ public static string RunScenarioNormalized(
+ string resourcePrefix,
+ ValidationSampleInputKind inputKind,
+ string inputPath,
+ TypePackageValidationMode mode,
+ bool validateUnreachableFiles)
+ {
+ var temporaryRoot = Path.Combine(
+ Path.GetTempPath(),
+ "bicep-types-validation-samples",
+ Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var packageRoot = MaterializePackage(
+ resourcePrefix, Path.Combine(temporaryRoot, "package"));
+
+ if (inputKind == ValidationSampleInputKind.ArchiveFile)
+ {
+ var archivePath = Path.Combine(
+ temporaryRoot, inputPath.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(archivePath)!);
+ MaterializeArchive(packageRoot, archivePath);
+ }
+
+ var input = CreateValidationInput(inputKind, inputPath, temporaryRoot);
+ var options = new TypePackageValidationOptions
+ {
+ Mode = mode,
+ ValidateUnreachableFiles = validateUnreachableFiles,
+ };
+
+ var result = new TypePackageValidator().Validate(input, options);
+ return ValidationSampleResultNormalizer.Normalize(result, temporaryRoot);
+ }
+ finally
+ {
+ if (Directory.Exists(temporaryRoot))
+ {
+ Directory.Delete(temporaryRoot, recursive: true);
+ }
+ }
+ }
+
+ /// DynamicData source: one case per (scenario, input, mode).
+ public static IEnumerable