diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 1d0c4a10..1ba55208 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -831,6 +831,164 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证运行时会接受当前共享支持的字符串 format 子集。 + /// + /// schema 中声明的 format 名称。 + /// 满足该 format 的 YAML 标量值。 + [TestCase("date", "2026-04-11")] + [TestCase("date-time", "2026-04-11T08:30:00Z")] + [TestCase("email", "boss@example.com")] + [TestCase("uri", "https://example.com/loot-table")] + [TestCase("uuid", "123e4567-e89b-12d3-a456-426614174000")] + public async Task LoadAsync_Should_Accept_Supported_String_Format( + string formatName, + string value) + { + CreateConfigFile( + "monster/slime.yaml", + $$""" + id: 1 + name: {{value}} + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + $$""" + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "format": "{{formatName}}" + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + 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(value)); + }); + } + + /// + /// 验证运行时会拒绝不满足共享字符串 format 子集的值。 + /// + /// schema 中声明的 format 名称。 + /// 不满足该 format 的 YAML 标量值。 + [TestCase("date", "2026-02-30")] + [TestCase("date-time", "2026-04-11T08:30:00")] + [TestCase("email", "boss.example.com")] + [TestCase("uri", "/loot-table")] + [TestCase("uuid", "123e4567e89b12d3a456426614174000")] + public void LoadAsync_Should_Throw_When_String_Does_Not_Match_Supported_Format( + string formatName, + string value) + { + CreateConfigFile( + "monster/slime.yaml", + $$""" + id: 1 + name: {{value}} + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + $$""" + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "format": "{{formatName}}" + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + 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.Diagnostic.RawValue, Is.EqualTo(value)); + Assert.That(exception.Message, Does.Contain("string format")); + Assert.That(exception.Message, Does.Contain(formatName)); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 schema 使用当前未支持的字符串 format 时会在解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_String_Format_Is_Not_Supported() + { + 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", + "format": "ipv4" + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + 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.Diagnostic.RawValue, Is.EqualTo("ipv4")); + Assert.That(exception.Message, Does.Contain("unsupported string format")); + Assert.That(exception.Message, Does.Contain("date-time")); + }); + } + /// /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 8c588e56..3c86d649 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -10,7 +10,7 @@ namespace GFramework.Game.Config; /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 /// 当前共享子集额外支持 multipleOfuniqueItems、 /// contains / minContains / maxContains、 -/// minPropertiesmaxProperties, +/// minPropertiesmaxProperties 与稳定字符串 format 子集, /// 让数值步进、数组去重、数组匹配计数和对象属性数量规则在运行时与生成器 / 工具侧保持一致。 /// internal static class YamlConfigSchemaValidator @@ -18,9 +18,22 @@ 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 static readonly Regex ExactDecimalPattern = new( @"^(?[+-]?)(?:(?\d+)(?:\.(?\d*))?|\.(?\d+))(?:[eE](?[+-]?\d+))?$", RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SupportedEmailFormatRegex = new( + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SupportedDateFormatRegex = new( + @"^(?\d{4})-(?\d{2})-(?\d{2})$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SupportedDateTimeFormatRegex = new( + @"^(?\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 SupportedUriSchemeRegex = new( + @"^[A-Za-z][A-Za-z0-9+\.-]*:", + RegexOptions.CultureInvariant | RegexOptions.Compiled); /// /// 从磁盘加载并解析一个 JSON Schema 文件。 @@ -1120,11 +1133,11 @@ internal static class YamlConfigSchemaValidator } /// - /// 解析标量字段支持的范围、长度与模式约束。 - /// 当前共享子集支持: - /// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`, - /// 以及 `string` 上的 `minLength/maxLength/pattern`。 - /// +/// 解析标量字段支持的范围、长度与模式约束。 +/// 当前共享子集支持: +/// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`, +/// 以及 `string` 上的 `minLength/maxLength/pattern/format`。 +/// /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 @@ -1148,6 +1161,7 @@ internal static class YamlConfigSchemaValidator var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength"); var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength"); var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType); + var formatConstraint = TryParseFormatConstraint(tableName, schemaPath, propertyPath, element, nodeType); if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value) { @@ -1187,7 +1201,8 @@ internal static class YamlConfigSchemaValidator var stringConstraints = CreateStringScalarConstraints( minLength, maxLength, - pattern); + pattern, + formatConstraint); return numericConstraints is null && stringConstraints is null ? null @@ -1533,6 +1548,102 @@ internal static class YamlConfigSchemaValidator return pattern; } + /// + /// 读取字符串 format 约束。 + /// 运行时只接受当前三端共享并已验证收益的稳定子集,避免把开放格式名误当成“全部支持”。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 字段类型。 + /// 归一化后的 format 约束;未声明时返回空。 + private static YamlConfigStringFormatConstraint? TryParseFormatConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType) + { + if (!element.TryGetProperty("format", out var formatElement)) + { + return null; + } + + if (nodeType != YamlConfigSchemaPropertyType.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'format', but only 'string' scalar types support string formats.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (formatElement.ValueKind != JsonValueKind.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'format' as a string.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var formatName = formatElement.GetString() ?? string.Empty; + if (TryMapSupportedStringFormat(formatName, out var kind)) + { + return new YamlConfigStringFormatConstraint(formatName, kind); + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported string format '{formatName}'. Supported formats are {SupportedStringFormatNames}.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath), + rawValue: formatName); + } + + /// + /// 将 schema 原文中的 format 名称映射为运行时共享枚举。 + /// 映射阶段统一使用大小写敏感比较,避免同一 schema 在不同环境下被“宽松容错”解释成不同语义。 + /// + /// schema 原始 format 名称。 + /// 解析成功时输出归一化枚举。 + /// 当前格式是否属于共享支持子集。 + private static bool TryMapSupportedStringFormat( + string formatName, + out YamlConfigStringFormatKind kind) + { + switch (formatName) + { + case "date": + kind = YamlConfigStringFormatKind.Date; + return true; + + case "date-time": + kind = YamlConfigStringFormatKind.DateTime; + return true; + + case "email": + kind = YamlConfigStringFormatKind.Email; + return true; + + case "uri": + kind = YamlConfigStringFormatKind.Uri; + return true; + + case "uuid": + kind = YamlConfigStringFormatKind.Uuid; + return true; + + default: + kind = default; + return false; + } + } + /// /// 读取数组元素数量约束。 /// @@ -1863,20 +1974,24 @@ internal static class YamlConfigSchemaValidator /// /// 根据已读取的字符串关键字创建字符串约束对象。 - /// 正则会在 schema 解析阶段预编译,避免每次校验都重复实例化。 + /// 正则会在 schema 解析阶段预编译,字符串 format 也会先归一化成共享枚举, + /// 避免每次校验都重新解释 schema 原文。 /// /// 最小长度约束。 /// 最大长度约束。 /// 正则模式约束。 + /// 字符串 format 约束。 /// 字符串约束对象;未声明任何字符串约束时返回空。 private static YamlConfigStringConstraints? CreateStringScalarConstraints( int? minLength, int? maxLength, - string? pattern) + string? pattern, + YamlConfigStringFormatConstraint? formatConstraint) { return !minLength.HasValue && !maxLength.HasValue && - pattern is null + pattern is null && + formatConstraint is null ? null : new YamlConfigStringConstraints( minLength, @@ -1886,7 +2001,8 @@ internal static class YamlConfigSchemaValidator ? null : new Regex( pattern, - SupportedPatternRegexOptions)); + SupportedPatternRegexOptions), + formatConstraint); } /// @@ -2026,7 +2142,7 @@ internal static class YamlConfigSchemaValidator } /// - /// 校验字符串标量的长度与模式约束。 + /// 校验字符串标量的长度、模式与 format 约束。 /// /// 所属配置表名称。 /// YAML 文件路径。 @@ -2087,6 +2203,96 @@ internal static class YamlConfigSchemaValidator rawValue: rawValue, detail: $"Expected pattern: {constraints.Pattern}."); } + + if (constraints.FormatConstraint is not null && + !MatchesSupportedStringFormat(rawValue, constraints.FormatConstraint.Kind)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must satisfy string format '{constraints.FormatConstraint.SchemaName}', but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Expected string format: {constraints.FormatConstraint.SchemaName}."); + } + } + + /// + /// 判断一个字符串是否满足当前共享支持的 format 子集。 + /// 这里只接受 Runtime / Generator / Tooling 三端都能稳定解释的格式, + /// 避免把 JSON Schema 的开放格式名直接当成“全部支持”。 + /// + /// 待校验的字符串值。 + /// 共享 format 枚举。 + /// 当前字符串是否满足指定 format。 + private static bool MatchesSupportedStringFormat( + string value, + YamlConfigStringFormatKind formatKind) + { + ArgumentNullException.ThrowIfNull(value); + + return formatKind switch + { + YamlConfigStringFormatKind.Date => MatchesSupportedDateFormat(value), + YamlConfigStringFormatKind.DateTime => MatchesSupportedDateTimeFormat(value), + YamlConfigStringFormatKind.Email => SupportedEmailFormatRegex.IsMatch(value), + YamlConfigStringFormatKind.Uri => MatchesSupportedUriFormat(value), + YamlConfigStringFormatKind.Uuid => Guid.TryParseExact(value, "D", out _), + _ => false + }; + } + + /// + /// 判断字符串是否满足共享支持的 date 格式。 + /// 这里固定采用 yyyy-MM-dd,避免文化区域或宽松解析导致工具与运行时漂移。 + /// + /// 待校验的字符串值。 + /// 当前值是否是合法日期。 + private static bool MatchesSupportedDateFormat(string value) + { + return SupportedDateFormatRegex.IsMatch(value) && + DateOnly.TryParseExact( + value, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _); + } + + /// + /// 判断字符串是否满足共享支持的 date-time 格式。 + /// 该格式固定要求显式时区偏移,避免消费者误把本地时区文本当成跨进程稳定配置。 + /// + /// 待校验的字符串值。 + /// 当前值是否是合法时间戳。 + private static bool MatchesSupportedDateTimeFormat(string value) + { + if (!SupportedDateTimeFormatRegex.IsMatch(value)) + { + return false; + } + + return DateTimeOffset.TryParseExact( + value, + ["yyyy'-'MM'-'dd'T'HH':'mm':'ssK", "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK"], + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _); + } + + /// + /// 判断字符串是否满足共享支持的 uri 格式。 + /// 这里要求输入显式包含 scheme,避免把普通路径意外解释成平台相关的绝对 URI。 + /// + /// 待校验的字符串值。 + /// 当前值是否是合法绝对 URI。 + private static bool MatchesSupportedUriFormat(string value) + { + return SupportedUriSchemeRegex.IsMatch(value) && + Uri.TryCreate(value, UriKind.Absolute, out var uri) && + uri.IsAbsoluteUri; } /// @@ -3321,28 +3527,32 @@ internal sealed class YamlConfigNumericConstraints } /// -/// 表示标量节点上声明的字符串长度与模式约束。 -/// 该模型将正则原文与预编译正则绑定保存,保证诊断内容与运行时匹配逻辑保持一致。 +/// 表示标量节点上声明的字符串长度、模式与 format 约束。 +/// 该模型将正则原文、预编译正则和共享 format 枚举绑定保存, +/// 保证诊断内容与运行时匹配逻辑保持一致。 /// internal sealed class YamlConfigStringConstraints { /// - /// 初始化字符串约束模型。 - /// +/// 初始化字符串约束模型。 +/// /// 最小长度约束。 /// 最大长度约束。 /// 正则模式约束原文。 /// 已编译的正则表达式。 + /// 字符串 format 约束。 public YamlConfigStringConstraints( int? minLength, int? maxLength, string? pattern, - Regex? patternRegex) + Regex? patternRegex, + YamlConfigStringFormatConstraint? formatConstraint) { MinLength = minLength; MaxLength = maxLength; Pattern = pattern; PatternRegex = patternRegex; + FormatConstraint = formatConstraint; } /// @@ -3364,6 +3574,74 @@ internal sealed class YamlConfigStringConstraints /// 获取已编译的正则表达式。 /// public Regex? PatternRegex { get; } + + /// + /// 获取字符串 format 约束。 + /// + public YamlConfigStringFormatConstraint? FormatConstraint { get; } +} + +/// +/// 表示一个已归一化的字符串 format 约束。 +/// 该模型同时保留 schema 原文与共享枚举,方便诊断信息稳定展示,又避免运行时校验反复解析字符串。 +/// +internal sealed class YamlConfigStringFormatConstraint +{ + /// + /// 初始化字符串 format 约束模型。 + /// + /// schema 中声明的 format 名称。 + /// 归一化后的共享 format 枚举。 + public YamlConfigStringFormatConstraint( + string schemaName, + YamlConfigStringFormatKind kind) + { + ArgumentException.ThrowIfNullOrWhiteSpace(schemaName); + + SchemaName = schemaName; + Kind = kind; + } + + /// + /// 获取 schema 中声明的 format 名称。 + /// + public string SchemaName { get; } + + /// + /// 获取归一化后的共享 format 枚举。 + /// + public YamlConfigStringFormatKind Kind { get; } +} + +/// +/// 表示当前 Runtime / Generator / Tooling 共享支持的字符串 format 子集。 +/// +internal enum YamlConfigStringFormatKind +{ + /// + /// 表示 yyyy-MM-dd 形式的日期。 + /// + Date, + + /// + /// 表示带显式时区偏移的 RFC 3339 日期时间。 + /// + DateTime, + + /// + /// 表示基础电子邮件地址格式。 + /// + Email, + + /// + /// 表示绝对 URI。 + /// + Uri, + + /// + /// 表示连字符分隔的 UUID 文本。 + /// + Uuid } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 268f3748..f34e7a49 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -91,6 +91,96 @@ public class SchemaConfigGeneratorTests Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: const = \"\".")); } + /// + /// 验证共享支持的字符串 format 会写入生成 XML 文档。 + /// + [Test] + public void Run_Should_Write_Supported_String_Format_Into_Generated_Documentation() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "contactEmail"], + "properties": { + "id": { "type": "integer" }, + "contactEmail": { + "type": "string", + "format": "email" + } + } + } + """; + + 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: format = 'email'.")); + } + + /// + /// 验证未纳入共享子集的字符串 format 会在生成阶段直接给出诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_String_Format_Is_Not_Supported() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "address"], + "properties": { + "id": { "type": "integer" }, + "address": { + "type": "string", + "format": "ipv4" + } + } + } + """; + + 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("address")); + Assert.That(diagnostic.GetMessage(), Does.Contain("ipv4")); + Assert.That(diagnostic.GetMessage(), Does.Contain("date-time")); + }); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md index f8af86be..df517406 100644 --- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -26,6 +26,7 @@ GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_007 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 078bd09d..14debdf2 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -8,7 +8,7 @@ namespace GFramework.SourceGenerators.Config; /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / const / ref-table 元数据。 /// 当前共享子集也会把 multipleOfuniqueItems、 /// contains / minContains / maxContains、 -/// minPropertiesmaxProperties 写入生成代码文档, +/// minPropertiesmaxProperties 与稳定字符串 format 子集写入生成代码文档, /// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// [Generator] @@ -25,6 +25,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator "The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index."; 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'"; /// public void Initialize(IncrementalGeneratorInitializationContext context) @@ -295,6 +296,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator var title = TryGetMetadataString(property.Value, "title"); var description = TryGetMetadataString(property.Value, "description"); var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table"); + if (!TryValidateStringFormatMetadata( + filePath, + displayPath, + property.Value, + schemaType, + out var formatDiagnostic)) + { + return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!); + } + var indexedLookupMetadata = TryGetMetadataBoolean(property.Value, LookupIndexMetadataKey); if (indexedLookupMetadata.Diagnostic is not null) { @@ -533,6 +544,84 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 验证字符串 format 元数据是否属于当前共享支持子集。 + /// 生成器不尝试解释开放格式名,而是直接在编译阶段拒绝三端无法稳定对齐的 schema。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 属性 schema 节点。 + /// 当前 schema type。 + /// 失败时返回的诊断。 + /// 当前节点的 format 元数据是否有效。 + private static bool TryValidateStringFormatMetadata( + string filePath, + string displayPath, + JsonElement element, + string schemaType, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("format", out var formatElement)) + { + return true; + } + + if (!string.Equals(schemaType, "string", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidStringFormatMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Only 'string' properties can declare 'format'."); + return false; + } + + if (formatElement.ValueKind != JsonValueKind.String) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidStringFormatMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "The 'format' value must be a string."); + return false; + } + + var formatName = formatElement.GetString() ?? string.Empty; + if (IsSupportedStringFormat(formatName)) + { + return true; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidStringFormatMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Unsupported string format '{formatName}'. Supported formats are {SupportedStringFormatNames}."); + return false; + } + + /// + /// 判断给定 format 名称是否属于当前共享支持子集。 + /// + /// schema 中声明的 format 名称。 + /// 是否支持该格式。 + private static bool IsSupportedStringFormat(string formatName) + { + return formatName switch + { + "date" => true, + "date-time" => true, + "email" => true, + "uri" => true, + "uuid" => true, + _ => false + }; + } + /// /// 解析数组属性,支持标量数组与对象数组。 /// @@ -577,6 +666,11 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } var itemType = itemTypeElement.GetString() ?? string.Empty; + if (!TryValidateStringFormatMetadata(filePath, $"{displayPath}[]", itemsElement, itemType, out var formatDiagnostic)) + { + return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!); + } + switch (itemType) { case "integer": @@ -2508,6 +2602,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"pattern = '{patternElement.GetString() ?? string.Empty}'"); } + if (schemaType == "string" && + element.TryGetProperty("format", out var formatElement) && + formatElement.ValueKind == JsonValueKind.String) + { + var formatName = formatElement.GetString() ?? string.Empty; + if (IsSupportedStringFormat(formatName)) + { + parts.Add($"format = '{formatName}'"); + } + } + if (schemaType == "array" && TryGetNonNegativeInt32(element, "minItems", out var minItems)) { diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index b3d6be65..3162bb6a 100644 --- a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -96,4 +96,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 字段的字符串 format 元数据无效。 + /// + public static readonly DiagnosticDescriptor InvalidStringFormatMetadata = new( + "GF_ConfigSchema_009", + "Config schema uses invalid string format metadata", + "Property '{1}' in schema file '{0}' uses invalid 'format' metadata: {2}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index fa5b520c..06492f01 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`、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` +- 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` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -714,6 +714,7 @@ var loader = new YamlConfigLoader("config-root") - 数值字段违反 `multipleOf` - 字符串字段违反 `minLength` / `maxLength` - 字符串字段违反 `pattern` +- 字符串字段违反 `format` - 数组字段违反 `minItems` / `maxItems` - 数组字段违反 `uniqueItems` - 数组字段违反 `contains` / `minContains` / `maxContains` @@ -772,6 +773,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `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 = ...` 说明 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 - `contains` / `minContains` / `maxContains`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”,其中仅声明 `contains` 时默认至少需要 1 个匹配元素 @@ -872,7 +874,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单入口中显示 `title / description / default / const / enum / ref-table / multipleOf / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 +- 在表单入口中显示 `title / description / default / const / enum / ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 77b39183..def29e11 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -9,6 +9,12 @@ const {ValidationMessageKeys} = require("./localizationKeys"); const IntegerScalarPattern = /^[+-]?\d+$/u; const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u; const BooleanScalarPattern = /^(true|false)$/iu; +const EmailFormatPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; +const UuidFormatPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; +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"]); /** * Compare two strings using the same UTF-16 code-unit ordering as C#'s @@ -32,7 +38,7 @@ function compareStringsOrdinal(left, right) { * runtime validator and source generator so tooling diagnostics stay aligned. * * @param {string} content Raw schema JSON text. - * @throws {Error} Thrown when the schema declares one unsupported or invalid pattern string. + * @throws {Error} Thrown when the schema declares one unsupported pattern or format string. * @returns {{ * type: "object", * required: string[], @@ -446,6 +452,39 @@ function normalizeSchemaPattern(value, displayPath) { } } +/** + * Normalize one schema string-format declaration into the shared supported subset. + * The tooling intentionally rejects unknown format names so editor diagnostics do + * not drift away from the runtime and source generator. + * + * @param {unknown} value Raw schema value. + * @param {string} schemaType Current schema type. + * @param {string} displayPath Logical property path used in diagnostics. + * @throws {Error} Thrown when the format value is invalid or unsupported for strings. + * @returns {string | undefined} Normalized format name. + */ +function normalizeSchemaStringFormat(value, schemaType, displayPath) { + if (schemaType !== "string") { + return undefined; + } + + if (value === undefined) { + return undefined; + } + + if (typeof value !== "string") { + throw new Error(`Schema property '${displayPath}' must declare 'format' as a string.`); + } + + if (SupportedStringFormats.has(value)) { + return value; + } + + throw new Error( + `Schema property '${displayPath}' declares unsupported string format '${value}'. ` + + "Supported formats are 'date', 'date-time', 'email', 'uri', and 'uuid'."); +} + /** * Convert a schema default value into a compact string that can be shown in UI * metadata hints. @@ -570,6 +609,124 @@ function matchesSchemaPattern(scalarValue, patternRegex) { return patternRegex.test(scalarValue); } +/** + * Test one scalar value against one shared string-format constraint. + * + * @param {string} scalarValue Scalar value from YAML. + * @param {string | undefined} formatName Normalized schema format name. + * @returns {boolean} True when compatible or no format is declared. + */ +function matchesSchemaStringFormat(scalarValue, formatName) { + if (typeof formatName !== "string") { + return true; + } + + switch (formatName) { + case "date": + return matchesSchemaDateFormat(scalarValue); + case "date-time": + return matchesSchemaDateTimeFormat(scalarValue); + case "email": + return EmailFormatPattern.test(scalarValue); + case "uri": + return matchesSchemaUriFormat(scalarValue); + case "uuid": + return UuidFormatPattern.test(scalarValue); + default: + return false; + } +} + +/** + * Validate one RFC 3339 full-date string. + * + * @param {string} scalarValue Scalar value from YAML. + * @returns {boolean} True when the value is a valid calendar date. + */ +function matchesSchemaDateFormat(scalarValue) { + const match = DateFormatPattern.exec(scalarValue); + if (!match || !match.groups) { + return false; + } + + const year = Number.parseInt(match.groups.year, 10); + const month = Number.parseInt(match.groups.month, 10); + const day = Number.parseInt(match.groups.day, 10); + return isValidCalendarDate(year, month, day); +} + +/** + * Validate one RFC 3339 date-time string with explicit timezone offset. + * + * @param {string} scalarValue Scalar value from YAML. + * @returns {boolean} True when the value is structurally and calendrically valid. + */ +function matchesSchemaDateTimeFormat(scalarValue) { + const match = DateTimeFormatPattern.exec(scalarValue); + if (!match || !match.groups) { + return false; + } + + const year = Number.parseInt(match.groups.year, 10); + const month = Number.parseInt(match.groups.month, 10); + const day = Number.parseInt(match.groups.day, 10); + if (!isValidCalendarDate(year, month, day)) { + 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. + * + * @param {string} scalarValue Scalar value from YAML. + * @returns {boolean} True when the value parses as an absolute URI. + */ +function matchesSchemaUriFormat(scalarValue) { + try { + const parsed = new URL(scalarValue); + return typeof parsed.protocol === "string" && parsed.protocol.length > 1; + } catch { + return false; + } +} + +/** + * Check whether one year-month-day triple forms a valid calendar date. + * + * @param {number} year Year component. + * @param {number} month Month component. + * @param {number} day Day component. + * @returns {boolean} True when the date exists in the Gregorian calendar. + */ +function isValidCalendarDate(year, month, day) { + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + return false; + } + + if (month < 1 || month > 12 || day < 1) { + return false; + } + + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return day <= lastDay; +} + /** * Build one schema-normalized comparable key for a const value declared in * JSON Schema so tooling comparisons align with runtime comparisons. @@ -845,6 +1002,7 @@ function parseSchemaNode(rawNode, displayPath) { const value = rawNode && typeof rawNode === "object" ? rawNode : {}; const type = typeof value.type === "string" ? value.type : "object"; const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); + const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath); const metadata = { title: typeof value.title === "string" ? value.title : undefined, description: typeof value.description === "string" ? value.description : undefined, @@ -858,6 +1016,7 @@ function parseSchemaNode(rawNode, displayPath) { maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), pattern: patternMetadata ? patternMetadata.source : undefined, patternRegex: patternMetadata ? patternMetadata.regex : undefined, + format: stringFormat, minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), minContains: normalizeSchemaNonNegativeInteger(value.minContains), @@ -969,6 +1128,9 @@ function parseSchemaNode(rawNode, displayPath) { patternRegex: type === "string" ? metadata.patternRegex : undefined, + format: type === "string" + ? metadata.format + : undefined, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }, value.const, displayPath); @@ -1238,6 +1400,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + if (supportsPatternConstraints && + !matchesSchemaStringFormat(scalarValue, schemaNode.format)) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.formatViolation, localizer, { + displayPath, + value: schemaNode.format + }) + }); + } + validateConstComparableValue(schemaNode, yamlNode, displayPath, diagnostics, localizer); } @@ -1507,6 +1680,11 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) { return false; } + if (supportsPatternConstraints && + !matchesSchemaStringFormat(scalarValue, schemaNode.format)) { + return false; + } + return typeof schemaNode.constComparableValue !== "string" || buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; } @@ -1696,6 +1874,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`; case ValidationMessageKeys.expectedScalarValue: return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; + case ValidationMessageKeys.formatViolation: + return `属性“${params.displayPath}”必须满足字符串格式“${params.value}”。`; case ValidationMessageKeys.enumMismatch: return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; case ValidationMessageKeys.exclusiveMaximumViolation: @@ -1742,6 +1922,8 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`; case ValidationMessageKeys.expectedScalarValue: return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; + case ValidationMessageKeys.formatViolation: + return `Property '${params.displayPath}' must satisfy string format '${params.value}'.`; case ValidationMessageKeys.enumMismatch: return `Property '${params.displayPath}' must be one of: ${params.values}.`; case ValidationMessageKeys.exclusiveMaximumViolation: @@ -2513,6 +2695,7 @@ module.exports = { * maxLength?: number, * pattern?: string, * patternRegex?: RegExp, + * format?: string, * enumValues?: string[], * refTable?: string * }} SchemaNode diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index e263612d..d750b04d 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1578,7 +1578,7 @@ function getScalarArrayValue(yamlNode) { /** * Render human-facing metadata hints for one schema field. * - * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @param {boolean} includeDescription Whether description text should be included in the hint output. * @returns {string} HTML fragment. @@ -1641,6 +1641,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern}))); } + if (!isArrayField && propertySchema.format) { + hints.push(escapeHtml(localizer.t("webview.hint.format", {value: propertySchema.format}))); + } + if (propertySchema.type === "object" && typeof propertySchema.minProperties === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minProperties", {value: propertySchema.minProperties}))); } @@ -1710,6 +1714,10 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true hints.push(escapeHtml(localizer.t("webview.hint.itemPattern", {value: propertySchema.items.pattern}))); } + if (isArrayField && propertySchema.items && propertySchema.items.format) { + hints.push(escapeHtml(localizer.t("webview.hint.itemFormat", {value: propertySchema.items.format}))); + } + if (propertySchema.refTable) { hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 5213e6db..e55e4239 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -114,6 +114,7 @@ const enMessages = { "webview.hint.minLength": "Min length: {value}", "webview.hint.maxLength": "Max length: {value}", "webview.hint.pattern": "Pattern: {value}", + "webview.hint.format": "Format: {value}", "webview.hint.minItems": "Min items: {value}", "webview.hint.maxItems": "Max items: {value}", "webview.hint.contains": "Contains: {summary}", @@ -129,6 +130,7 @@ const enMessages = { "webview.hint.itemMinLength": "Item min length: {value}", "webview.hint.itemMaxLength": "Item max length: {value}", "webview.hint.itemPattern": "Item pattern: {value}", + "webview.hint.itemFormat": "Item format: {value}", "webview.hint.minProperties": "Min properties: {value}", "webview.hint.maxProperties": "Max properties: {value}", "webview.hint.refTable": "Ref table: {refTable}", @@ -156,6 +158,7 @@ const enMessages = { [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", [ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.", [ValidationMessageKeys.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.", + [ValidationMessageKeys.formatViolation]: "Property '{displayPath}' must satisfy string format '{value}'.", [ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.", [ValidationMessageKeys.unknownProperty]: "Property '{displayPath}' is not declared in the matching schema." }; @@ -229,6 +232,7 @@ const zhCnMessages = { "webview.hint.minLength": "最小长度:{value}", "webview.hint.maxLength": "最大长度:{value}", "webview.hint.pattern": "正则模式:{value}", + "webview.hint.format": "格式:{value}", "webview.hint.minItems": "最少元素数:{value}", "webview.hint.maxItems": "最多元素数:{value}", "webview.hint.contains": "Contains 约束:{summary}", @@ -244,6 +248,7 @@ const zhCnMessages = { "webview.hint.itemMinLength": "元素最小长度:{value}", "webview.hint.itemMaxLength": "元素最大长度:{value}", "webview.hint.itemPattern": "元素正则模式:{value}", + "webview.hint.itemFormat": "元素格式:{value}", "webview.hint.minProperties": "最少属性数:{value}", "webview.hint.maxProperties": "最多属性数:{value}", "webview.hint.refTable": "引用表:{refTable}", @@ -271,6 +276,7 @@ const zhCnMessages = { [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", [ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。", [ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。", + [ValidationMessageKeys.formatViolation]: "属性“{displayPath}”必须满足字符串格式“{value}”。", [ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。", [ValidationMessageKeys.unknownProperty]: "属性“{displayPath}”未在匹配的 schema 中声明。" }; diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 68df294c..3551b173 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -7,6 +7,7 @@ const ValidationMessageKeys = Object.freeze({ expectedObject: "validation.expectedObject", expectedScalarShape: "validation.expectedScalarShape", expectedScalarValue: "validation.expectedScalarValue", + formatViolation: "validation.formatViolation", maximumViolation: "validation.maximumViolation", maxContainsViolation: "validation.maxContainsViolation", maxItemsViolation: "validation.maxItemsViolation", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 92ff0b1e..a291bd2d 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -658,6 +658,91 @@ tags: assert.match(diagnostics[2].message, /at least 2 items|至少需要包含 2 个元素/u); }); +test("validateParsedConfig should enforce supported string formats", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "releaseDate": { + "type": "string", + "format": "date" + }, + "publishedAt": { + "type": "string", + "format": "date-time" + }, + "contactEmail": { + "type": "string", + "format": "email" + }, + "catalogUri": { + "type": "string", + "format": "uri" + }, + "configId": { + "type": "string", + "format": "uuid" + } + } + } + `); + const yaml = parseTopLevelYaml(` +releaseDate: 2026-02-30 +publishedAt: 2026-04-11T08:30:00 +contactEmail: boss.example.com +catalogUri: /loot-table +configId: 123e4567e89b12d3a456426614174000 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 5); + assert.match(diagnostics[0].message, /format 'date'|字符串格式“date”/u); + assert.match(diagnostics[1].message, /format 'date-time'|字符串格式“date-time”/u); + assert.match(diagnostics[2].message, /format 'email'|字符串格式“email”/u); + assert.match(diagnostics[3].message, /format 'uri'|字符串格式“uri”/u); + assert.match(diagnostics[4].message, /format 'uuid'|字符串格式“uuid”/u); +}); + +test("validateParsedConfig should accept supported string formats", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "releaseDate": { + "type": "string", + "format": "date" + }, + "publishedAt": { + "type": "string", + "format": "date-time" + }, + "contactEmail": { + "type": "string", + "format": "email" + }, + "catalogUri": { + "type": "string", + "format": "uri" + }, + "configId": { + "type": "string", + "format": "uuid" + } + } + } + `); + const yaml = parseTopLevelYaml(` +releaseDate: 2026-04-11 +publishedAt: 2026-04-11T08:30:00Z +contactEmail: boss@example.com +catalogUri: https://example.com/loot-table +configId: 123e4567-e89b-12d3-a456-426614174000 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + test("validateParsedConfig should report exclusive maximum and maxItems violations", () => { const schema = parseSchemaContent(` { @@ -1302,6 +1387,30 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$"); }); +test("parseSchemaContent should capture supported string format metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "contactEmail": { + "type": "string", + "format": "email" + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + `); + + assert.equal(schema.properties.contactEmail.format, "email"); + assert.equal(schema.properties.aliases.items.format, "uuid"); +}); + test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () => { const schema = parseSchemaContent(` { @@ -1504,6 +1613,23 @@ test("parseSchemaContent should reject invalid pattern declarations instead of d ); }); +test("parseSchemaContent should reject unsupported string format declarations", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "format": "ipv4" + } + } + } + `), + /unsupported string format 'ipv4'/u + ); +}); + test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => { const schema = parseSchemaContent(` {