diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs new file mode 100644 index 00000000..169a03bb --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs @@ -0,0 +1,397 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器对 not 约束的运行时行为。 +/// +[TestFixture] +public sealed class YamlConfigLoaderNegationTests +{ + private string _rootPath = null!; + + /// + /// 为每个测试创建隔离的临时目录,避免不同 not 用例互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理当前测试创建的临时目录,避免本地文件残留影响后续执行。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + /// + /// 验证运行时会拒绝命中 not 子 schema 的标量值。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Value_Matches_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Deprecated + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "not": { + "type": "string", + "const": "Deprecated" + } + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = CreateMonsterLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("must not match the 'not' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证值未命中 not 子 schema 时,加载器不会误报禁用约束。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_Value_Does_Not_Match_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "not": { + "type": "string", + "const": "Deprecated" + } + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = CreateMonsterLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(1).Hp, Is.EqualTo(10)); + }); + } + + /// + /// 验证对象完整命中禁用 schema 时,同样会触发 not 约束失败。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Fully_Matches_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + reward: + gold: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "not": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { "type": "integer" } + } + }, + "properties": { + "gold": { "type": "integer" }, + "bonus": { "type": "integer" } + } + } + } + } + """); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward")); + Assert.That(exception.Message, Does.Contain("must not match the 'not' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证对象仅命中 not schema 的属性子集时,不会被误判为完整命中。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_Object_Does_Not_Strictly_Match_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + reward: + gold: 10 + bonus: 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "not": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { "type": "integer" } + } + }, + "properties": { + "gold": { "type": "integer" }, + "bonus": { "type": "integer" } + } + } + } + } + """); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Reward.Gold, Is.EqualTo(10)); + Assert.That(table.Get(1).Reward.Bonus, Is.EqualTo(5)); + }); + } + + /// + /// 验证 schema 将 not 声明为非对象值时,会在解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Not_Is_Not_An_Object() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "not": "deprecated" + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = CreateMonsterLoader(); + var registry = CreateRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("must declare 'not' as an object-valued schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 在测试目录下写入配置文件,并自动创建缺失目录。 + /// + /// 相对根目录的配置文件路径。 + /// 要写入的 YAML 或 schema 内容。 + private void CreateConfigFile(string relativePath, string content) + { + var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, content); + } + + /// + /// 写入测试 schema 文件,复用通用配置文件创建逻辑。 + /// + /// schema 相对路径。 + /// schema JSON 内容。 + private void CreateSchemaFile(string relativePath, string content) + { + CreateConfigFile(relativePath, content); + } + + /// + /// 创建用于标量 not 场景的加载器,统一测试夹具中的注册方式。 + /// + /// 已注册怪物表与 schema 路径的加载器。 + private YamlConfigLoader CreateMonsterLoader() + { + return new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + } + + /// + /// 创建用于对象 not 场景的加载器,避免重复维护同一注册参数。 + /// + /// 已注册奖励对象测试表的加载器。 + private YamlConfigLoader CreateMonsterRewardLoader() + { + return new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + } + + /// + /// 创建新的配置注册表,明确每个用例都从干净状态开始。 + /// + /// 空的配置注册表。 + private static ConfigRegistry CreateRegistry() + { + return new ConfigRegistry(); + } + + /// + /// 用于标量 not 回归测试的最小配置类型。 + /// + private sealed class MonsterConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置生命值。 + /// + public int Hp { get; set; } + } + + /// + /// 用于对象 not 回归测试的最小配置类型。 + /// + private sealed class MonsterRewardConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置奖励对象。 + /// + public RewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示对象 not 回归测试中的奖励节点。 + /// + private sealed class RewardConfigStub + { + /// + /// 获取或设置金币数量。 + /// + public int Gold { get; set; } + + /// + /// 获取或设置额外奖励数量。 + /// + public int Bonus { get; set; } + } +} diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index c22d2148..e23217e9 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -838,7 +838,9 @@ public class YamlConfigLoaderTests /// 满足该 format 的 YAML 标量值。 [TestCase("date", "2026-04-11")] [TestCase("date-time", "2026-04-11T08:30:00Z")] + [TestCase("duration", "P2DT3H4M5.5S")] [TestCase("email", "boss@example.com")] + [TestCase("time", "08:30:00Z")] [TestCase("uri", "https://example.com/loot-table")] [TestCase("uuid", "123e4567-e89b-12d3-a456-426614174000")] public async Task LoadAsync_Should_Accept_Supported_String_Format( @@ -891,7 +893,9 @@ public class YamlConfigLoaderTests /// 不满足该 format 的 YAML 标量值。 [TestCase("date", "2026-02-30")] [TestCase("date-time", "2026-04-11T08:30:00")] + [TestCase("duration", "P1Y")] [TestCase("email", "boss.example.com")] + [TestCase("time", "08:30:00")] [TestCase("uri", "/loot-table")] [TestCase("uuid", "123e4567e89b12d3a456426614174000")] public void LoadAsync_Should_Throw_When_String_Does_Not_Match_Supported_Format( @@ -986,6 +990,8 @@ public class YamlConfigLoaderTests Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("ipv4")); Assert.That(exception.Message, Does.Contain("unsupported string format")); Assert.That(exception.Message, Does.Contain("date-time")); + Assert.That(exception.Message, Does.Contain("duration")); + Assert.That(exception.Message, Does.Contain("time")); }); } diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index b525cf79..2f22c714 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -18,7 +18,7 @@ internal static class YamlConfigSchemaValidator // The runtime intentionally uses the same culture-invariant regex semantics as the // JS tooling so grouping and backreferences behave consistently across environments. private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant; - private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', 'uuid'"; + private const string SupportedStringFormatNames = "'date', 'date-time', 'duration', 'email', 'time', 'uri', 'uuid'"; private static readonly Regex ExactDecimalPattern = new( @"^(?[+-]?)(?:(?\d+)(?:\.(?\d*))?|\.(?\d+))(?:[eE](?[+-]?\d+))?$", @@ -36,6 +36,14 @@ internal static class YamlConfigSchemaValidator @"^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$", RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SupportedDurationFormatRegex = new( + @"^P(?:(?\d+)D)?(?:T(?:(?\d+)H)?(?:(?\d+)M)?(?:(?\d+(?:\.\d+)?)S)?)?$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly Regex SupportedTimeFormatRegex = new( + @"^(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SupportedUriSchemeRegex = new( @"^[A-Za-z][A-Za-z0-9+\.-]*:", RegexOptions.CultureInvariant | RegexOptions.Compiled); @@ -316,42 +324,80 @@ internal static class YamlConfigSchemaValidator var typeName = typeElement.GetString() ?? string.Empty; var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); - switch (typeName) + var parsedNode = typeName switch { - case "object": - EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, - YamlConfigSchemaPropertyType.Object, - referenceTableName); - return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot); + "object" => ParseObjectSchemaNode( + tableName, + schemaPath, + propertyPath, + element, + referenceTableName, + isRoot), + "array" => ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName), + "integer" => CreateScalarNode( + tableName, + schemaPath, + propertyPath, + YamlConfigSchemaPropertyType.Integer, + element, + referenceTableName), + "number" => CreateScalarNode( + tableName, + schemaPath, + propertyPath, + YamlConfigSchemaPropertyType.Number, + element, + referenceTableName), + "boolean" => CreateScalarNode( + tableName, + schemaPath, + propertyPath, + YamlConfigSchemaPropertyType.Boolean, + element, + referenceTableName), + "string" => CreateScalarNode( + tableName, + schemaPath, + propertyPath, + YamlConfigSchemaPropertyType.String, + element, + referenceTableName), + _ => throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath), + rawValue: typeName) + }; + return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element)); + } - case "array": - return ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName); - - case "integer": - return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, - element, referenceTableName); - - case "number": - return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, - element, referenceTableName); - - case "boolean": - return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, - element, referenceTableName); - - case "string": - return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, - element, referenceTableName); - - default: - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath), - rawValue: typeName); - } + /// + /// 解析对象类型 schema,并在进入对象节点解析前先校验 ref-table 是否兼容。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象属性路径。 + /// 对象 schema 节点。 + /// 声明在当前节点上的目标引用表。 + /// 是否为根节点。 + /// 对象节点模型。 + private static YamlConfigSchemaNode ParseObjectSchemaNode( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + string? referenceTableName, + bool isRoot) + { + EnsureReferenceKeywordIsSupported( + tableName, + schemaPath, + propertyPath, + YamlConfigSchemaPropertyType.Object, + referenceTableName); + return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot); } /// @@ -514,6 +560,57 @@ internal static class YamlConfigSchemaValidator ParseConstantValue(tableName, schemaPath, propertyPath, element, scalarNode)); } + /// + /// 解析节点上的 not 约束。 + /// 该子 schema 继续复用同一套节点解析逻辑,保证 Runtime / Generator / Tooling + /// 对深层结构与格式白名单的解释保持一致。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 当前节点路径。 + /// Schema JSON 节点。 + /// 解析后的 negated schema;未声明时返回空。 + private static YamlConfigSchemaNode? ParseNegatedSchemaNode( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (!element.TryGetProperty("not", out var notElement)) + { + return null; + } + + if (notElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'not' as an object-valued schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return ParseNode( + tableName, + schemaPath, + BuildNestedSchemaPath(propertyPath, "not"), + notElement); + } + + /// + /// 为 contains / not 这类内联子 schema 构建稳定的诊断路径。 + /// + /// 当前节点路径。 + /// 内联子 schema 后缀。 + /// 带内联后缀的 schema 路径。 + private static string BuildNestedSchemaPath(string propertyPath, string suffix) + { + return string.IsNullOrWhiteSpace(propertyPath) + ? $"[{suffix}]" + : $"{propertyPath}[{suffix}]"; + } + /// /// 递归校验 YAML 节点。 /// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。 @@ -698,6 +795,7 @@ internal static class YamlConfigSchemaValidator } ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode); + ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, mappingNode, schemaNode); } /// @@ -820,6 +918,7 @@ internal static class YamlConfigSchemaValidator ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references); ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode); + ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } /// @@ -913,6 +1012,7 @@ internal static class YamlConfigSchemaValidator } ValidateConstantValue(tableName, yamlPath, displayPath, scalarNode, schemaNode); + ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, scalarNode, schemaNode); if (schemaNode.ReferenceTableName != null && references is not null) @@ -1703,10 +1803,18 @@ internal static class YamlConfigSchemaValidator kind = YamlConfigStringFormatKind.DateTime; return true; + case "duration": + kind = YamlConfigStringFormatKind.Duration; + return true; + case "email": kind = YamlConfigStringFormatKind.Email; return true; + case "time": + kind = YamlConfigStringFormatKind.Time; + return true; + case "uri": kind = YamlConfigStringFormatKind.Uri; return true; @@ -2317,7 +2425,9 @@ internal static class YamlConfigSchemaValidator { YamlConfigStringFormatKind.Date => MatchesSupportedDateFormat(value), YamlConfigStringFormatKind.DateTime => MatchesSupportedDateTimeFormat(value), + YamlConfigStringFormatKind.Duration => MatchesSupportedDurationFormat(value), YamlConfigStringFormatKind.Email => SupportedEmailFormatRegex.IsMatch(value), + YamlConfigStringFormatKind.Time => MatchesSupportedTimeFormat(value), YamlConfigStringFormatKind.Uri => MatchesSupportedUriFormat(value), YamlConfigStringFormatKind.Uuid => Guid.TryParseExact(value, "D", out _), _ => false @@ -2362,6 +2472,78 @@ internal static class YamlConfigSchemaValidator out _); } + /// + /// 判断字符串是否满足共享支持的 duration 格式。 + /// 当前共享子集只接受 day-time duration:可声明 D/H/M/S,秒允许小数, + /// 但拒绝 Y / M(月) / W 等依赖日历语义的部分, + /// 避免不同宿主对“一个月/一年到底多长”出现解释漂移。 + /// + /// 待校验的字符串值。 + /// 当前值是否是合法持续时间文本。 + private static bool MatchesSupportedDurationFormat(string value) + { + var match = SupportedDurationFormatRegex.Match(value); + if (!match.Success) + { + return false; + } + + var hasDayComponent = match.Groups["days"].Success; + var hasHourComponent = match.Groups["hours"].Success; + var hasMinuteComponent = match.Groups["minutes"].Success; + var hasSecondComponent = match.Groups["seconds"].Success; + var hasAnyComponent = hasDayComponent || hasHourComponent || hasMinuteComponent || hasSecondComponent; + if (!hasAnyComponent) + { + return false; + } + + var hasTimeSection = value.Contains('T', StringComparison.Ordinal); + if (hasTimeSection && + !hasHourComponent && + !hasMinuteComponent && + !hasSecondComponent) + { + return false; + } + + return true; + } + + /// + /// 判断字符串是否满足共享支持的 time 格式。 + /// 该格式固定要求显式时区偏移,并只接受 24 小时制的合法时分秒文本, + /// 避免不同宿主对“time-only + offset”解析默认日期或时区时产生漂移。 + /// + /// 待校验的字符串值。 + /// 当前值是否是合法时间文本。 + private static bool MatchesSupportedTimeFormat(string value) + { + var match = SupportedTimeFormatRegex.Match(value); + if (!match.Success) + { + return false; + } + + var hour = int.Parse(match.Groups["hour"].Value, CultureInfo.InvariantCulture); + var minute = int.Parse(match.Groups["minute"].Value, CultureInfo.InvariantCulture); + var second = int.Parse(match.Groups["second"].Value, CultureInfo.InvariantCulture); + if (hour > 23 || minute > 59 || second > 59) + { + return false; + } + + var offset = match.Groups["offset"].Value; + if (offset == "Z") + { + return true; + } + + var offsetHour = int.Parse(offset.AsSpan(1, 2), CultureInfo.InvariantCulture); + var offsetMinute = int.Parse(offset.AsSpan(4, 2), CultureInfo.InvariantCulture); + return offsetHour <= 23 && offsetMinute <= 59; + } + /// /// 判断字符串是否满足共享支持的 uri 格式。 /// 这里要求输入显式包含 scheme,避免把普通路径意外解释成平台相关的绝对 URI。 @@ -2554,13 +2736,14 @@ internal static class YamlConfigSchemaValidator var matchingCount = 0; for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { - if (IsArrayItemMatchingContains( + if (TryMatchSchemaNode( tableName, yamlPath, $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], containsNode, - references)) + references, + allowUnknownObjectProperties: true)) { matchingCount++; } @@ -2570,26 +2753,29 @@ internal static class YamlConfigSchemaValidator } /// - /// 判断单个数组元素是否满足 contains 子 schema。 - /// contains 的语义是“尝试匹配”,因此普通约束失败会返回 ,但内部意外状态仍会继续抛出。 + /// 判断当前 YAML 节点是否满足给定 schema 子树。 + /// contains / not 都通过该路径复用主校验逻辑,因此普通约束失败会返回 , + /// 但内部意外状态仍会继续抛出。 /// /// 所属配置表名称。 /// YAML 文件路径。 - /// 当前数组元素路径。 - /// 实际 YAML 元素。 - /// contains 子 schema。 - /// 当前元素匹配成功后要写回的可选跨表引用收集器。 - /// 当前元素是否匹配 contains 子 schema。 - private static bool IsArrayItemMatchingContains( + /// 当前节点路径。 + /// 实际 YAML 节点。 + /// 要试匹配的 schema 子树。 + /// 当前节点匹配成功后要写回的可选跨表引用收集器。 + /// 对象试匹配时是否允许额外字段。 + /// 当前节点是否匹配指定 schema 子树。 + private static bool TryMatchSchemaNode( string tableName, string yamlPath, string displayPath, - YamlNode itemNode, - YamlConfigSchemaNode containsNode, - ICollection? references) + YamlNode node, + YamlConfigSchemaNode schemaNode, + ICollection? references, + bool allowUnknownObjectProperties) { - // contains 的“试匹配”不能把失败元素的引用泄漏给外层,但匹配成功的元素仍需要参与 - // 跨表引用收集,否则仅声明在 contains 子 schema 里的 ref-table 会被运行时遗漏。 + // 约束试匹配不能把失败路径的引用泄漏给外层,但匹配成功的分支仍需要把引用写回, + // 这样 contains / not 等内联 schema 才能与主校验链复用同一套递归解释规则。 List? matchedReferences = references is null ? null : new(); try @@ -2598,10 +2784,10 @@ internal static class YamlConfigSchemaValidator tableName, yamlPath, displayPath, - itemNode, - containsNode, + node, + schemaNode, matchedReferences, - allowUnknownObjectProperties: true); + allowUnknownObjectProperties); if (references is not null && matchedReferences is not null) @@ -2621,6 +2807,49 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验节点是否命中了 not 声明的禁用 schema。 + /// 与 contains 不同,not 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 当前字段路径。 + /// 当前 YAML 节点。 + /// 当前 schema 节点。 + private static void ValidateNegatedSchemaConstraint( + string tableName, + string yamlPath, + string displayPath, + YamlNode node, + YamlConfigSchemaNode schemaNode) + { + if (schemaNode.NegatedSchemaNode is null || + !TryMatchSchemaNode( + tableName, + yamlPath, + displayPath, + node, + schemaNode.NegatedSchemaNode, + references: null, + allowUnknownObjectProperties: false)) + { + return; + } + + var subject = string.IsNullOrWhiteSpace(displayPath) + ? "Root object" + : $"Property '{displayPath}'"; + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"{subject} in config file '{yamlPath}' must not match the 'not' schema.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode.NegatedSchemaNode), + detail: "The current YAML value matches the forbidden 'not' schema."); + } + /// /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。 /// 该键同时服务于 uniqueItemsconst, @@ -3223,6 +3452,7 @@ internal sealed class YamlConfigSchemaNode ArrayConstraints = validation.ArrayConstraints; ObjectConstraints = validation.ObjectConstraints; ConstantValue = validation.ConstantValue; + NegatedSchemaNode = validation.NegatedSchemaNode; SchemaPathHint = schemaPathHint; } @@ -3276,6 +3506,11 @@ internal sealed class YamlConfigSchemaNode /// public YamlConfigConstantValue? ConstantValue { get; } + /// + /// 获取节点声明的 not 子 schema;未声明时返回空。 + /// + public YamlConfigSchemaNode? NegatedSchemaNode { get; } + /// /// 获取用于诊断显示的 schema 路径提示。 /// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。 @@ -3305,7 +3540,8 @@ internal sealed class YamlConfigSchemaNode constraints: null, arrayConstraints: null, objectConstraints, - constantValue: null), + constantValue: null, + negatedSchemaNode: null), schemaPathHint); } @@ -3330,7 +3566,8 @@ internal sealed class YamlConfigSchemaNode constraints: null, arrayConstraints, objectConstraints: null, - constantValue: null), + constantValue: null, + negatedSchemaNode: null), schemaPathHint); } @@ -3359,7 +3596,8 @@ internal sealed class YamlConfigSchemaNode constraints, arrayConstraints: null, objectConstraints: null, - constantValue: null), + constantValue: null, + negatedSchemaNode: null), schemaPathHint); } @@ -3392,6 +3630,20 @@ internal sealed class YamlConfigSchemaNode SchemaPathHint); } + /// + /// 基于当前节点复制一个只替换 not 子 schema 的新节点。 + /// + /// 新的 negated schema。 + /// 复制后的节点。 + public YamlConfigSchemaNode WithNegatedSchemaNode(YamlConfigSchemaNode? negatedSchemaNode) + { + return new YamlConfigSchemaNode( + NodeType, + _children, + _validation.WithNegatedSchemaNode(negatedSchemaNode), + SchemaPathHint); + } + private sealed class NodeChildren { public NodeChildren( @@ -3421,7 +3673,8 @@ internal sealed class YamlConfigSchemaNode YamlConfigScalarConstraints? constraints, YamlConfigArrayConstraints? arrayConstraints, YamlConfigObjectConstraints? objectConstraints, - YamlConfigConstantValue? constantValue) + YamlConfigConstantValue? constantValue, + YamlConfigSchemaNode? negatedSchemaNode) { ReferenceTableName = referenceTableName; AllowedValues = allowedValues; @@ -3429,6 +3682,7 @@ internal sealed class YamlConfigSchemaNode ArrayConstraints = arrayConstraints; ObjectConstraints = objectConstraints; ConstantValue = constantValue; + NegatedSchemaNode = negatedSchemaNode; } public static NodeValidation None { get; } = new( @@ -3437,7 +3691,8 @@ internal sealed class YamlConfigSchemaNode constraints: null, arrayConstraints: null, objectConstraints: null, - constantValue: null); + constantValue: null, + negatedSchemaNode: null); public string? ReferenceTableName { get; } @@ -3451,16 +3706,24 @@ internal sealed class YamlConfigSchemaNode public YamlConfigConstantValue? ConstantValue { get; } + public YamlConfigSchemaNode? NegatedSchemaNode { get; } + public NodeValidation WithReferenceTable(string referenceTableName) { return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints, - ObjectConstraints, ConstantValue); + ObjectConstraints, ConstantValue, NegatedSchemaNode); } public NodeValidation WithConstantValue(YamlConfigConstantValue? constantValue) { return new NodeValidation(ReferenceTableName, AllowedValues, Constraints, ArrayConstraints, - ObjectConstraints, constantValue); + ObjectConstraints, constantValue, NegatedSchemaNode); + } + + public NodeValidation WithNegatedSchemaNode(YamlConfigSchemaNode? negatedSchemaNode) + { + return new NodeValidation(ReferenceTableName, AllowedValues, Constraints, ArrayConstraints, + ObjectConstraints, ConstantValue, negatedSchemaNode); } } } @@ -3710,11 +3973,21 @@ internal enum YamlConfigStringFormatKind /// DateTime, + /// + /// 表示 day-time duration 形式的持续时间。 + /// + Duration, + /// /// 表示基础电子邮件地址格式。 /// Email, + /// + /// 表示带显式时区偏移的 RFC 3339 时间。 + /// + Time, + /// /// 表示绝对 URI。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 3e74a9a1..52b67787 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -95,7 +95,7 @@ public class SchemaConfigGeneratorTests /// 验证共享支持的字符串 format 会写入生成 XML 文档。 /// [Test] - public void Run_Should_Write_Supported_String_Format_Into_Generated_Documentation() + public void Run_Should_Write_Supported_Duration_Format_Into_Generated_Documentation() { const string source = """ namespace TestApp @@ -109,12 +109,12 @@ public class SchemaConfigGeneratorTests const string schema = """ { "type": "object", - "required": ["id", "contactEmail"], + "required": ["id", "respawnDelay"], "properties": { "id": { "type": "integer" }, - "contactEmail": { + "respawnDelay": { "type": "string", - "format": "email" + "format": "duration" } } } @@ -133,7 +133,7 @@ public class SchemaConfigGeneratorTests StringComparer.Ordinal); Assert.That(result.Results.Single().Diagnostics, Is.Empty); - Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: format = 'email'.")); + Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: format = 'duration'.")); } /// @@ -178,6 +178,8 @@ public class SchemaConfigGeneratorTests Assert.That(diagnostic.GetMessage(), Does.Contain("address")); Assert.That(diagnostic.GetMessage(), Does.Contain("ipv4")); Assert.That(diagnostic.GetMessage(), Does.Contain("date-time")); + Assert.That(diagnostic.GetMessage(), Does.Contain("duration")); + Assert.That(diagnostic.GetMessage(), Does.Contain("time")); }); } @@ -270,6 +272,102 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证 not 子 schema 的约束会写入生成 XML 文档。 + /// + [Test] + public void Run_Should_Write_Not_Constraint_Into_Generated_Documentation() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "not": { + "type": "string", + "const": "Deprecated" + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var generatedSources = result.Results + .Single() + .GeneratedSources + .ToDictionary( + static sourceResult => sourceResult.HintName, + static sourceResult => sourceResult.SourceText.ToString(), + StringComparer.Ordinal); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + Assert.That(generatedSources["MonsterConfig.g.cs"], + Does.Contain("Constraints: not = string (const = \"Deprecated\").")); + } + + /// + /// 验证 not 子 schema 内的非法 format 也会在生成阶段直接给出诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Not_Schema_Uses_Format_On_Non_String_Node() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "hp"], + "properties": { + "id": { "type": "integer" }, + "hp": { + "type": "integer", + "not": { + "type": "integer", + "format": "uuid" + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("hp[not]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'.")); + }); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 857b9092..84a1cef6 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -30,7 +30,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator private const string LookupIndexReferencePropertyMessage = "Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics."; - private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', and 'uuid'"; + private const string SupportedStringFormatNames = "'date', 'date-time', 'duration', 'email', 'time', 'uri', and 'uuid'"; /// public void Initialize(IncrementalGeneratorInitializationContext context) @@ -670,6 +670,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + if (element.TryGetProperty("not", out var notElement) && + notElement.ValueKind == JsonValueKind.Object && + !TryValidateStringFormatMetadataRecursively( + filePath, + $"{displayPath}[not]", + notElement, + out diagnostic)) + { + return false; + } + if (!string.Equals(schemaType, "array", StringComparison.Ordinal)) { return true; @@ -707,7 +718,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { "date" => true, "date-time" => true, + "duration" => true, "email" => true, + "time" => true, "uri" => true, "uuid" => true, _ => false @@ -2886,6 +2899,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + var notDocumentation = TryBuildNotDocumentation(element); + if (notDocumentation is not null) + { + parts.Add($"not = {notDocumentation}"); + } + if (schemaType == "array" && TryGetNonNegativeInt32(element, "minContains", out var minContains)) { @@ -2927,18 +2946,34 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return null; } - return TryBuildContainsSchemaSummary(containsElement); + return TryBuildInlineSchemaSummary(containsElement); } /// - /// 为 contains 子 schema 生成紧凑摘要。 - /// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains 与主属性文档逐渐漂移。 + /// 将 not 子 schema 整理成 XML 文档可读字符串。 /// - /// contains 子 schema。 - /// 格式化后的摘要字符串。 - private static string? TryBuildContainsSchemaSummary(JsonElement containsElement) + /// Schema 节点。 + /// 格式化后的 not 说明。 + private static string? TryBuildNotDocumentation(JsonElement element) { - if (!containsElement.TryGetProperty("type", out var typeElement) || + if (!element.TryGetProperty("not", out var notElement) || + notElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + return TryBuildInlineSchemaSummary(notElement); + } + + /// + /// 为内联子 schema 生成紧凑摘要。 + /// 该摘要复用现有 enum / const / 约束文档构造器,避免 contains / not 与主属性文档逐渐漂移。 + /// + /// 内联子 schema。 + /// 格式化后的摘要字符串。 + private static string? TryBuildInlineSchemaSummary(JsonElement schemaElement) + { + if (!schemaElement.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) { return null; @@ -2951,19 +2986,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } var details = new List(); - var enumDocumentation = TryBuildEnumDocumentation(containsElement, schemaType!); + var enumDocumentation = TryBuildEnumDocumentation(schemaElement, schemaType!); if (enumDocumentation is not null) { details.Add($"enum = {enumDocumentation}"); } - var constraintDocumentation = TryBuildConstraintDocumentation(containsElement, schemaType!); + var constraintDocumentation = TryBuildConstraintDocumentation(schemaElement, schemaType!); if (constraintDocumentation is not null) { details.Add(constraintDocumentation); } - var refTable = TryGetMetadataString(containsElement, "x-gframework-ref-table"); + var refTable = TryGetMetadataString(schemaElement, "x-gframework-ref-table"); if (!string.IsNullOrWhiteSpace(refTable)) { details.Add($"ref-table = {refTable}"); diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 6dc5ba21..e3e23bd9 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `const`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`email`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` +- Runtime / Generator / Tooling 共享支持 `const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -720,6 +720,7 @@ var loader = new YamlConfigLoader("config-root") - 数组字段违反 `contains` / `minContains` / `maxContains` - 对象字段违反 `minProperties` / `maxProperties` - 标量 / 对象 / 数组字段违反 `const` +- 标量 / 对象 / 数组字段命中 `not` - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -767,13 +768,17 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `description`:供表单提示、生成代码 XML 文档和接入说明复用 - `default`:供生成类型属性初始值和工具提示复用 - `const`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象会忽略字段顺序比较,数组保留元素顺序,标量按运行时同一套类型归一化规则比较 +- `not`:供运行时校验、VS Code 校验和生成代码 XML 文档复用;`not` 子 schema 会复用同一套递归校验规则,但对象匹配保持主校验链的严格语义,不会像 `contains` 那样把“声明属性子集”视为命中 - `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前优先按运行时与 JS 共用的十进制精确整倍数判定处理常见十进制步进,并在必要时退回浮点容差兜底 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 -- `format`:当前只支持 Runtime / Generator / Tooling 三端都能稳定对齐的字符串子集 `date`、`date-time`、`email`、`uri`、`uuid`;运行时会拒绝不满足格式的值,VS Code 校验与表单 hint 会同步展示该约束,生成代码 XML 文档也会保留 `format = ...` 说明 +- `format`:当前只支持 Runtime / Generator / Tooling 三端都能稳定对齐的字符串子集 `date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid` +- `duration`:当前只支持稳定的 day-time duration 子集,例如 `P2D`、`PT45M`、`P2DT3H4M5.5S`;为了避免跨宿主对日历语义解释漂移,暂不支持 `Y` / `M(月)` / `W` +- `time`:固定要求显式时区偏移(例如 `08:30:00Z` 或 `08:30:00+08:00`),避免不同宿主对 time-only 文本隐式补日期或本地时区 +- 对上述共享子集,运行时会拒绝不满足格式的值,VS Code 校验与表单 hint 会同步展示该约束,生成代码 XML 文档也会保留 `format = ...` 说明 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 - `contains` / `minContains` / `maxContains`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”,其中仅声明 `contains` 时默认至少需要 1 个匹配元素 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 9eb7ddc4..670e7000 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -14,7 +14,11 @@ const UuidFormatPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9 const DateFormatPattern = /^(?\d{4})-(?\d{2})-(?\d{2})$/u; const DateTimeFormatPattern = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$/u; -const SupportedStringFormats = new Set(["date", "date-time", "email", "uri", "uuid"]); +const DurationFormatPattern = + /^P(?:(?\d+)D)?(?:T(?:(?\d+)H)?(?:(?\d+)M)?(?:(?\d+(?:\.\d+)?)S)?)?$/u; +const TimeFormatPattern = + /^(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$/u; +const SupportedStringFormats = new Set(["date", "date-time", "duration", "email", "time", "uri", "uuid"]); /** * Compare two strings using the same UTF-16 code-unit ordering as C#'s @@ -482,7 +486,7 @@ function normalizeSchemaStringFormat(value, schemaType, displayPath) { throw new Error( `Schema property '${displayPath}' declares unsupported string format '${value}'. ` + - "Supported formats are 'date', 'date-time', 'email', 'uri', and 'uuid'."); + "Supported formats are 'date', 'date-time', 'duration', 'email', 'time', 'uri', and 'uuid'."); } /** @@ -626,8 +630,12 @@ function matchesSchemaStringFormat(scalarValue, formatName) { return matchesSchemaDateFormat(scalarValue); case "date-time": return matchesSchemaDateTimeFormat(scalarValue); + case "duration": + return matchesSchemaDurationFormat(scalarValue); case "email": return EmailFormatPattern.test(scalarValue); + case "time": + return matchesSchemaTimeFormat(scalarValue); case "uri": return matchesSchemaUriFormat(scalarValue); case "uuid": @@ -691,6 +699,64 @@ function matchesSchemaDateTimeFormat(scalarValue) { return offsetHour <= 23 && offsetMinute <= 59; } +/** + * Validate one shared day-time duration string. + * + * @param {string} scalarValue Scalar value from YAML. + * @returns {boolean} True when the value stays within the shared day-time subset. + */ +function matchesSchemaDurationFormat(scalarValue) { + const match = DurationFormatPattern.exec(scalarValue); + if (!match || !match.groups) { + return false; + } + + const hasDayComponent = match.groups.days !== undefined; + const hasHourComponent = match.groups.hours !== undefined; + const hasMinuteComponent = match.groups.minutes !== undefined; + const hasSecondComponent = match.groups.seconds !== undefined; + const hasAnyComponent = hasDayComponent || hasHourComponent || hasMinuteComponent || hasSecondComponent; + if (!hasAnyComponent) { + return false; + } + + const hasTimeSection = scalarValue.includes("T"); + if (hasTimeSection && !hasHourComponent && !hasMinuteComponent && !hasSecondComponent) { + return false; + } + + return true; +} + +/** + * Validate one RFC 3339 full-time string with explicit timezone offset. + * + * @param {string} scalarValue Scalar value from YAML. + * @returns {boolean} True when the value is structurally valid. + */ +function matchesSchemaTimeFormat(scalarValue) { + const match = TimeFormatPattern.exec(scalarValue); + if (!match || !match.groups) { + return false; + } + + const hour = Number.parseInt(match.groups.hour, 10); + const minute = Number.parseInt(match.groups.minute, 10); + const second = Number.parseInt(match.groups.second, 10); + if (hour > 23 || minute > 59 || second > 59) { + return false; + } + + const offset = match.groups.offset; + if (offset === "Z") { + return true; + } + + const offsetHour = Number.parseInt(offset.slice(1, 3), 10); + const offsetMinute = Number.parseInt(offset.slice(4, 6), 10); + return offsetHour <= 23 && offsetMinute <= 59; +} + /** * Validate one absolute URI string using the platform URL parser. * @@ -1005,6 +1071,7 @@ function parseSchemaNode(rawNode, displayPath) { const type = typeof value.type === "string" ? value.type : "object"; const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath); + const negatedSchemaNode = parseNegatedSchemaNode(value.not, displayPath); const metadata = { title: typeof value.title === "string" ? value.title : undefined, description: typeof value.description === "string" ? value.description : undefined, @@ -1049,7 +1116,8 @@ function parseSchemaNode(rawNode, displayPath) { maxProperties: metadata.maxProperties, title: metadata.title, description: metadata.description, - defaultValue: metadata.defaultValue + defaultValue: metadata.defaultValue, + not: negatedSchemaNode }, value.const, displayPath); } @@ -1093,7 +1161,8 @@ function parseSchemaNode(rawNode, displayPath) { uniqueItems: metadata.uniqueItems === true, refTable: metadata.refTable, contains: containsNode, - items: itemNode + items: itemNode, + not: negatedSchemaNode }, value.const, displayPath); } @@ -1134,10 +1203,31 @@ function parseSchemaNode(rawNode, displayPath) { ? metadata.format : undefined, enumValues: normalizeSchemaEnumValues(value.enum), - refTable: metadata.refTable + refTable: metadata.refTable, + not: negatedSchemaNode }, value.const, displayPath); } +/** + * Parse one optional `not` sub-schema and keep path formatting aligned with + * the runtime/generator diagnostics. + * + * @param {unknown} rawNot Raw `not` node. + * @param {string} displayPath Parent schema path. + * @returns {SchemaNode | undefined} Parsed negated schema node. + */ +function parseNegatedSchemaNode(rawNot, displayPath) { + if (rawNot === undefined) { + return undefined; + } + + if (!rawNot || typeof rawNot !== "object" || Array.isArray(rawNot)) { + throw new Error(`Schema property '${displayPath}' must declare 'not' as an object-valued schema.`); + } + + return parseSchemaNode(rawNot, `${displayPath}[not]`); +} + /** * Validate one schema node against one YAML node. * @@ -1233,7 +1323,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) if (!hasStructurallyInvalidArrayItems && schemaNode.contains) { let matchingContainsCount = 0; for (const {node} of containsCandidateItems) { - if (matchesSchemaNode(schemaNode.contains, node)) { + if (matchesSchemaNode(schemaNode.contains, node, true)) { matchingContainsCount += 1; } } @@ -1264,6 +1354,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); + validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); return; } @@ -1414,6 +1505,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); + validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); } /** @@ -1495,6 +1587,7 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); + validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer); } /** @@ -1505,10 +1598,12 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. + * @param {boolean} allowUnknownObjectProperties Whether object matching should + * tolerate extra undeclared properties. * @returns {boolean} True when the YAML node matches the schema node. */ -function matchesSchemaNode(schemaNode, yamlNode) { - return matchesSchemaNodeInternal(schemaNode, yamlNode); +function matchesSchemaNode(schemaNode, yamlNode, allowUnknownObjectProperties = false) { + return matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties); } /** @@ -1518,9 +1613,11 @@ function matchesSchemaNode(schemaNode, yamlNode) { * * @param {SchemaNode} schemaNode Schema node. * @param {YamlNode} yamlNode YAML node. + * @param {boolean} allowUnknownObjectProperties Whether object matching should + * tolerate extra undeclared properties. * @returns {boolean} True when the YAML node satisfies the schema node. */ -function matchesSchemaNodeInternal(schemaNode, yamlNode) { +function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectProperties) { if (schemaNode.type === "object") { if (!yamlNode || yamlNode.kind !== "object") { return false; @@ -1538,9 +1635,17 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { } } + if (!allowUnknownObjectProperties) { + for (const entry of yamlNode.entries) { + if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { + return false; + } + } + } + for (const [key, childSchema] of Object.entries(schemaNode.properties)) { if (yamlNode.map.has(key) && - !matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key))) { + !matchesSchemaNodeInternal(childSchema, yamlNode.map.get(key), allowUnknownObjectProperties)) { return false; } } @@ -1555,8 +1660,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { return false; } - return typeof schemaNode.constComparableValue !== "string" || - buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; + if (typeof schemaNode.constComparableValue === "string" && + buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { + return false; + } + + return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false); } if (schemaNode.type === "array") { @@ -1575,7 +1684,7 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { } for (const item of yamlNode.items) { - if (!matchesSchemaNodeInternal(schemaNode.items, item)) { + if (!matchesSchemaNodeInternal(schemaNode.items, item, allowUnknownObjectProperties)) { return false; } } @@ -1595,7 +1704,7 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { if (schemaNode.contains) { let matchingContainsCount = 0; for (const item of yamlNode.items) { - if (matchesSchemaNodeInternal(schemaNode.contains, item)) { + if (matchesSchemaNodeInternal(schemaNode.contains, item, true)) { matchingContainsCount += 1; } } @@ -1613,8 +1722,12 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { } } - return typeof schemaNode.constComparableValue !== "string" || - buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; + if (typeof schemaNode.constComparableValue === "string" && + buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { + return false; + } + + return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false); } if (!yamlNode || yamlNode.kind !== "scalar") { @@ -1687,8 +1800,36 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { return false; } - return typeof schemaNode.constComparableValue !== "string" || - buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; + if (typeof schemaNode.constComparableValue === "string" && + buildComparableNodeValue(schemaNode, yamlNode) !== schemaNode.constComparableValue) { + return false; + } + + return !schemaNode.not || !matchesSchemaNodeInternal(schemaNode.not, yamlNode, false); +} + +/** + * Emit one validation error when the current YAML node matches a forbidden `not` + * sub-schema. Unlike `contains`, this path keeps object matching strict so + * undeclared members still block the negated branch from matching. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {YamlNode} yamlNode YAML node. + * @param {string} displayPath Current logical path. + * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. + * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. + */ +function validateNotSchemaMatch(schemaNode, yamlNode, displayPath, diagnostics, localizer) { + if (!schemaNode.not || !matchesSchemaNode(schemaNode.not, yamlNode, false)) { + return; + } + + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.notViolation, localizer, { + displayPath + }) + }); } /** @@ -1896,6 +2037,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; case ValidationMessageKeys.multipleOfViolation: return `属性“${params.displayPath}”必须是 ${params.value} 的整数倍。`; + case ValidationMessageKeys.notViolation: + return `属性“${params.displayPath}”不能匹配被 \`not\` 禁止的 schema。`; case ValidationMessageKeys.minContainsViolation: return `属性“${params.displayPath}”至少需要包含 ${params.value} 个匹配 contains 条件的元素。`; case ValidationMessageKeys.minItemsViolation: @@ -1944,6 +2087,8 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; case ValidationMessageKeys.multipleOfViolation: return `Property '${params.displayPath}' must be a multiple of ${params.value}.`; + case ValidationMessageKeys.notViolation: + return `Property '${params.displayPath}' must not match the forbidden 'not' schema.`; case ValidationMessageKeys.minContainsViolation: return `Property '${params.displayPath}' must contain at least ${params.value} items matching the 'contains' schema.`; case ValidationMessageKeys.minItemsViolation: @@ -2661,7 +2806,8 @@ module.exports = { * defaultValue?: string, * constValue?: string, * constDisplayValue?: string, - * constComparableValue?: string + * constComparableValue?: string, + * not?: SchemaNode * } | { * type: "array", * displayPath: string, @@ -2678,6 +2824,7 @@ module.exports = { * uniqueItems?: boolean, * refTable?: string, * contains?: SchemaNode, + * not?: SchemaNode, * items: SchemaNode * } | { * type: "string" | "integer" | "number" | "boolean", @@ -2699,7 +2846,8 @@ module.exports = { * patternRegex?: RegExp, * format?: string, * enumValues?: string[], - * refTable?: string + * refTable?: string, + * not?: SchemaNode * }} SchemaNode */ diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index e55e4239..3cbbb26d 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -148,6 +148,7 @@ const enMessages = { [ValidationMessageKeys.maxPropertiesViolation]: "Property '{displayPath}' must contain at most {value} properties.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", + [ValidationMessageKeys.notViolation]: "Property '{displayPath}' must not match the forbidden 'not' schema.", [ValidationMessageKeys.minContainsViolation]: "Property '{displayPath}' must contain at least {value} items matching the 'contains' schema.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", @@ -266,6 +267,7 @@ const zhCnMessages = { [ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", + [ValidationMessageKeys.notViolation]: "属性“{displayPath}”不能匹配被 `not` 禁止的 schema。", [ValidationMessageKeys.minContainsViolation]: "属性“{displayPath}”至少需要包含 {value} 个匹配 contains 条件的元素。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 3551b173..a1a3c43b 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -15,6 +15,7 @@ const ValidationMessageKeys = Object.freeze({ maxPropertiesViolation: "validation.maxPropertiesViolation", minimumViolation: "validation.minimumViolation", multipleOfViolation: "validation.multipleOfViolation", + notViolation: "validation.notViolation", minContainsViolation: "validation.minContainsViolation", minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index a34723f0..38eb14ec 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -675,10 +675,18 @@ test("validateParsedConfig should enforce supported string formats", () => { "type": "string", "format": "date-time" }, + "respawnDelay": { + "type": "string", + "format": "duration" + }, "contactEmail": { "type": "string", "format": "email" }, + "dailyResetAt": { + "type": "string", + "format": "time" + }, "catalogUri": { "type": "string", "format": "uri" @@ -694,20 +702,24 @@ test("validateParsedConfig should enforce supported string formats", () => { releaseDate: 2026-02-30 ancientReleaseDate: 0000-01-01 publishedAt: 2026-04-11T08:30:00 +respawnDelay: P1Y contactEmail: boss.example.com +dailyResetAt: 08:30:00 catalogUri: /loot-table configId: 123e4567e89b12d3a456426614174000 `); const diagnostics = validateParsedConfig(schema, yaml); - assert.equal(diagnostics.length, 6); + assert.equal(diagnostics.length, 8); assert.match(diagnostics[0].message, /format 'date'|字符串格式“date”/u); assert.match(diagnostics[1].message, /format 'date'|字符串格式“date”/u); assert.match(diagnostics[2].message, /format 'date-time'|字符串格式“date-time”/u); - assert.match(diagnostics[3].message, /format 'email'|字符串格式“email”/u); - assert.match(diagnostics[4].message, /format 'uri'|字符串格式“uri”/u); - assert.match(diagnostics[5].message, /format 'uuid'|字符串格式“uuid”/u); + assert.match(diagnostics[3].message, /format 'duration'|字符串格式“duration”/u); + assert.match(diagnostics[4].message, /format 'email'|字符串格式“email”/u); + assert.match(diagnostics[5].message, /format 'time'|字符串格式“time”/u); + assert.match(diagnostics[6].message, /format 'uri'|字符串格式“uri”/u); + assert.match(diagnostics[7].message, /format 'uuid'|字符串格式“uuid”/u); }); test("validateParsedConfig should accept supported string formats", () => { @@ -723,10 +735,18 @@ test("validateParsedConfig should accept supported string formats", () => { "type": "string", "format": "date-time" }, + "respawnDelay": { + "type": "string", + "format": "duration" + }, "contactEmail": { "type": "string", "format": "email" }, + "dailyResetAt": { + "type": "string", + "format": "time" + }, "catalogUri": { "type": "string", "format": "uri" @@ -741,7 +761,9 @@ test("validateParsedConfig should accept supported string formats", () => { const yaml = parseTopLevelYaml(` releaseDate: 2026-04-11 publishedAt: 2026-04-11T08:30:00Z +respawnDelay: P2DT3H4M5.5S contactEmail: boss@example.com +dailyResetAt: 08:30:00Z catalogUri: https://example.com/loot-table configId: 123e4567-e89b-12d3-a456-426614174000 `); @@ -1406,7 +1428,7 @@ test("parseSchemaContent should capture supported string format metadata", () => "type": "array", "items": { "type": "string", - "format": "uuid" + "format": "duration" } } } @@ -1414,7 +1436,7 @@ test("parseSchemaContent should capture supported string format metadata", () => `); assert.equal(schema.properties.contactEmail.format, "email"); - assert.equal(schema.properties.aliases.items.format, "uuid"); + assert.equal(schema.properties.aliases.items.format, "duration"); }); test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () => { @@ -1602,6 +1624,43 @@ test("parseSchemaContent should capture object property-count metadata", () => { assert.equal(schema.properties.reward.maxProperties, 2); }); +test("parseSchemaContent should capture not sub-schema metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "not": { + "type": "string", + "const": "Deprecated" + } + } + } + } + `); + + assert.equal(schema.properties.name.not.type, "string"); + assert.equal(schema.properties.name.not.constDisplayValue, "\"Deprecated\""); +}); + +test("parseSchemaContent should reject non-object not declarations", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "not": "deprecated" + } + } + } + `), + /must declare 'not' as an object-valued schema/u + ); +}); + test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => { assert.throws( () => parseSchemaContent(` @@ -1632,7 +1691,7 @@ test("parseSchemaContent should reject unsupported string format declarations", } } `), - /unsupported string format 'ipv4'/u + /unsupported string format 'ipv4'.*'duration'.*'time'/u ); }); @@ -1737,6 +1796,99 @@ reward: 1 assert.equal(diagnostics[0].message, "属性“reward”应为对象。"); }); +test("validateParsedConfig should reject values that match a forbidden not schema", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "not": { + "type": "string", + "const": "Deprecated" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +name: Deprecated +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.equal( + diagnostics[0].message, + "Property 'name' must not match the forbidden 'not' schema."); +}); + +test("validateParsedConfig should keep not object matching strict instead of contains-style subset matching", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "not": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { "type": "integer" } + } + }, + "properties": { + "gold": { "type": "integer" }, + "bonus": { "type": "integer" } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + gold: 10 + bonus: 5 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.deepEqual(diagnostics, []); +}); + +test("validateParsedConfig should reject objects that fully match a forbidden not schema", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "not": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { "type": "integer" } + } + }, + "properties": { + "gold": { "type": "integer" }, + "bonus": { "type": "integer" } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + gold: 10 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /not/u); +}); + test("applyFormUpdates should update nested scalar and scalar-array paths", () => { const updated = applyFormUpdates( [ diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index 6a4f1f55..7d10aff9 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -69,3 +69,15 @@ test("createLocalizer should expose contains-count validation keys", () => { chineseLocalizer.t(ValidationMessageKeys.maxContainsViolation, {displayPath: "dropRates", value: 1}), "属性“dropRates”最多只能包含 1 个匹配 contains 条件的元素。"); }); + +test("createLocalizer should expose not validation keys", () => { + const englishLocalizer = createLocalizer("en"); + const chineseLocalizer = createLocalizer("zh-cn"); + + assert.equal( + englishLocalizer.t(ValidationMessageKeys.notViolation, {displayPath: "name"}), + "Property 'name' must not match the forbidden 'not' schema."); + assert.equal( + chineseLocalizer.t(ValidationMessageKeys.notViolation, {displayPath: "name"}), + "属性“name”不能匹配被 `not` 禁止的 schema。"); +});