From bba589a8538f072099710666c68bee041ea9f6e6 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Mon, 6 Apr 2026 21:19:49 +0800
Subject: [PATCH] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?=
=?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 CI/CD 工作流配置文件,集成代码质量检查、安全扫描和构建测试
- 详细介绍配置系统架构,包括 YAML 源文件、JSON Schema 结构描述和运行时只读查询
- 提供完整的目录结构推荐和 Schema/JSON 示例配置
- 包含项目接入模板,涵盖 csproj 配置、启动帮助器和运行时读取模板
- 说明运行时校验行为,支持必填字段、类型匹配、数值范围等校验规则
- 介绍开发期热重载功能,支持配置文件变更自动刷新
- 详述生成器接入约定,包括配置类型、表包装和注册辅助生成
- 提供 VS Code 工具使用指南,支持配置浏览、表单编辑和批量操作
- 说明当前系统限制和未来发展规划,明确适用场景
---
.github/workflows/ci.yml | 18 ++
.../Config/GameConfigBootstrapTests.cs | 87 +++++++
.../Config/YamlConfigLoaderTests.cs | 155 ++++++++++++-
GFramework.Game/Config/GameConfigBootstrap.cs | 216 +++++++++++++++---
.../Config/YamlConfigSchemaValidator.cs | 8 +-
docs/zh-CN/game/config-system.md | 2 +-
.../src/configValidation.js | 21 +-
.../test/configValidation.test.js | 54 +++++
8 files changed, 511 insertions(+), 50 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb3fb6b9..70a6b34c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -123,6 +123,24 @@ jobs:
# 恢复.NET本地工具
- name: Restore .NET tools
run: dotnet tool restore
+
+ - name: Setup Node.js 20
+ uses: actions/setup-node@v5
+ with:
+ node-version: 20
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.2.15
+
+ - name: Install config tool dependencies
+ working-directory: tools/gframework-config-tool
+ run: bun install
+
+ - name: Run config tool tests
+ working-directory: tools/gframework-config-tool
+ run: bun run test
# 构建项目,使用Release配置且跳过恢复步骤
- name: Build
diff --git a/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs
index a01d5ef3..56b00149 100644
--- a/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs
+++ b/GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Threading;
using System.Threading.Tasks;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
@@ -126,6 +127,92 @@ public class GameConfigBootstrapTests
Assert.That(exception!.ParamName, Is.EqualTo("ConfigureLoader"));
}
+ ///
+ /// 验证初始化链路进行中时,第二个调用者不会再次进入并发初始化流程。
+ ///
+ [Test]
+ public void InitializeAsync_Should_Reject_Concurrent_Caller_While_Initialization_Is_In_Progress()
+ {
+ CreateMonsterFiles();
+
+ using ManualResetEventSlim initializeEntered = new(false);
+ using ManualResetEventSlim continueInitialization = new(false);
+ using var bootstrap = new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = _rootPath,
+ ConfigureLoader = loader =>
+ {
+ initializeEntered.Set();
+ Assert.That(
+ continueInitialization.Wait(TimeSpan.FromSeconds(5)),
+ Is.True,
+ "The first initialization attempt did not resume within the expected timeout.");
+ loader.RegisterAllGeneratedConfigTables(
+ new GeneratedConfigRegistrationOptions
+ {
+ IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
+ });
+ }
+ });
+
+ var firstInitializeTask = Task.Run(() => bootstrap.InitializeAsync());
+
+ Assert.That(
+ initializeEntered.Wait(TimeSpan.FromSeconds(5)),
+ Is.True,
+ "The first initialization attempt did not reach the guarded lifecycle section.");
+
+ var secondCallerException = Assert.ThrowsAsync(async () => await bootstrap.InitializeAsync());
+
+ continueInitialization.Set();
+
+ Assert.DoesNotThrowAsync(async () => await firstInitializeTask);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(secondCallerException, Is.Not.Null);
+ Assert.That(secondCallerException!.Message, Does.Contain("only be initialized once"));
+ Assert.That(bootstrap.IsInitialized, Is.True);
+ });
+ }
+
+ ///
+ /// 验证在可选热重载启动失败时,不会提前公开加载器与初始化成功状态。
+ ///
+ [Test]
+ public void InitializeAsync_Should_Not_Publish_State_When_HotReload_Enable_Fails()
+ {
+ CreateMonsterFiles();
+
+ using var bootstrap = new GameConfigBootstrap(
+ new GameConfigBootstrapOptions
+ {
+ RootPath = _rootPath,
+ EnableHotReload = true,
+ HotReloadOptions = new YamlConfigHotReloadOptions
+ {
+ DebounceDelay = TimeSpan.FromMilliseconds(-1)
+ },
+ ConfigureLoader = static loader =>
+ loader.RegisterAllGeneratedConfigTables(
+ new GeneratedConfigRegistrationOptions
+ {
+ IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
+ })
+ });
+
+ var exception = Assert.ThrowsAsync(async () => await bootstrap.InitializeAsync());
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(bootstrap.IsInitialized, Is.False);
+ Assert.That(bootstrap.IsHotReloadEnabled, Is.False);
+ Assert.Throws(() => _ = bootstrap.Loader);
+ });
+ }
+
///
/// 创建一个使用生成聚合注册入口的官方启动帮助器。
///
diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
index 8254afe1..059d61aa 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -413,10 +413,10 @@ public class YamlConfigLoaderTests
}
///
- /// 验证开区间数值边界约束会在运行时被统一拒绝。
+ /// 验证数值命中开区间下界时会按 schema 在运行时被拒绝。
///
[Test]
- public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum_Or_Exclusive_Maximum()
+ public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum()
{
CreateConfigFile(
"monster/slime.yaml",
@@ -461,6 +461,55 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证数值命中开区间上界时会按 schema 在运行时被拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Maximum()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 100
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "hp"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": {
+ "type": "integer",
+ "exclusiveMinimum": 10,
+ "exclusiveMaximum": 100
+ }
+ }
+ }
+ """);
+
+ 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("hp"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("100"));
+ Assert.That(exception.Message, Does.Contain("less than 100"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
///
@@ -560,10 +609,56 @@ public class YamlConfigLoaderTests
}
///
- /// 验证数组元素数量约束会在运行时被统一拒绝。
+ /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。
///
[Test]
- public void LoadAsync_Should_Throw_When_Array_Violates_MinItems_Or_MaxItems()
+ public async Task LoadAsync_Should_Accept_Backreference_Pattern_When_Value_Matches()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: aa
+ hp: 10
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "hp"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": {
+ "type": "string",
+ "pattern": "^(a)\\1$"
+ },
+ "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("aa"));
+ });
+ }
+
+ ///
+ /// 验证数组元素数量命中上界时会在运行时被统一拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Array_Violates_MaxItems()
{
CreateConfigFile(
"monster/slime.yaml",
@@ -615,6 +710,58 @@ public class YamlConfigLoaderTests
});
}
+ ///
+ /// 验证数组元素数量命中下界时会在运行时被统一拒绝。
+ ///
+ [Test]
+ public void LoadAsync_Should_Throw_When_Array_Violates_MinItems()
+ {
+ CreateConfigFile(
+ "monster/slime.yaml",
+ """
+ id: 1
+ name: Slime
+ dropRates: []
+ """);
+ CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "dropRates"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "dropRates": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 3,
+ "items": {
+ "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("dropRates"));
+ Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("0"));
+ Assert.That(exception.Message, Does.Contain("at least 1 items"));
+ Assert.That(registry.Count, Is.EqualTo(0));
+ });
+ }
+
///
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
///
diff --git a/GFramework.Game/Config/GameConfigBootstrap.cs b/GFramework.Game/Config/GameConfigBootstrap.cs
index 5ab37571..242002be 100644
--- a/GFramework.Game/Config/GameConfigBootstrap.cs
+++ b/GFramework.Game/Config/GameConfigBootstrap.cs
@@ -8,15 +8,25 @@ namespace GFramework.Game.Config;
/// 该类型负责把配置注册表、YAML 加载器与开发期热重载句柄收敛到一个长生命周期对象中,
/// 让消费者项目可以通过一个稳定入口完成配置启动,而不是在多个脚本里重复拼装运行时细节。
///
+///
+/// 生命周期转换会串行化执行,因此并发调用只会观察到已经提交完成的加载器与热重载句柄。
+/// 如果初始化或热重载启动在中途失败,当前实例会保留失败前的稳定状态,而不会公开半初始化对象。
+///
public sealed class GameConfigBootstrap : IDisposable
{
private const string ConfigureLoaderCannotBeNullMessage = "ConfigureLoader must be provided.";
private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace.";
+ // All lifecycle transitions share one gate so initialization, hot-reload startup,
+ // stop, and disposal never publish half-finished state to concurrent callers.
+ private readonly object _stateGate = new();
private readonly GameConfigBootstrapOptions _options;
private IUnRegister? _hotReload;
private YamlConfigLoader? _loader;
private bool _disposed;
+ private bool _isInitializing;
+ private bool _isStartingHotReload;
+ private bool _stopHotReloadAfterStart;
///
/// 使用指定选项创建配置启动帮助器。
@@ -35,14 +45,14 @@ public sealed class GameConfigBootstrap : IDisposable
{
throw new ArgumentException(
RootPathCannotBeNullOrWhiteSpaceMessage,
- nameof(options.RootPath));
+ nameof(options));
}
if (options.ConfigureLoader == null)
{
throw new ArgumentException(
ConfigureLoaderCannotBeNullMessage,
- nameof(options.ConfigureLoader));
+ nameof(options));
}
_options = options;
@@ -63,13 +73,34 @@ public sealed class GameConfigBootstrap : IDisposable
///
/// 获取一个值,指示启动帮助器是否已经成功完成初次加载。
+ /// 该状态只会在完整初始化链路(包括可选热重载启动)成功后才对外可见,
+ /// 避免并发调用观察到半初始化生命周期。
///
- public bool IsInitialized => _loader != null;
+ public bool IsInitialized
+ {
+ get
+ {
+ lock (_stateGate)
+ {
+ return _loader != null;
+ }
+ }
+ }
///
/// 获取一个值,指示开发期热重载是否已启用。
+ /// 只有当监听句柄已经成功创建并提交到当前生命周期后,该属性才会返回 。
///
- public bool IsHotReloadEnabled => _hotReload != null;
+ public bool IsHotReloadEnabled
+ {
+ get
+ {
+ lock (_stateGate)
+ {
+ return _hotReload != null;
+ }
+ }
+ }
///
/// 获取当前生效的 YAML 配置加载器。
@@ -81,10 +112,13 @@ public sealed class GameConfigBootstrap : IDisposable
{
get
{
- ThrowIfDisposed();
+ lock (_stateGate)
+ {
+ ThrowIfDisposedCore();
- return _loader ?? throw new InvalidOperationException(
- "The config bootstrap has not been initialized yet.");
+ return _loader ?? throw new InvalidOperationException(
+ "The config bootstrap has not been initialized yet.");
+ }
}
}
@@ -99,24 +133,59 @@ public sealed class GameConfigBootstrap : IDisposable
/// 当配置加载失败时抛出。
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
- ThrowIfDisposed();
-
- if (_loader != null)
+ lock (_stateGate)
{
- throw new InvalidOperationException(
- "The config bootstrap can only be initialized once per instance.");
+ ThrowIfDisposedCore();
+
+ if (_isInitializing || _loader != null)
+ {
+ throw new InvalidOperationException(
+ "The config bootstrap can only be initialized once per instance.");
+ }
+
+ _isInitializing = true;
}
- var loader = new YamlConfigLoader(RootPath);
- _options.ConfigureLoader!(loader);
- await loader.LoadAsync(Registry, cancellationToken);
+ IUnRegister? hotReload = null;
- // 仅在初次加载完全成功后才公开加载器实例,避免上层观察到半初始化状态。
- _loader = loader;
-
- if (_options.EnableHotReload)
+ try
{
- StartHotReload(_options.HotReloadOptions);
+ var loader = new YamlConfigLoader(RootPath);
+ _options.ConfigureLoader!(loader);
+ await loader.LoadAsync(Registry, cancellationToken);
+
+ if (_options.EnableHotReload)
+ {
+ hotReload = loader.EnableHotReload(Registry, _options.HotReloadOptions);
+ }
+
+ lock (_stateGate)
+ {
+ try
+ {
+ ThrowIfDisposedCore();
+
+ // 仅在初次加载与可选热重载都完整成功后才提交结果,
+ // 避免 IsInitialized / Loader 暴露半初始化生命周期。
+ _loader = loader;
+ _hotReload = hotReload;
+ hotReload = null;
+ }
+ finally
+ {
+ _isInitializing = false;
+ }
+ }
+ }
+ catch
+ {
+ lock (_stateGate)
+ {
+ _isInitializing = false;
+ }
+
+ hotReload?.UnRegister();
+ throw;
}
}
@@ -135,17 +204,70 @@ public sealed class GameConfigBootstrap : IDisposable
///
public void StartHotReload(YamlConfigHotReloadOptions? options = null)
{
- ThrowIfDisposed();
-
- var loader = _loader ?? throw new InvalidOperationException(
- "Hot reload can only be started after the initial config load succeeds.");
-
- if (_hotReload != null)
+ YamlConfigLoader loader;
+ lock (_stateGate)
{
- throw new InvalidOperationException("Hot reload is already enabled.");
+ ThrowIfDisposedCore();
+
+ loader = _loader ?? throw new InvalidOperationException(
+ "Hot reload can only be started after the initial config load succeeds.");
+
+ if (_isStartingHotReload || _hotReload != null)
+ {
+ throw new InvalidOperationException("Hot reload is already enabled.");
+ }
+
+ _isStartingHotReload = true;
+ _stopHotReloadAfterStart = false;
}
- _hotReload = loader.EnableHotReload(Registry, options);
+ IUnRegister? hotReload = null;
+ try
+ {
+ hotReload = loader.EnableHotReload(Registry, options);
+
+ var shouldStop = false;
+ lock (_stateGate)
+ {
+ try
+ {
+ ThrowIfDisposedCore();
+
+ // Stop/Dispose may arrive while the watcher is being created. In that
+ // case, release the new handle immediately instead of publishing it.
+ if (_stopHotReloadAfterStart)
+ {
+ shouldStop = true;
+ _stopHotReloadAfterStart = false;
+ }
+ else
+ {
+ _hotReload = hotReload;
+ hotReload = null;
+ }
+ }
+ finally
+ {
+ _isStartingHotReload = false;
+ }
+ }
+
+ if (shouldStop)
+ {
+ hotReload?.UnRegister();
+ }
+ }
+ catch
+ {
+ lock (_stateGate)
+ {
+ _isStartingHotReload = false;
+ _stopHotReloadAfterStart = false;
+ }
+
+ hotReload?.UnRegister();
+ throw;
+ }
}
///
@@ -154,8 +276,19 @@ public sealed class GameConfigBootstrap : IDisposable
///
public void StopHotReload()
{
- var hotReload = _hotReload;
- _hotReload = null;
+ IUnRegister? hotReload;
+ lock (_stateGate)
+ {
+ if (_isStartingHotReload && _hotReload == null)
+ {
+ _stopHotReloadAfterStart = true;
+ return;
+ }
+
+ hotReload = _hotReload;
+ _hotReload = null;
+ }
+
hotReload?.UnRegister();
}
@@ -164,16 +297,29 @@ public sealed class GameConfigBootstrap : IDisposable
///
public void Dispose()
{
- if (_disposed)
+ IUnRegister? hotReload;
+ lock (_stateGate)
{
- return;
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ if (_isStartingHotReload && _hotReload == null)
+ {
+ _stopHotReloadAfterStart = true;
+ }
+
+ hotReload = _hotReload;
+ _hotReload = null;
}
- _disposed = true;
- StopHotReload();
+ hotReload?.UnRegister();
}
- private void ThrowIfDisposed()
+ private void ThrowIfDisposedCore()
{
if (_disposed)
{
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 98f670b3..f435c10a 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -10,6 +10,10 @@ namespace GFramework.Game.Config;
///
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;
+
///
/// 从磁盘加载并解析一个 JSON Schema 文件。
///
@@ -828,7 +832,7 @@ internal static class YamlConfigSchemaValidator
? null
: new Regex(
pattern,
- RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture));
+ SupportedPatternRegexOptions));
}
///
@@ -1005,7 +1009,7 @@ internal static class YamlConfigSchemaValidator
var pattern = patternElement.GetString() ?? string.Empty;
try
{
- _ = new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
+ _ = new Regex(pattern, SupportedPatternRegexOptions);
}
catch (ArgumentException exception)
{
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index f28b4f13..982ab32c 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -607,7 +607,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
-- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
+- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错
- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js
index 2d563723..e80a559e 100644
--- a/tools/gframework-config-tool/src/configValidation.js
+++ b/tools/gframework-config-tool/src/configValidation.js
@@ -12,6 +12,7 @@ const {ValidationMessageKeys} = require("./localizationKeys");
* 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.
* @returns {{
* type: "object",
* required: string[],
@@ -384,9 +385,11 @@ function normalizeSchemaNonNegativeInteger(value) {
* compiled by the local tooling runtime.
*
* @param {unknown} value Raw schema value.
+ * @param {string} displayPath Logical property path used in diagnostics.
+ * @throws {Error} Thrown when the pattern string cannot be compiled.
* @returns {string | undefined} Normalized pattern string.
*/
-function normalizeSchemaPattern(value) {
+function normalizeSchemaPattern(value, displayPath) {
if (typeof value !== "string") {
return undefined;
}
@@ -394,8 +397,8 @@ function normalizeSchemaPattern(value) {
try {
void new RegExp(value);
return value;
- } catch {
- return undefined;
+ } catch (error) {
+ throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
}
}
@@ -435,17 +438,19 @@ function formatSchemaDefaultValue(value) {
*
* @param {string} scalarValue Scalar value from YAML.
* @param {string | undefined} pattern Schema pattern string.
+ * @param {string} displayPath Logical property path used in diagnostics.
+ * @throws {Error} Thrown when the pattern string cannot be compiled.
* @returns {boolean} True when the value matches or no pattern is declared.
*/
-function matchesSchemaPattern(scalarValue, pattern) {
+function matchesSchemaPattern(scalarValue, pattern, displayPath) {
if (typeof pattern !== "string") {
return true;
}
try {
return new RegExp(pattern).test(scalarValue);
- } catch {
- return true;
+ } catch (error) {
+ throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`);
}
}
@@ -502,7 +507,7 @@ function parseSchemaNode(rawNode, displayPath) {
exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum),
minLength: normalizeSchemaNonNegativeInteger(value.minLength),
maxLength: normalizeSchemaNonNegativeInteger(value.maxLength),
- pattern: normalizeSchemaPattern(value.pattern),
+ pattern: normalizeSchemaPattern(value.pattern, displayPath),
minItems: normalizeSchemaNonNegativeInteger(value.minItems),
maxItems: normalizeSchemaNonNegativeInteger(value.maxItems),
refTable: typeof value["x-gframework-ref-table"] === "string"
@@ -749,7 +754,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}
if (supportsPatternConstraints &&
- !matchesSchemaPattern(scalarValue, schemaNode.pattern)) {
+ !matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) {
diagnostics.push({
severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, {
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index e35c16de..0c3ebaab 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -271,6 +271,43 @@ tags:
assert.match(diagnostics[2].message, /at least 2 items|至少需要包含 2 个元素/u);
});
+test("validateParsedConfig should report exclusive maximum and maxItems violations", () => {
+ const schema = parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "hp": {
+ "type": "integer",
+ "exclusiveMinimum": 10,
+ "exclusiveMaximum": 20
+ },
+ "tags": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 3,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ `);
+ const yaml = parseTopLevelYaml(`
+hp: 20
+tags:
+ - a
+ - b
+ - c
+ - d
+`);
+
+ const diagnostics = validateParsedConfig(schema, yaml);
+
+ assert.equal(diagnostics.length, 2);
+ assert.match(diagnostics[0].message, /less than 20|小于 20/u);
+ assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u);
+});
+
test("parseSchemaContent should capture scalar range and length metadata", () => {
const schema = parseSchemaContent(`
{
@@ -341,6 +378,23 @@ test("parseSchemaContent should capture exclusive bounds, pattern, and array ite
assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$");
});
+test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => {
+ assert.throws(
+ () => parseSchemaContent(`
+ {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "["
+ }
+ }
+ }
+ `),
+ /invalid 'pattern' regular expression/u
+ );
+});
+
test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => {
const schema = parseSchemaContent(`
{