feat(config-tool): 支持对象数组内嵌对象数组编辑

- 新增对象数组编辑器对数组项内嵌对象数组的递归渲染与保存能力

- 补充嵌套对象数组表单模型与 YAML 写回回归测试

- 更新配置系统文档中的 raw YAML 回退边界说明
This commit is contained in:
gewuyou 2026-04-30 11:33:29 +08:00 committed by GeWuYou
parent d6a154726c
commit fad391e8cf
6 changed files with 318 additions and 32 deletions

View File

@ -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但不要让其反向改写主线恢复点定义

View File

@ -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 后续改为非阻塞并行 laneactive 入口只保留主线恢复点,把批处理细节下沉到 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 精简,只记录当前恢复点、最近验证和下一步恢复指针

View File

@ -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 细节、治理台账或面向读者的文档草稿

View File

@ -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 关键字
## 工具形态建议

View File

@ -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 `
<div class="object-array depth-${field.depth}" data-object-array-editor data-object-array-path="${escapeHtml(field.path)}">
<div class="object-array depth-${field.depth}" data-object-array-editor ${pathAttribute}>
<div class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</div>
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
${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
}
};

View File

@ -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(
[