Files
FreeCADWasmDev/tools/generate_web_feature_task_packets.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

510 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate small, module-local P6 task packets from the source-backed matrix."""
from __future__ import annotations
import json
import hashlib
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MATRIX = ROOT / "docs" / "web-parity-matrix.json"
OUT = ROOT / "docs" / "tasks" / "p6"
STATE = ROOT / "docs" / "current" / "task-state.json"
MODULE_IDS = {
"Assembly": "ASM",
"BIM": "BIM",
"CAM": "CAM",
"Cloud": "CLD",
"Draft": "DFT",
"Fem": "FEM",
"Help": "HLP",
"Import": "IMP",
"Inspection": "INS",
"JtReader": "JTR",
"Material": "MAT",
"Measure": "MEA",
"Mesh": "MSH",
"MeshPart": "MSP",
"OpenSCAD": "OSC",
"Part": "PRT",
"PartDesign": "PDN",
"Plot": "PLT",
"Points": "PTS",
"ReverseEngineering": "REV",
"Robot": "ROB",
"Sandbox": "SDB",
"Show": "SHW",
"Sketcher": "SKT",
"Spreadsheet": "SPR",
"Start": "STR",
"Surface": "SUR",
"TechDraw": "TDR",
"TemplatePyMod": "TPM",
"Test": "TST",
"Tux": "TUX",
"Web": "WEB",
}
PRIMARY_ACTION = {
"Assembly": "create/recompute",
"BIM": "import/export",
"CAM": "create/recompute",
"Cloud": "create/recompute",
"Draft": "create/recompute",
"Fem": "create/recompute",
"Help": "inspect/source-inventory",
"Import": "import/export",
"Inspection": "create/recompute",
"JtReader": "create/recompute",
"Material": "create/recompute",
"Measure": "create/recompute",
"Mesh": "create/recompute",
"MeshPart": "create/recompute",
"OpenSCAD": "inspect/source-inventory",
"Part": "create/recompute",
"PartDesign": "create/recompute",
"Plot": "inspect/source-inventory",
"Points": "create/recompute",
"ReverseEngineering": "create/recompute",
"Robot": "create/recompute",
"Sandbox": "create/recompute",
"Show": "inspect/source-inventory",
"Sketcher": "create/recompute",
"Spreadsheet": "create/recompute",
"Start": "create/recompute",
"Surface": "create/recompute",
"TechDraw": "create/recompute",
"TemplatePyMod": "inspect/source-inventory",
"Test": "inspect/source-inventory",
"Tux": "inspect/source-inventory",
"Web": "create/recompute",
}
def load_state() -> dict:
if not STATE.exists():
return {"default_status": "planned", "tasks": {}, "module_status": {}}
return json.loads(STATE.read_text(encoding="utf-8"))
STATE_DATA = load_state()
def task_status(task_id: str) -> str:
return STATE_DATA.get("tasks", {}).get(task_id, STATE_DATA.get("default_status", "planned"))
def module_status(module: str, fallback: str) -> str:
return STATE_DATA.get("module_status", {}).get(module, fallback)
def write_generated(path: Path, content: str) -> None:
"""Write the generated prefix while preserving a manually recorded result section."""
if path.exists():
old = path.read_text(encoding="utf-8")
marker = "\n## 结果\n"
if marker in old and marker not in content:
content = content.rstrip() + old[old.index(marker):]
path.write_text(content.rstrip() + "\n", encoding="utf-8")
def slug(module: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", module.lower()).strip("-")
def action_slug(action: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", action.lower()).strip("-")
def module_index(module: str, row: dict) -> str:
code = MODULE_IDS[module]
action = PRIMARY_ACTION[module]
objects = ", ".join(row.get("object_types", [])[:8]) or "无已枚举对象;以源码入口和模块 API 为主"
if len(row.get("object_types", [])) > 8:
objects += f"(另有 {len(row['object_types']) - 8} 个,完整清单见 object-inventory.json)"
formats = ", ".join(row.get("format_extensions", [])) or "无格式入口"
api = row.get("python_api_count", 0)
gui = "需要 GUI 依赖" if row.get("gui_dependency") else "不需要 GUI 依赖"
actions = ", ".join(row.get("user_actions", []))
followups = [action for action in row.get("user_actions", []) if action != action_primary(module)]
followup_text = ", ".join(f"`{action}`" for action in followups) or "无"
action_rows = []
for action_number, action_name in enumerate(row.get("user_actions", []), start=1):
if action_name == action:
target = f"{slug(module)}/FW-P6-{code}-C.md"
role = "首切片 C-G"
else:
target = f"{slug(module)}/FW-P6-{code}-ACT-{action_number:02d}.md"
role = "后续拆分合同"
action_rows.append(f"| `{action_name}` | {role} | [{target.rsplit('/', 1)[-1]}]({target}) |")
action_table = "\n".join(action_rows)
action_predecessors = [
f"`FW-P6-{code}-ACT-{number:02d}-G`"
for number, action_name in enumerate(row.get("user_actions", []), start=1)
if action_name != action
]
final_predecessor = f"`FW-P6-{code}-X`" + (" + " + " + ".join(action_predecessors) if action_predecessors else "")
links = "\n".join(
f"| [FW-P6-{code}-{letter}]({slug(module)}/FW-P6-{code}-{letter}.md) | {title} | {prev} |"
for letter, title, prev in [
("A", "源码边界探针", "FW-P6-000"),
("B", "运行时注册与依赖闭包", f"FW-P6-{code}-A + FW-P6-001"),
("C", "一个主要内核动作", f"FW-P6-{code}-B"),
("D", "重算、非法输入与恢复", f"FW-P6-{code}-C"),
("E", "Worker/Facade 消息合同", f"FW-P6-{code}-D + FW-P6-002"),
("F", "Chrome 最小浏览器切片", f"FW-P6-{code}-E + FW-P6-003"),
("G", "持久化或模块边界证据", f"FW-P6-{code}-F"),
("X", "首切片出口", f"FW-P6-{code}-G"),
("Y", "完整动作出口", final_predecessor),
]
)
package_status = module_status(module, row.get("web_status", "pending"))
# Module parity uses `pending`; executable task cards use `planned`.
package_task_status = "planned" if package_status == "pending" else package_status
web_status_note = (
"Web 状态已由 Y gate 晋级为 `completed`;边界见对应 module-gate 产物。"
if package_status == "completed"
else "Web 状态保持 `pending`,直到本包的 Y 卡通过。"
)
return f"""# FW-P6-{code}:{module} 功能扩展任务包
本文件只做模块范围和原子卡索引;执行时读取当前原子卡,不读取本包其他卡。
## 范围快照
- 状态:`{package_task_status}`
- 来源:`docs/web-parity-matrix.json`,源码 revision 由 `FW-P6-000` 锁定
- 主要动作:`{action}`
- 对象规模:`{row.get('object_type_count', 0)}`;代表入口:{objects}
- 格式入口:{formats}
- Python API 计数:`{api}`;GUI:{gui}
- 对标动作全集:{actions}
- {web_status_note}
首个 C-G 切片只覆盖 `{action}`。后续动作:{followup_text};每个后续动作必须在 Y 前完成自己的拆分卡和 C-G 子链,不能因为首个切片通过就把模块标为 `completed`。
## 动作级入口
| 动作 | 角色 | 入口 |
|---|---|---|
{action_table}
## 原子卡
| 卡 | 合同 | 直接前置 |
|---|---|---|
{links}
顺序:`A → B → C → D → E → F → G → X`;随后完成动作级卡,最后执行 `Y`。失败只新增同目录 `R` 恢复卡,不复制原卡全文。
"""
def atomic_card(module: str, row: dict, letter: str) -> str:
code = MODULE_IDS[module]
name = slug(module)
prefix = f"FW-P6-{code}"
titles = {
"A": "源码边界探针",
"B": "运行时注册与依赖闭包",
"C": "一个主要内核动作",
"D": "重算、非法输入与恢复",
"E": "Worker/Facade 消息合同",
"F": "Chrome 最小浏览器切片",
"G": "持久化或模块边界证据",
"X": "首切片出口",
"Y": "完整动作出口",
}
prev = {
"A": "`FW-P6-000`",
"B": f"`{prefix}-A`、`FW-P6-001`",
"C": f"`{prefix}-B`",
"D": f"`{prefix}-C`",
"E": f"`{prefix}-D`、`FW-P6-002`",
"F": f"`{prefix}-E`、`FW-P6-003`",
"G": f"`{prefix}-F`",
"X": f"`{prefix}-G`",
"Y": f"`{prefix}-X`",
}[letter]
if letter == "Y":
action_predecessors = [
f"`{prefix}-ACT-{number:02d}-G`"
for number, action_name in enumerate(row.get("user_actions", []), start=1)
if action_name != PRIMARY_ACTION[module]
]
if action_predecessors:
prev += " + " + " + ".join(action_predecessors)
task_type = {"A": "probe", "F": "slice", "X": "gate", "Y": "gate"}.get(letter, "contract")
bodies = {
"A": f"""- 合同:确认 `{module}` 的 CMake target、App/Gui 分界、直接依赖、类型注册入口和主要动作源码入口。
- 输入:`workbench-inventory.json`、`object-inventory.json`、`src/Mod/{module}/`。
- 命令/方法:在 `freecad-source` 上运行 `rg -n "BUILD_|Init|addObject|registerType|PyInit" src/Mod/{module}`,再对命中的 CMake 与源码行做最小闭包记录。
- 产物:`docs/p6/{name}-source-boundary.json` 与日志。
- 验收:每个入口有文件和行号;条件编译、缺失依赖和 GUI 符号单独列出;不把源码存在写成运行时通过。
- 不包含:代码修改、浏览器测试、完整 API 迁移。""",
"B": f"""- 合同:在独立 WASM probe 中完成 `{module}` 初始化、类型可见性和重复初始化检查。
- 输入:A 的边界 JSON、模块 App CMake、共享模块加载合同。
- 命令/方法:复制现有最小 Web probe 的 configure/build/run 流程;只替换模块 target 和一个代表类型;连续执行初始化两次并保存新的 build/smoke 日志,不覆盖成功证据。
- 产物:`docs/p6/{name}-runtime.json`、构建日志和 WASM SHA-256。
- 验收:初始化计数为 1;重复调用不重复注册;缺失依赖返回结构化错误;无未声明 GUI/动态库符号。
- 不包含:主要动作、Facade、Chrome UI。""",
"C": f"""- 合同:只验证一个最小 `{PRIMARY_ACTION[module]}` 合同,固定输入、输出和对象句柄。
- 输入:B 的 WASM、固定 fixture、对应源码 API;对象模块用代表类型,格式模块用一个最小样例。
- 命令/方法:从 A 的主要动作入口选择一个 deterministic fixture;在 C ABI/Worker probe 中执行一次成功路径和一次资源释放,记录输入 hash、结果摘要和返回码。
- 产物:`docs/p6/{name}-kernel.json` 与 smoke 日志。
- 验收:成功结果可重复;对象/几何/格式摘要符合源码语义;异常转为稳定错误码和消息;资源可释放。
- 不包含:第二个动作、全量对象覆盖、GUI 交互。""",
"D": f"""- 合同:覆盖一次重算/校验边界和一个非法输入,失败后保留上一次有效状态。
- 输入:C 的成功 fixture;只读模块改为缺少资源/不支持输入边界。
- 命令/方法:复用 C 的同一进程,先记录成功快照,再注入一个字段级非法值或缺失资源;校验错误 code、旧状态 hash、重复回放结果。
- 产物:`docs/p6/{name}-recovery.json` 与日志。
- 验收:错误定位到字段或步骤;有效对象/文件 hash 不污染;重复执行一致;缓存失败不升级为主流程失败。
- 不包含:跨文档事务、并发修改、性能预算。""",
"E": f"""- 合同:把 C/D 的一个动作接入 request id、typed result、错误和取消语义。
- 输入:共享 Facade envelope、D 的 fixture 和错误样例。
- 命令/方法:为一个 action 增加 typed request/result schema;顺序发送 success、expected error、duplicate id、cancel 四个 request,保存 Worker trace。
- 产物:`docs/p6/{name}-facade.json`、schema 测试和 Worker 日志。
- 验收:请求严格串行且可关联;JS 不持有原生指针;成功/失败/重复 request 均有固定结果;取消不破坏文档状态。
- 不包含:完整 UI、插件系统、跨工作台组合。""",
"F": f"""- 合同:在 Google Chrome 固定版本和 viewport 下完成一个真实 `{PRIMARY_ACTION[module]}` 用户动作回放。
- 输入:Facade bundle、固定 fixture、Chrome harness;只执行 Chrome,不切换 Firefox/WebKit/Safari。
- 命令/方法:调用 `FW-P6-003` harness 启动入口,拦截所有外部请求,执行 ready→action→expected-error→reset,并保存 console/network/截图证据。
- 产物:`docs/p6/{name}-chrome.json`、控制台/网络日志和截图(如适用)。
- 验收:ready、动作结果、错误路径和 Worker 消息通过;外部请求为 0;运行时异常为 0;证据含 Chrome 版本、模式和 viewport。
- 不包含:非 Chrome 兼容矩阵、移动适配、完整 UI。""",
"G": f"""- 合同:有保存/重开动作时验证一个最小 FCStd round-trip;否则验证 manifest/资源/只读重放边界。
- 输入:F 的成功状态、P2.3 FCStd 合同、模块 fixture。
- 命令/方法:有 `save/reopen` 时按 P2.3 的临时文件、hash、关闭、重开、对象/属性校验顺序执行;无该动作时运行资源 manifest 和只读重放检查。
- 产物:`docs/p6/{name}-persistence.json` 与日志。
- 验收:保存/重开对象和属性可校验;不适用时明确原因,不伪造通过;错误后旧状态仍有效。
- 不包含:跨版本迁移、全量格式覆盖、升级回滚。""",
"X": f"""- 合同:汇总本包 A-G 的独立证据,关闭 `{module}` 的首切片 gate。
- 输入:A-G JSON/日志、源码 revision、共享 gate 报告。
- 命令/方法:逐项读取本包 A-G 产物,核对 revision、输入/输出 hash、Chrome 证据和未覆盖动作;生成机器可读结果后再写人读 gate。
- 产物:`docs/p6/{name}-gate.md` 与 JSON。
- 验收:首个动作的每个原子合同有命令、输入、产物、hash 和结论;本卡只关闭首切片,不修改完整模块状态;其他动作保持 `pending`。
- 不包含:其他模块、全量发布 gate、法律或性能结论。""",
"Y": f"""- 合同:汇总首切片和全部动作级子链,决定 `{module}` 是否可以从 `pending` 晋级。
- 输入:X 的首切片 gate、所有动作级卡及其 C-G 子链产物、源码 revision。
- 命令/方法:逐动作核对源码入口、成功/错误/Chrome/持久化证据;任一声明动作缺证据即保持 `pending`,不得用动作计数推断完成。
- 产物:`docs/p6/{name}-module-gate.md` 与 JSON。
- 验收:本模块声明的 {len(row.get('user_actions', []))} 个动作全部有独立结论;未实现或不适用项显式列出;只有本卡可以晋级该模块 Web 状态。
- 不包含:其他模块、全量发布 gate、法律或性能结论。""",
}[letter]
return f"""# {prefix}-{letter}:{module} {titles[letter]}
> 最小执行上下文:只读取本卡、直接前置 {prev} 和本卡列出的产物。浏览器步骤只允许 Google Chrome。
- 状态:`{task_status(f"{prefix}-{letter}")}`
- 类型:`{task_type}`
- 前置:{prev}
{bodies}
- 失败恢复:保留首个错误、命令、输入 hash 和部分产物;恢复卡命名 `{prefix}-{letter}-R1`,不得覆盖成功证据。
"""
def action_card(module: str, action_name: str, action_number: int) -> str:
code = MODULE_IDS[module]
name = slug(module)
prefix = f"FW-P6-{code}-ACT-{action_number:02d}"
action_id = action_slug(action_name)
extra_prerequisite = {
"python-api": ";Python 运行时前置 `FW-P4-006-G`",
"save/reopen": ";持久化证据前置 `freecad-p2.3-persistence-transaction-gate.md`",
}.get(action_name, "")
return f"""# {prefix}:{module} `{action_name}` 动作拆分合同
> 这是动作级拆分卡,不等于功能已实现。完成本卡后,必须按卡内的 C→D→E→F→G 子链继续执行;只读取本卡、`FW-P6-{code}-B` 和本卡产物。
- 状态:`{task_status(prefix)}`
- 类型:`contract`
- 前置:`FW-P6-{code}-B`{extra_prerequisite};若首切片已完成,复用其 WASM、loader 和 fixture 约束。
- 合同:为 `{action_name}` 冻结一个可观察用户动作的输入、源码入口、结果摘要、错误边界、Worker request 和 Chrome 回放验收。
- 输入:`{name}-source-boundary.json`、对象/格式/API inventory 中与该动作对应的行、共享 Facade envelope 和 Chrome harness。
- 命令/方法:先在源码中定位动作入口,再写固定 fixture;把实现拆成 `{prefix}-C`(内核)、`{prefix}-D`(错误/恢复)、`{prefix}-E`(Facade)、`{prefix}-F`(Chrome)、`{prefix}-G`(持久化或适用边界)。
- 产物:`docs/p6/{name}-action-{action_number:02d}-{action_id}.json`,以及上述子卡各自的日志和 hash。
- 验收:动作有唯一 request id、成功和预期错误结果、无外部网络请求;未完成子链时本动作保持 `pending`。
- 不包含:其他动作、工作台 gate、全量对象覆盖、跨版本迁移。
- 失败恢复:只记录首个源码/fixture/schema 错误,新增 `{prefix}-R1`;不复制或覆盖首切片证据。
"""
def action_child_card(module: str, action_name: str, action_number: int, letter: str) -> str:
code = MODULE_IDS[module]
name = slug(module)
prefix = f"FW-P6-{code}-ACT-{action_number:02d}"
titles = {
"C": "内核动作",
"D": "错误与恢复",
"E": "Facade 接入",
"F": "Chrome 回放",
"G": "持久化或适用边界",
}
prev = {
"C": f"`{prefix}`、`FW-P6-{code}-B`",
"D": f"`{prefix}-C`",
"E": f"`{prefix}-D`、`FW-P6-002`",
"F": f"`{prefix}-E`、`FW-P6-003`",
"G": f"`{prefix}-F`",
}[letter]
bodies = {
"C": f"""- 合同:执行 `{action_name}` 的一个 deterministic 内核输入,返回对象/格式/API 摘要。
- 输入:动作拆分卡、B 的 WASM、动作 fixture 和源码入口。
- 命令/方法:单进程执行成功路径、释放路径和重复回放;记录输入 hash、结果 hash、错误 code。
- 产物:`docs/p6/{name}-action-{action_number:02d}-kernel.json` 与 smoke 日志。
- 验收:结果可重复,异常结构化,资源释放;不实现其他动作。""",
"D": f"""- 合同:为 `{action_name}` 注入一个字段级非法输入或缺少资源并保持旧状态。
- 输入:`{prefix}-C` 成功快照和错误 fixture。
- 命令/方法:成功→非法→恢复三段回放,比较旧状态 hash 和诊断字段。
- 产物:`docs/p6/{name}-action-{action_number:02d}-recovery.json` 与日志。
- 验收:首个错误可定位,旧状态未污染,重复回放一致。""",
"E": f"""- 合同:把 `{action_name}` 接入通用 request id、typed result、error 和 cancel。
- 输入:`{prefix}-D`、共享 envelope、成功和错误样例。
- 命令/方法:顺序发送 success、expected error、duplicate id、cancel;保存 Worker trace。
- 产物:`docs/p6/{name}-action-{action_number:02d}-facade.json` 与 schema 日志。
- 验收:请求可关联且严格串行;JS 不持有原生指针;取消不污染文档。""",
"F": f"""- 合同:在 Chrome 固定版本和 viewport 下回放真实 `{action_name}`。
- 输入:`{prefix}-E` bundle、fixture、`FW-P6-003` harness。
- 命令/方法:ready→action→expected-error→reset,拦截外部请求并保存 console/network/截图。
- 产物:`docs/p6/{name}-action-{action_number:02d}-chrome.json` 与日志。
- 验收:Chrome 版本、模式、viewport 齐全;外部请求和非预期异常为 0。""",
"G": f"""- 合同:为 `{action_name}` 完成 FCStd round-trip,或记录该动作不适用的边界证据。
- 输入:`{prefix}-F` 成功状态、P2.3 合同和 fixture。
- 命令/方法:有保存动作时执行临时文件、hash、关闭、重开、对象/属性校验;否则执行 manifest/只读重放。
- 产物:`docs/p6/{name}-action-{action_number:02d}-persistence.json` 与日志。
- 验收:对象/属性或“不适用”原因可校验;不伪造未覆盖能力。""",
}[letter]
task_type = "slice" if letter == "F" else "contract"
return f"""# {prefix}-{letter}:{module} `{action_name}` {titles[letter]}
> 最小执行上下文:只读取本卡、直接前置 {prev} 和本卡产物。浏览器只允许 Google Chrome。
- 状态:`{task_status(f"{prefix}-{letter}")}`
- 类型:`{task_type}`
- 前置:{prev}
{bodies}
- 不包含:其他用户动作、完整工作台 gate、跨版本迁移。
- 失败恢复:保留首个错误、输入 hash 和 trace;新增 `{prefix}-{letter}-R1`,不覆盖成功证据。
"""
def action_primary(module: str) -> str:
return PRIMARY_ACTION[module]
def main() -> None:
data = json.loads(MATRIX.read_text(encoding="utf-8"))
rows = {row["module"]: row for row in data["rows"]}
missing = set(MODULE_IDS) - set(rows)
if missing:
raise SystemExit(f"matrix missing modules: {sorted(missing)}")
OUT.mkdir(parents=True, exist_ok=True)
index = [
"# P6 功能扩展任务索引",
"",
"> P3 gate 已完成的是源码盘点,不是 32 个工作台的 Web 实现。P6 按工作台建立独立任务包;每个包内部再拆为 A-G、X 首切片和 Y 完整出口。",
"> 当前 32 个模块声明了 115 个用户动作;每个包先做一个首切片,其他动作必须继续拆独立合同,不能用首切片冒充完整对标。",
"> 状态由 `docs/current/task-state.json` 统一维护;本索引和卡片是生成投影。",
"> 机器可读文件清单和数量校验见 [`manifest.json`](manifest.json)。",
"> 矩阵更新后依次运行生成器、`sync_web_task_state.py` 和 `validate_web_context.py`;不要手改生成结构。",
"",
"## 共享前置",
"",
"| 编号 | 合同 | 产物 |",
"|---|---|---|",
"| [FW-P6-000](../FW-P6-000.md) | 锁定源码、矩阵、构建和 Chrome 环境 | `docs/p6/baseline.json` |",
"| [FW-P6-001](../FW-P6-001.md) | 模块初始化、类型注册和依赖错误 | `docs/p6/module-loader.json` |",
"| [FW-P6-002](../FW-P6-002.md) | Worker/Facade 通用消息 envelope | `docs/p6/facade-envelope.json` |",
"| [FW-P6-003](../FW-P6-003.md) | Chrome 回放 harness 和证据格式 | `docs/p6/chrome-harness.json` |",
"",
"## 工作台任务包",
"",
"| 包 | 模块 | 模块状态 | 首个动作 | 全部动作 | 对象 | 格式 | Python API | 直接前置 |",
"|---|---|---|---|---|---:|---|---:|---|",
]
for module in MODULE_IDS:
row = rows[module]
code = MODULE_IDS[module]
index.append(
f"| [FW-P6-{code}](FW-P6-{slug(module)}.md) | `{module}` | "
f"`{module_status(module, row.get('web_status', 'pending'))}` | `{PRIMARY_ACTION[module]}` | "
f"{', '.join(row['user_actions'])} | {row['object_type_count']} | "
f"{', '.join('`'+x+'`' for x in row['format_extensions']) or '-'} | "
f"{row['python_api_count']} | `FW-P6-000`, `001`, `002`, `003` |"
)
index += [
"",
"## 原子步骤语义",
"",
"| 步骤 | 单一合同 | 不读取的内容 |",
"|---|---|---|",
"| A | 源码边界探针 | 其他模块实现、浏览器日志 |",
"| B | WASM 初始化和类型注册 | 主要动作、UI |",
"| C | 一个主要内核动作 | 第二个动作、全量对象 |",
"| D | 重算/非法输入/恢复 | 并发和性能 |",
"| E | Worker/Facade 消息 | 完整 UI 和插件 |",
"| F | Chrome 用户动作 | 非 Chrome 浏览器 |",
"| G | FCStd round-trip 或适用边界 | 跨版本迁移 |",
"| X | 首切片 gate | 其他模块和发布 gate |",
"| Y | 完整动作 gate | 其他模块和发布 gate |",
"",
"失败恢复卡使用同一包内的 `R` 后缀,例如 `FW-P6-PRT-C-R1`;恢复卡只记录首个错误、最小修复和重放命令,不复制原卡全文。",
]
write_generated(OUT / "INDEX.md", "\n".join(index))
for module, row in rows.items():
module_slug = slug(module)
write_generated(OUT / f"FW-P6-{module_slug}.md", module_index(module, row))
module_dir = OUT / module_slug
module_dir.mkdir(parents=True, exist_ok=True)
for letter in "ABCDEFGXY":
write_generated(
module_dir / f"FW-P6-{MODULE_IDS[module]}-{letter}.md",
atomic_card(module, row, letter),
)
for action_number, action_name in enumerate(row.get("user_actions", []), start=1):
if action_name == PRIMARY_ACTION[module]:
continue
write_generated(
module_dir / f"FW-P6-{MODULE_IDS[module]}-ACT-{action_number:02d}.md",
action_card(module, action_name, action_number),
)
for letter in "CDEFG":
write_generated(
module_dir / f"FW-P6-{MODULE_IDS[module]}-ACT-{action_number:02d}-{letter}.md",
action_child_card(module, action_name, action_number, letter),
)
files = sorted(str(path.relative_to(ROOT)) for path in OUT.rglob("*.md"))
manifest = {
"schema": "freecad-web-p6-task-packet-manifest/v1",
"source_matrix": str(MATRIX.relative_to(ROOT)),
"source_matrix_sha256": hashlib.sha256(MATRIX.read_bytes()).hexdigest(),
"module_count": len(rows),
"declared_action_count": sum(len(row.get("user_actions", [])) for row in rows.values()),
"module_index_count": len(rows),
"atomic_card_count": len(rows) * 9,
"action_split_card_count": sum(
len([action for action in row.get("user_actions", []) if action != PRIMARY_ACTION[module]])
for module, row in rows.items()
),
"action_child_card_count": sum(
5 * len([action for action in row.get("user_actions", []) if action != PRIMARY_ACTION[module]])
for module, row in rows.items()
),
"markdown_file_count": len(files),
"files": files,
"shared_prerequisites": [
"docs/tasks/FW-P6-000.md",
"docs/tasks/FW-P6-001.md",
"docs/tasks/FW-P6-002.md",
"docs/tasks/FW-P6-003.md",
],
}
(OUT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()