diff --git a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md index 524f736b..cbfa6585 100644 --- a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md +++ b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md @@ -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 或缺失类型的坏形状被工具侧宽松吞掉 ## 下一步 diff --git a/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md b/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md index e0b075b0..7c7a1cb4 100644 --- a/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md +++ b/ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md @@ -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 示例或采用路径,而不是继续堆积边界清单 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 926c73c8..19a43b0b 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -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} 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. diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index fb3acb6b..056df511 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -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(`