fix(config-tool): 收紧坏形状 schema 解析边界

- 修复 Tooling 侧 additionalProperties 仅接受 false 的共享边界校验

- 补充数组 items 与 contains 子 schema 必须显式声明 type 的拒绝逻辑

- 更新 ai-plan 恢复摘要与 JS 回归测试验证记录
This commit is contained in:
gewuyou 2026-04-30 11:54:55 +08:00 committed by GeWuYou
parent e8cceac7ae
commit fdcb11c92c
4 changed files with 175 additions and 4 deletions

View File

@ -96,6 +96,10 @@
- `2026-04-30` Tooling / Docs reader-facing 收口:
- `git diff --check -- docs/zh-CN/game/config-tool.md docs/zh-CN/game/config-system.md tools/gframework-config-tool/README.md ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md`:通过
- 已补齐工具能力边界、`additionalProperties: false` / `oneOf` / `anyOf` 说明、工具与 Runtime 契约关系,以及复杂 shape 的 raw YAML 回退路径
- `2026-04-30` Tooling parser 边界收紧:
- `bun run test``tools/gframework-config-tool`通过4 test files
- 已让工具侧对 `additionalProperties` 的共享边界与 Runtime / Generator 对齐,只接受 `additionalProperties: false`
- 已让数组 `items` / `contains` 子 schema 必须显式声明 object-shaped 且带 `type`,避免 tuple-array 或缺失类型的坏形状被工具侧宽松吞掉
## 下一步

View File

@ -189,3 +189,25 @@
1. Tooling / Docs 后续若继续推进,优先补真实采用示例,而不是重复扩写边界清单
2. 主线代码批次继续以 Runtime / Generator / Tooling 三端共享关键字收口为中心
## 2026-04-30
### 阶段Tooling parser 坏形状拒绝收紧AI-FIRST-CONFIG-RP-003
- 已在 `tools/gframework-config-tool/src/configValidation.js` 收紧工具侧 schema parser 边界
- 本轮不是扩 JSON Schema 能力,而是避免工具侧比 Runtime / Generator 更宽松:
- `additionalProperties` 现在只接受 `false`
- 数组 `items` 必须是 object-shaped 且显式带 `type`
- 数组 `contains` 若声明,也必须是 object-shaped 且显式带 `type`
- 这样 tuple-array `items: []`、缺失 `type``contains` 子 schema以及其他会误导用户以为“工具支持但运行时不支持”的坏形状会在工具解析阶段直接失败
### 验证
- 2026-04-30`bun run test``tools/gframework-config-tool`
- 结果:通过
- 备注:新增 JS 回归覆盖 `additionalProperties`、tuple-array `items` 与缺失 `type``contains`
### 下一步
1. 继续盘点 Runtime / Generator / Tooling 三端是否还有类似“工具宽松吞掉、主线不支持”的 schema 形状
2. 若继续做 Tooling lane优先补 reader-facing 示例或采用路径,而不是继续堆积边界清单

View File

@ -1102,6 +1102,8 @@ function parseSchemaNode(rawNode, displayPath) {
"The current config schema subset does not support combinators that can change generated type shape.");
}
validateUnsupportedOpenObjectKeyword(value, displayPath);
const type = typeof value.type === "string" ? value.type : "object";
const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath);
@ -1175,15 +1177,19 @@ function parseSchemaNode(rawNode, displayPath) {
}
if (type === "array") {
const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath));
const containsNode = value.contains && typeof value.contains === "object"
? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath))
: undefined;
const itemNode = parseRequiredArrayChildSchema(value.items, displayPath, "items");
const containsNode = value.contains === undefined
? undefined
: parseOptionalArrayChildSchema(value.contains, displayPath, "contains");
if (!containsNode &&
(typeof metadata.minContains === "number" || typeof metadata.maxContains === "number")) {
throw new Error(`Schema property '${displayPath}' declares 'minContains' or 'maxContains' without 'contains'.`);
}
if (itemNode.type === "array") {
throw new Error(`Schema property '${displayPath}' uses unsupported nested array items.`);
}
if (containsNode && containsNode.type === "array") {
throw new Error(`Schema property '${displayPath}' uses unsupported nested array 'contains' schemas.`);
}
@ -1260,6 +1266,87 @@ function parseSchemaNode(rawNode, displayPath) {
}, value.const, displayPath), value.enum, displayPath);
}
/**
* Reject open-object keyword forms that would drift away from the Runtime and
* Source Generator contracts. The current shared subset keeps object fields
* closed and only accepts an explicit `additionalProperties: false` reminder.
*
* @param {Record<string, unknown>} schemaNode Raw schema object.
* @param {string} displayPath Logical property path.
*/
function validateUnsupportedOpenObjectKeyword(schemaNode, displayPath) {
if (!Object.prototype.hasOwnProperty.call(schemaNode, "additionalProperties")) {
return;
}
if (schemaNode.additionalProperties === false) {
return;
}
throw new Error(
`Schema property '${displayPath}' uses unsupported 'additionalProperties' metadata. ` +
"The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.");
}
/**
* Parse one required array child schema while keeping tooling errors aligned
* with the Runtime and Source Generator contracts.
*
* @param {unknown} rawChild Raw child schema node.
* @param {string} displayPath Logical parent array path.
* @param {"items" | "contains"} keywordName Child schema keyword.
* @returns {SchemaNode} Parsed child schema node.
*/
function parseRequiredArrayChildSchema(rawChild, displayPath, keywordName) {
const childNode = parseArrayChildSchema(rawChild, displayPath, keywordName);
if (childNode) {
return childNode;
}
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
}
/**
* Parse one optional array child schema when it is present.
*
* @param {unknown} rawChild Raw child schema node.
* @param {string} displayPath Logical parent array path.
* @param {"items" | "contains"} keywordName Child schema keyword.
* @returns {SchemaNode | undefined} Parsed child schema node.
*/
function parseOptionalArrayChildSchema(rawChild, displayPath, keywordName) {
const childNode = parseArrayChildSchema(rawChild, displayPath, keywordName);
if (childNode) {
return childNode;
}
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
}
/**
* Parse one array child schema only when it is object-shaped and explicitly
* typed. This avoids silently treating tuple arrays or malformed child
* schemas as empty object nodes.
*
* @param {unknown} rawChild Raw child schema node.
* @param {string} displayPath Logical parent array path.
* @param {"items" | "contains"} keywordName Child schema keyword.
* @returns {SchemaNode | undefined} Parsed child schema node.
*/
function parseArrayChildSchema(rawChild, displayPath, keywordName) {
if (!rawChild || typeof rawChild !== "object" || Array.isArray(rawChild)) {
return undefined;
}
if (typeof rawChild.type !== "string") {
return undefined;
}
return parseSchemaNode(rawChild, joinArrayTemplatePath(displayPath, keywordName));
}
/**
* Return the first combinator keyword that the current shared schema subset
* intentionally rejects to keep Runtime / Generator / Tooling behavior aligned.

View File

@ -206,6 +206,25 @@ test("parseSchemaContent should reject unsupported oneOf combinators", () => {
/unsupported combinator keyword 'oneOf'/u);
});
test("parseSchemaContent should reject unsupported additionalProperties forms", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"additionalProperties": true,
"properties": {
"itemCount": { "type": "integer" }
}
}
}
}
`),
/unsupported 'additionalProperties' metadata/u);
});
test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => {
const schema = parseSchemaContent(`
{
@ -1544,6 +1563,45 @@ test("parseSchemaContent should reject nested-array contains schemas", () => {
/unsupported nested array 'contains' schemas/u);
});
test("parseSchemaContent should reject array items without an explicit typed object schema", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"items": [
{ "type": "integer" }
]
}
}
}
`),
/must declare 'items' as an object-valued schema with an explicit 'type'/u);
});
test("parseSchemaContent should reject contains without an explicit typed object schema", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"contains": {
"const": 5
},
"items": {
"type": "integer"
}
}
}
}
`),
/must declare 'contains' as an object-valued schema with an explicit 'type'/u);
});
test("parseSchemaContent should reject minContains and maxContains without contains", () => {
assert.throws(
() => parseSchemaContent(`