diff --git a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md index 8b976e54..c63eb6a9 100644 --- a/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md +++ b/ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md @@ -6,6 +6,13 @@ 当前阶段不再把 VS Code 工具能力当作阻塞项;工具链只要不拖累 C# 首发可用版本即可。 +## 并行 Lane 约束 + +- `C# Runtime + Source Generator + Consumer DX` 仍是当前主线恢复点 +- Tooling / Docs 作为非阻塞并行 lane 单独推进,但每一批仍要和 Runtime / Generator 的共享关键字边界保持一致 +- active tracking / trace 只保留恢复点、验证与 lane 指针;复杂编辑器细节、宿主手工验证和文档批次安排统一写在本文件 +- public docs 只写消费者接入、限制和迁移边界;治理噪音、批次编排和 recovery 元数据继续留在 `ai-plan/**` + ## 当前状态 - [x] 单表注册辅助:`Register{Entity}Table()` @@ -60,6 +67,24 @@ - [ ] 继续扩插件的复杂表单能力 - 说明:这是可选项,不阻塞 C# 主线 +## Tooling / Docs 并行 Lane + +- [ ] Tooling:让 VS Code 表单支持更深层对象数组嵌套,减少 raw YAML 回退 + - 边界:不改变 Runtime / Generator 已定义的 schema 形状契约 + - 验证:优先补 JS 测试,其次再做真实 VS Code 宿主手工验证 + +- [ ] Tooling:为复杂结构提供比“顶层标量 / 标量数组”更强的批量编辑能力 + - 边界:只增强编辑体验,不反向要求 schema 扩展或新的生成类型形状 + - 验证:记录可观察的编辑路径和回退路径,而不是在 active 入口堆叠 UI 细节 + +- [ ] Tooling:在真实 VS Code 宿主中完成对象数组编辑与复杂 schema 的交互式手工验证 + - 边界:作为发布前增强项,不阻塞共享关键字主线 + - 验证:后续 batch 直接补记宿主验证结论与未覆盖场景 + +- [ ] Docs:在相关接入文档里补齐“工具能力是辅助层,不定义 Runtime 契约”的读者提示 + - 边界:只写 reader-facing 接入 guidance,不写批次、治理、风险台账 + - 验证:确认文档用语聚焦接入路径、能力边界和回退方案 + ## 暂缓 - [ ] 不追求完整 JSON Schema 全量支持 @@ -75,7 +100,7 @@ 1. 用 `GeneratedConfigCatalog` 继续补齐启动与诊断辅助 2. 补一条比 `Architecture.OnInitialize()` 更正式的模块化接入建议 - 当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步转到下一批仍不改变生成形状的组合关键字评估,或继续推进 VS Code 复杂编辑体验 + 当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步默认转到下一批仍不改变生成形状的组合关键字评估。若另开并行 batch,再从本文件的 Tooling / Docs lane 接手 ## 完成标准 @@ -94,6 +119,9 @@ - `tools/gframework-config-tool/src/configValidation.js` - `tools/gframework-config-tool/src/extension.js` - `docs/zh-CN/game/config-system.md` +- 若恢复的是 Tooling / Docs 并行 lane: + - 先回看本文件的 `Tooling / Docs 并行 Lane` + - 只把结果摘要回填到 active tracking / trace,避免把编辑器批次细节重新塞回默认入口 ### 恢复块 @@ -111,3 +139,4 @@ 1. 检查 `YamlConfigSchemaValidator.cs`、`SchemaConfigGenerator.cs`、`configValidation.js` 中当前已支持的关键字列表 2. 跳过 `oneOf` / `anyOf`,选择下一批仍不改变生成类型形状的共享关键字 3. 优先找不需要属性合并、联合分支生成或额外 UI 形状解释的关键字,而不是先回工具 UI + 4. 若主线批次暂不动代码,可并行开启 Tooling / Docs lane,但不要让其反向改写主线恢复点定义 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 b70f768d..b7e04e1b 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 @@ -14,7 +14,7 @@ - 已明确将 `oneOf` / `anyOf` 归类为当前不支持的组合关键字,并在 Runtime / Generator / Tooling 三端显式拒绝,避免静默接受导致形状漂移 - 已完成 PR #262 的 CodeRabbit follow-up,补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests - 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema - - 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线 + - Tooling / Docs 后续改为非阻塞并行 lane;active 入口只保留主线恢复点,把批处理细节下沉到 backlog 文件 ### 已知风险 @@ -26,8 +26,8 @@ - 缓解措施:`gframework-pr-review` 现已同时解析 latest review body,并输出 declared / parsed 数量以便快速识别解析缺口 - PR follow-up 残留风险:PR `#262` 最新 review thread 仍有少量 open comments,且 nitpick body 解析仍存在 declared / parsed 缺口 - 缓解措施:先以 latest unresolved thread 为准逐条本地核验;已确认并补齐运行时诊断路径与 `else without if` 回归测试,skill 现已补齐 `.py` nitpick 与 outside-diff comment 解析,剩余项只需等待本地修复推送后再复抓确认 -- 非阻塞项回退风险:将 VS Code 功能标为非阻塞但导致主线回退的风险 - - 缓解措施:C# 主线补齐新关键字时仍需在 `configValidation.js` 与 `extension.js` 中同步落地,只是不让复杂表单控件阻塞发布 +- 并行 lane 漂移风险:Tooling / Docs 作为并行项后,后续 batch 可能重新把治理说明写回 active 入口或 public docs + - 缓解措施:active tracking / trace 只保留恢复点、验证和 lane 指针;reader-facing 文档只写接入信息,治理说明继续留在 `ai-plan/**` ## 当前状态 @@ -73,9 +73,7 @@ - 继续扩展“不会改变生成类型形状”的共享关键字支持 - 继续降低复杂 schema 与多配置域项目的接入成本 -- 让 VS Code 表单支持更深层对象数组嵌套,减少 raw YAML 回退 -- 为复杂结构提供比“顶层标量 / 标量数组”更强的批量编辑能力 -- 在真实 VS Code 宿主中完成对象数组编辑与复杂 schema 的交互式手工验证 +- Tooling / Docs 并行 lane 仍需推进复杂表单、交互式宿主验证和后续接入文档,但这些事项不再阻塞当前恢复点 ## 活跃文档 @@ -93,9 +91,11 @@ - `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`:通过 - `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderIfThenElseTests"`:通过(8 tests;新增 `else without if` 运行时回归) - `dotnet build GFramework.sln -c Release`:通过(存在仓库既有 analyzer warning,无新增错误) +- `2026-04-30` Tooling lane 收口验证: + - `dotnet build GFramework.sln -c Release`:待本轮 ai-plan 收口后补记结果;仅用来确认计划文件改动没有伴随未验证的代码漂移 ## 下一步 -1. 提交并推送当前 PR `#262` follow-up 修复后,重新抓取一次 PR review,确认 outside-diff comment 与 open thread 是否都已收口 -2. 若 PR review 已收口,再回到 `GFramework.Game/Config/YamlConfigSchemaValidator.cs`、`GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs`、`tools/gframework-config-tool/src/configValidation.js` 盘点下一批候选关键字 -3. 跳过 `oneOf` / `anyOf`,优先筛选下一个仍不改变生成类型形状、且不需要属性合并或联合分支生成的共享关键字 +1. 主线继续回到 `YamlConfigSchemaValidator.cs`、`SchemaConfigGenerator.cs` 与 `configValidation.js` 的共享关键字盘点,默认跳过 `oneOf` / `anyOf` +2. Tooling / Docs 若要并发推进,直接从 `ai-first-config-system-csharp-experience-next.md` 的并行 lane 开新 batch,不再扩写 active 入口 +3. 保持 active tracking / trace 精简,只记录当前恢复点、最近验证和下一步恢复指针 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 72ff88df..49fc846d 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 @@ -135,3 +135,32 @@ 1. 若本轮定向验证通过,继续盘点下一批真正低风险、且不改变生成类型形状的共享关键字 2. 不再重复评估 `oneOf` / `anyOf` 的 object-focused 子集,除非未来主线明确接受联合形状生成 3. 若后续关键字需要新诊断编号或文档边界说明,继续保持 Runtime / Generator / Tooling 同步收口 + +## 2026-04-30 + +### 阶段:Tooling lane 收口整理(AI-FIRST-CONFIG-RP-003) + +- 已把 Tooling / Docs 后续动作从 active 入口的主线叙述中剥离,改成 backlog 文件里的非阻塞并行 lane +- 当前 active tracking / trace 只继续承担三件事: + - 给 `boot` 提供当前恢复点 + - 记录最近一次验证或计划性验证占位 + - 指向真正承载并行批次细节的 backlog 文件 +- 本轮不新增代码范围、测试范围或文档范围,只整理 public `ai-plan/**` 的恢复入口表达,避免把治理噪音带回 reader-facing docs + +### 关键决定 + +- `C# Runtime + Source Generator + Consumer DX` 仍是默认恢复主线 +- Tooling / Docs 可以并发推进,但后续 batch 应直接以 `ai-first-config-system-csharp-experience-next.md` 为入口,而不是继续扩写 active tracking / trace +- public docs 后续只承接接入 guidance、能力边界和回退方式;批次编排、lane 风险和治理说明继续留在 `ai-plan/**` + +### 验证 + +- 2026-04-30:`wc -l ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md` + - 结果:通过 + - 备注:确认本轮仍把 active 入口控制在精简范围,并把 lane 细节下沉到 backlog 文件 + +### 下一步 + +1. 若继续做主线代码批次,直接回到共享关键字盘点,不让 Tooling / Docs 成为阻塞条件 +2. 若另开 Tooling / Docs batch,先读取 `ai-first-config-system-csharp-experience-next.md` 的并行 lane,再把结果摘要写回 active tracking / trace +3. 继续保持 active 入口精简,不在默认恢复文件中追加 UI 细节、治理台账或面向读者的文档草稿 diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 64331588..424adb04 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -1005,8 +1005,9 @@ var hotReload = loader.EnableHotReload( - 编辑对象项中的标量字段 - 编辑对象项中的标量数组 - 编辑对象项中的嵌套对象字段 +- 编辑对象项内部继续嵌套的对象数组,只要这些内层对象数组项仍然由对象、标量字段、标量数组和嵌套对象组成 -如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。 +如果对象数组中混入了标量项,或者更深层结构超出当前 schema 子集,表单入口会明确提示该路径需要回退到 raw YAML。 当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。 @@ -1017,7 +1018,6 @@ var hotReload = loader.EnableHotReload( 以下场景目前仍建议保留 raw YAML 编辑,或由项目补充专用工具: - 需要更完整的 JSON Schema 支持 -- 需要在 VS Code 中安全编辑更深层对象数组嵌套 - 需要覆盖更复杂的数组结构和更深层 schema 关键字 ## 工具形态建议 diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 941d40bf..1fd71390 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1,6 +1,43 @@ const fs = require("fs"); const path = require("path"); -const vscode = require("vscode"); +let vscode; +try { + vscode = require("vscode"); +} catch { + // Tests load pure helpers from this module without the VS Code host. + vscode = { + env: { + language: "en" + }, + EventEmitter: class EventEmitter { + constructor() { + this.event = () => undefined; + } + + fire() { + } + }, + TreeItem: class TreeItem { + constructor(label, collapsibleState) { + this.label = label; + this.collapsibleState = collapsibleState; + } + }, + TreeItemCollapsibleState: { + None: 0, + Collapsed: 1, + Expanded: 2 + }, + Uri: { + joinPath() { + return undefined; + } + }, + window: {}, + workspace: {}, + languages: {} + }; +} const { applyFormUpdates, createSampleConfigYaml, @@ -973,7 +1010,7 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { } } function renumberObjectArrayItems(editor) { - const items = editor.querySelectorAll("[data-object-array-item]"); + const items = editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]"); items.forEach((item, index) => { const title = item.querySelector(".object-array-item-title"); if (title) { @@ -981,6 +1018,52 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { } }); } + function shouldIncludeNestedControl(control, ownerItem) { + return control.closest("[data-object-array-item]") === ownerItem; + } + function collectObjectArrayEditorItems(editor) { + const items = []; + for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) { + items.push(collectObjectArrayItemValue(item)); + } + + return items; + } + function collectObjectArrayItemValue(item) { + const itemValue = {}; + + for (const control of item.querySelectorAll("[data-item-local-path]")) { + if (!shouldIncludeNestedControl(control, item)) { + continue; + } + + setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value); + } + + for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) { + if (!shouldIncludeNestedControl(textarea, item)) { + continue; + } + + setNestedObjectValue( + itemValue, + textarea.dataset.itemArrayPath, + parseArrayEditorValue(textarea.value)); + } + + for (const nestedEditor of item.querySelectorAll("[data-item-object-array-path]")) { + if (!shouldIncludeNestedControl(nestedEditor, item)) { + continue; + } + + setNestedObjectValue( + itemValue, + nestedEditor.dataset.itemObjectArrayPath, + collectObjectArrayEditorItems(nestedEditor)); + } + + return itemValue; + } document.addEventListener("click", (event) => { const schemaButton = event.target.closest("[data-open-ref-schema]"); if (schemaButton) { @@ -1048,23 +1131,9 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { for (const textarea of document.querySelectorAll("textarea[data-comment-path]")) { comments[textarea.dataset.commentPath] = textarea.value; } - for (const editor of document.querySelectorAll("[data-object-array-editor]")) { + for (const editor of document.querySelectorAll("[data-object-array-editor][data-object-array-path]")) { const path = editor.dataset.objectArrayPath; - const items = []; - for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) { - const itemValue = {}; - for (const control of item.querySelectorAll("[data-item-local-path]")) { - setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value); - } - for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) { - setNestedObjectValue( - itemValue, - textarea.dataset.itemArrayPath, - parseArrayEditorValue(textarea.value)); - } - items.push(itemValue); - } - objectArrays[path] = items; + objectArrays[path] = collectObjectArrayEditorItems(editor); } vscode.postMessage({ type: "save", scalars, arrays, objectArrays, comments }); }); @@ -1110,8 +1179,11 @@ function renderFormField(field) { title: localizer.t("webview.objectArray.item"), fields: field.templateFields }); + const pathAttribute = field.itemMode + ? `data-item-object-array-path="${escapeHtml(field.path)}"` + : `data-object-array-path="${escapeHtml(field.path)}"`; return ` -
+
${escapeHtml(field.label)} ${field.required ? `${escapeHtml(localizer.t("webview.badge.required"))}` : ""}
${escapeHtml(field.displayPath || field.path)}
${renderYamlCommentBlock(field)} @@ -1507,6 +1579,41 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa continue; } + if (propertySchema.type === "array" && + propertySchema.items && + propertySchema.items.type === "object") { + const templateFields = []; + collectObjectArrayItemFields( + propertySchema.items, + undefined, + "", + joinArrayTemplatePath(itemDisplayPath), + depth + 1, + templateFields, + unsupported, + commentLookup); + fields.push({ + kind: "objectArray", + path: itemLocalPath, + displayPath: itemDisplayPath, + label, + required: requiredSet.has(key), + depth, + schema: propertySchema, + itemMode: true, + comment: commentLookup[itemDisplayPath] || "", + items: buildObjectArrayItemModels( + propertySchema.items, + propertyValue, + itemDisplayPath, + depth + 1, + unsupported, + commentLookup), + templateFields + }); + continue; + } + if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) { fields.push({ kind: "scalar", @@ -2096,5 +2203,8 @@ function parseArrayFieldPayload(arrays) { module.exports = { activate, - deactivate + deactivate, + __test: { + buildFormModel + } }; diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 312cacff..fb3acb6b 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1,5 +1,6 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const {__test: extensionTest} = require("../src/extension"); const { applyFormUpdates, applyScalarUpdates, @@ -2525,6 +2526,123 @@ test("applyFormUpdates should rewrite object-array items from structured form pa assert.match(updated, /^ monsterId: goblin$/mu); }); +test("buildFormModel should expose nested object-array editors inside object-array items", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "phases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "wave": { "type": "integer" }, + "spawns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "monsterId": { "type": "string" }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +phases: + - + wave: 1 + spawns: + - + monsterId: slime + tags: + - starter +`); + + const formModel = extensionTest.buildFormModel(schema, yaml, {}); + const phasesField = formModel.fields.find((field) => field.path === "phases"); + + assert.ok(phasesField); + assert.equal(phasesField.kind, "objectArray"); + assert.equal(phasesField.items.length, 1); + assert.deepEqual(formModel.unsupported, []); + + const nestedSpawnField = phasesField.items[0].fields.find((field) => field.path === "spawns"); + assert.ok(nestedSpawnField); + assert.equal(nestedSpawnField.kind, "objectArray"); + assert.equal(nestedSpawnField.itemMode, true); + assert.equal(nestedSpawnField.items.length, 1); + + const spawnMonsterField = nestedSpawnField.items[0].fields.find((field) => field.path === "monsterId"); + assert.ok(spawnMonsterField); + assert.equal(spawnMonsterField.kind, "scalar"); + + const spawnTagsField = nestedSpawnField.items[0].fields.find((field) => field.path === "tags"); + assert.ok(spawnTagsField); + assert.equal(spawnTagsField.kind, "array"); +}); + +test("applyFormUpdates should rewrite nested object arrays from structured form payloads", () => { + const updated = applyFormUpdates( + [ + "phases:", + " -", + " wave: 1" + ].join("\n"), + { + objectArrays: { + phases: [ + { + wave: "1", + spawns: [ + { + monsterId: "slime", + tags: ["starter", "melee"], + reward: { + gold: "10" + } + }, + { + monsterId: "goblin", + conditions: [ + { + type: "night", + value: "true" + } + ] + } + ] + } + ] + } + }); + + assert.match(updated, /^phases:$/mu); + assert.match(updated, /^ -$/mu); + assert.match(updated, /^ wave: 1$/mu); + assert.match(updated, /^ spawns:$/mu); + assert.match(updated, /^ -$/mu); + assert.match(updated, /^ monsterId: slime$/mu); + assert.match(updated, /^ tags:$/mu); + assert.match(updated, /^ - starter$/mu); + assert.match(updated, /^ - melee$/mu); + assert.match(updated, /^ reward:$/mu); + assert.match(updated, /^ gold: 10$/mu); + assert.match(updated, /^ monsterId: goblin$/mu); + assert.match(updated, /^ conditions:$/mu); + assert.match(updated, /^ -$/mu); + assert.match(updated, /^ type: night$/mu); + assert.match(updated, /^ value: true$/mu); +}); + test("applyFormUpdates should clear object arrays when the form removes all items", () => { const updated = applyFormUpdates( [