feat(config): 添加配置验证功能模块

- 实现配置架构解析器,支持JSON架构到递归树的转换
- 添加YAML解析器,支持根映射、嵌套对象和数组结构
- 集成配置验证诊断系统,提供架构和YAML内容校验
- 实现批量编辑器字段提取,支持标量类型安全更新
- 添加YAML注释提取功能,映射到逻辑字段路径
- 创建示例配置YAML生成功能,包含架构描述作为注释
- 实现表单更新应用到YAML功能,重写YAML树结构
- 添加标量兼容性检查,支持整数、数字、布尔值和字符串类型
- 实现精确十进制算术运算,用于multipleOf约束验证
- 添加模式匹配验证,支持正则表达式编译和测试
- 实现常量值比较功能,保持与运行时一致的比较格式
- 集成多语言本地化支持,提供中英文验证消息
This commit is contained in:
GeWuYou 2026-04-10 20:30:04 +08:00
parent b0e8b6ecc5
commit 19088fed03
3 changed files with 208 additions and 6 deletions

View File

@ -1023,7 +1023,8 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
} }
const comparableItems = []; const comparableItems = [];
let hasInvalidArrayItems = false; const containsCandidateItems = [];
let hasStructurallyInvalidArrayItems = false;
for (let index = 0; index < yamlNode.items.length; index += 1) { for (let index = 0; index < yamlNode.items.length; index += 1) {
const diagnosticsBeforeValidation = diagnostics.length; const diagnosticsBeforeValidation = diagnostics.length;
validateNode( validateNode(
@ -1033,12 +1034,16 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
diagnostics, diagnostics,
localizer); localizer);
if (isStructurallyCompatibleWithSchemaNode(schemaNode.items, yamlNode.items[index])) {
containsCandidateItems.push({index, node: yamlNode.items[index]});
} else {
hasStructurallyInvalidArrayItems = true;
}
// Keep uniqueItems focused on values that are otherwise valid so a // Keep uniqueItems focused on values that are otherwise valid so a
// shape/type error does not also surface as a misleading duplicate. // shape/type or constraint error does not also surface as a misleading duplicate.
if (diagnostics.length === diagnosticsBeforeValidation) { if (diagnostics.length === diagnosticsBeforeValidation) {
comparableItems.push({index, node: yamlNode.items[index]}); comparableItems.push({index, node: yamlNode.items[index]});
} else {
hasInvalidArrayItems = true;
} }
} }
@ -1061,9 +1066,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
} }
} }
if (!hasInvalidArrayItems && schemaNode.contains) { if (!hasStructurallyInvalidArrayItems && schemaNode.contains) {
let matchingContainsCount = 0; let matchingContainsCount = 0;
for (const {node} of comparableItems) { for (const {node} of containsCandidateItems) {
if (matchesSchemaNode(schemaNode.contains, node)) { if (matchesSchemaNode(schemaNode.contains, node)) {
matchingContainsCount += 1; matchingContainsCount += 1;
} }
@ -1506,6 +1511,60 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode) {
buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue; buildComparableNodeValue(schemaNode, yamlNode) === schemaNode.constComparableValue;
} }
/**
* Test whether one YAML node is structurally compatible with one schema node.
* This keeps array-level `contains` validation from producing noisy follow-on
* diagnostics when an item already has a shape or scalar-type mismatch, while
* still allowing value-level constraint failures to participate in contains counting.
*
* @param {SchemaNode} schemaNode Schema node.
* @param {YamlNode} yamlNode YAML node.
* @returns {boolean} True when the YAML node has the expected recursive shape.
*/
function isStructurallyCompatibleWithSchemaNode(schemaNode, yamlNode) {
if (schemaNode.type === "object") {
if (!yamlNode || yamlNode.kind !== "object") {
return false;
}
for (const requiredProperty of schemaNode.required) {
if (!yamlNode.map.has(requiredProperty)) {
return false;
}
}
for (const entry of yamlNode.entries) {
if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) {
return false;
}
if (!isStructurallyCompatibleWithSchemaNode(schemaNode.properties[entry.key], entry.node)) {
return false;
}
}
return true;
}
if (schemaNode.type === "array") {
if (!yamlNode || yamlNode.kind !== "array") {
return false;
}
for (const item of yamlNode.items) {
if (!isStructurallyCompatibleWithSchemaNode(schemaNode.items, item)) {
return false;
}
}
return true;
}
return Boolean(yamlNode) &&
yamlNode.kind === "scalar" &&
isScalarCompatible(schemaNode.type, yamlNode.value);
}
/** /**
* Validate one parsed YAML node against one normalized const comparable value. * Validate one parsed YAML node against one normalized const comparable value.
* The helper reuses the same comparable-key logic as uniqueItems so array order * The helper reuses the same comparable-key logic as uniqueItems so array order

View File

@ -831,6 +831,100 @@ dropRates:
assert.match(diagnostics[0].message, /at least 2 items matching the 'contains' schema|至少需要包含 2 个匹配 contains 条件的元素/u); assert.match(diagnostics[0].message, /at least 2 items matching the 'contains' schema|至少需要包含 2 个匹配 contains 条件的元素/u);
}); });
test("validateParsedConfig should skip contains match-count when items are structurally invalid", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"required": ["dropRates"],
"properties": {
"dropRates": {
"type": "array",
"minContains": 2,
"contains": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"const": "RARE"
}
}
},
"items": {
"type": "object",
"required": ["type", "value"],
"properties": {
"type": {
"type": "string"
},
"value": {
"type": "integer"
}
}
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
dropRates:
-
type: RARE
value: "not-a-number"
-
type: RARE
value: 10
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.ok(diagnostics.length > 0);
assert.match(
diagnostics[0].message,
/dropRates\[0\]\.value/u);
assert.match(
diagnostics[0].message,
/integer|整数/u);
assert.equal(
diagnostics.some((diagnostic) => /at least 2 items matching the 'contains' schema|至少需要包含 2 个匹配 contains 条件的元素/u.test(diagnostic.message)),
false);
assert.equal(
diagnostics.some((diagnostic) => /at most \d+ items matching the 'contains' schema|最多只能包含 \d+ 个匹配 contains 条件的元素/u.test(diagnostic.message)),
false);
});
test("validateParsedConfig should continue contains match-count when items only have value-level violations", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"minContains": 1,
"contains": {
"type": "integer",
"const": 7
},
"items": {
"type": "integer",
"minimum": 10
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
dropRates:
- 5
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 2);
assert.match(diagnostics[0].message, /greater than or equal to 10|大于或等于 10/u);
assert.match(diagnostics[1].message, /at least 1 items matching the 'contains' schema|至少需要包含 1 个匹配 contains 条件的元素/u);
});
test("validateParsedConfig should report maxContains violations", () => { test("validateParsedConfig should report maxContains violations", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {

View File

@ -44,3 +44,52 @@ test("buildContainsHintLines should include default minContains when schema omit
"Min contains: 1" "Min contains: 1"
]); ]);
}); });
test("buildContainsHintLines should use explicit minContains when provided", () => {
const localizer = createLocalizer("en");
const lines = buildContainsHintLines(
{
minContains: 2,
contains: {
type: "string",
constValue: "\"potion\"",
constDisplayValue: "\"potion\"",
refTable: "item"
}
},
localizer);
assert.deepEqual(lines, [
"Contains: string, Const: \"potion\", Ref table: item",
"Min contains: 2"
]);
});
test("describeContainsSchema should format enum-based contains schema in English", () => {
const localizer = createLocalizer("en");
const summary = describeContainsSchema(
{
type: "string",
enumValues: ["potion", "elixir"],
refTable: "item"
},
localizer);
assert.equal(summary, "string, Allowed: potion, elixir, Ref table: item");
});
test("describeContainsSchema should format pattern-based contains schema in Chinese", () => {
const localizer = createLocalizer("zh-cn");
const summary = describeContainsSchema(
{
type: "string",
pattern: "^potion-",
refTable: "item"
},
localizer);
assert.equal(summary, "string, 正则模式:^potion-, 引用表item");
});