diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs
new file mode 100644
index 00000000..7d58b0db
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs
@@ -0,0 +1,376 @@
+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 = 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.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 = 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("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 = 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("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 = 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).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 = 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.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 回归测试的最小配置类型。
+ ///
+ 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 565ef1e9..e23217e9 100644
--- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
@@ -1085,103 +1085,6 @@ public class YamlConfigLoaderTests
});
}
- ///
- /// 验证运行时会拒绝命中 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 = 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.Message, Does.Contain("must not match the 'not' schema"));
- Assert.That(registry.Count, Is.EqualTo(0));
- });
- }
-
- ///
- /// 验证 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 = 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.Message, Does.Contain("must declare 'not' as an object-valued schema"));
- Assert.That(registry.Count, Is.EqualTo(0));
- });
- }
-
///
/// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。
///
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index 58d6b4b6..84a1cef6 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -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;
@@ -693,17 +704,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return false;
}
- if (element.TryGetProperty("not", out var notElement) &&
- notElement.ValueKind == JsonValueKind.Object &&
- !TryValidateStringFormatMetadataRecursively(
- filePath,
- $"{displayPath}[not]",
- notElement,
- out diagnostic))
- {
- return false;
- }
-
return true;
}
diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js
index 39837c95..38eb14ec 100644
--- a/tools/gframework-config-tool/test/configValidation.test.js
+++ b/tools/gframework-config-tool/test/configValidation.test.js
@@ -1856,6 +1856,39 @@ reward:
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(
[