ht32-code-mcp 0.4.3__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/PKG-INFO +1 -1
  2. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/pyproject.toml +1 -1
  3. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/materializer.py +90 -13
  4. ht32_code_mcp-0.4.4/tests/test_export_cloud_mode.py +176 -0
  5. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/tests/test_tools.py +4 -2
  6. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/uv.lock +1 -1
  7. ht32_code_mcp-0.4.3/tests/test_export_cloud_mode.py +0 -83
  8. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/.gitignore +0 -0
  9. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/.gitkeep +0 -0
  10. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/.pytest_base_final_run.log +0 -0
  11. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/DEPLOY.md +0 -0
  12. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/README.md +0 -0
  13. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/e2e_scenario_a.py +0 -0
  14. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/e2e_scenario_b.py +0 -0
  15. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/smoke_http.py +0 -0
  16. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/smoke_stdio.py +0 -0
  17. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/__init__.py +0 -0
  18. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/__main__.py +0 -0
  19. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/exporter.py +0 -0
  20. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/index.py +0 -0
  21. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/knowledge.py +0 -0
  22. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/release.py +0 -0
  23. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/server.py +0 -0
  24. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/usage.py +0 -0
  25. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/tests/conftest.py +0 -0
  26. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/tests/test_knowledge_grounding.py +0 -0
  27. {ht32_code_mcp-0.4.3 → ht32_code_mcp-0.4.4}/tests/test_recipe_claims.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出
5
5
  License-Expression: MIT
6
6
  Keywords: firmware,holtek,ht32,mcp,mcu
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ht32-code-mcp"
7
- version = "0.4.3"
7
+ version = "0.4.4"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -4,7 +4,7 @@
4
4
  1. 先校验(schema/来源 sha256/授权/唯一 recipe/配置闭包)再创建 staging;
5
5
  2. XML 只做限定 target/节点路径/预期命中数的声明式改写,保留原字节其余部分;
6
6
  3. 内存一处裁决(recipe memory model)、多处一致:Cpu/IROM/IRAM/OCR_RVCT4/OCR_RVCT9/
7
- umfTarg/FlashDriverDll -FL 全部由同一模型派生;
7
+ FlashDriverDll -FL 由同一模型派生;umfTarg 保持官方生成工程口径(0);
8
8
  4. manifest 四件套(project-lock/files/deviations/validation.json)随产物发布;
9
9
  5. staging -> 同文件系统原子重命名;目标已存在则失败,不合并覆盖;
10
10
  6. ZIP 写 .part 校验后原子发布;失败不留公开半成品。
@@ -622,6 +622,14 @@ class Materializer:
622
622
  lib_src = pkg / ("libraries" if kernel == "m4" else "library")
623
623
  lib_dst = stage / "library"
624
624
  self._copy_official_tree(lib_src, lib_dst, manifest_files, origin["package"])
625
+ # 1b) middlewares:仅 M4 官方包自带(USB drivers/classes、FatFS、I2C app library)。
626
+ # 官方工程 IncludePath 直接引用 middlewares\usb_drivers\inc,必须随工程携带;
627
+ # M0+/M3 官方包无此目录。
628
+ if kernel == "m4":
629
+ mid_src = pkg / "middlewares"
630
+ if mid_src.is_dir():
631
+ self._copy_official_tree(mid_src, stage / "middlewares",
632
+ manifest_files, origin["package"])
625
633
  # 2) utilities:官方构建资产;host 工具(gsar/eLink32pro)默认随包
626
634
  if kernel == "m4":
627
635
  board_asset = r.recipe.get("board_asset_dir") or board_dir
@@ -698,6 +706,15 @@ class Materializer:
698
706
  deviations.append(dict(kind="example-doc-excluded", detail=sf["file_name"],
699
707
  reason="官方示例文档,非构建资产"))
700
708
  continue
709
+ # 工程自有资产(system 时钟/看门狗配置、startup 栈配置)必须落在 project/ 内。
710
+ # 写进 library/ 会在用户整体更新库时被官方原档覆盖,工程配置静默失效。
711
+ if sf["kind"] in ("cmsis", "mdk") and \
712
+ sf["target_path"].replace("/", "\\").startswith("..\\..\\library\\"):
713
+ raise ContractError(
714
+ RECIPE_MISMATCH,
715
+ f"{cfg['config_id']}: 工程自有文件 {sf['file_name']} 指向 library/"
716
+ f"({sf['target_path']});system/startup 必须随工程放在 "
717
+ f"project/Src_USER 或 project/MDK_ARMv5")
701
718
  target_file = _resolve_target(stage, sf["target_path"])
702
719
  in_tree = target_file is None
703
720
  base_name = Path(sf["origin"]).name
@@ -831,6 +848,28 @@ class Materializer:
831
848
  _remap_includepath, text)
832
849
  if n_inc:
833
850
  edits.append(f"IncludePath remapped ({n_inc} nodes scanned)")
851
+ # b2) M4 官方 middlewares:官方板级模板本身不含 middlewares,官方逐 IC 生成工程
852
+ # 会追加 usb_drivers/inc;随工程携带 middlewares/ 后同步补进 IncludePath
853
+ # (F493x5 的目录名是 usbd_drivers)。
854
+ mw_incs: list[str] = []
855
+ if kernel == "m4":
856
+ mw_root = pkg / "middlewares"
857
+ for drv in ("usb_drivers", "usbd_drivers"):
858
+ if (mw_root / drv / "inc").is_dir():
859
+ mw_incs.append(f"..\\..\\middlewares\\{drv}\\inc")
860
+ if mw_incs:
861
+ def _add_middleware_incs(m):
862
+ body = m.group(2)
863
+ if not body.strip():
864
+ return m.group(0)
865
+ entries = body.split(";")
866
+ for inc in mw_incs:
867
+ if inc not in entries:
868
+ entries.append(inc)
869
+ edits.append(f"IncludePath += {inc} (official middlewares)")
870
+ return m.group(1) + ";".join(entries) + m.group(3)
871
+ text = re.sub(r"(<IncludePath>)([^<]*)(</IncludePath>)",
872
+ _add_middleware_incs, text)
834
873
  # c) Device / Cpu / 内存节点(一处裁决多处一致)
835
874
  mem = cfg["memory"]
836
875
  flash_base = int(mem["flash"][0]["base"], 16)
@@ -851,10 +890,9 @@ class Materializer:
851
890
  if f"<{tag}>" in text:
852
891
  text = rewrite_memory_node(text, tag, f"0x{base:08X}", f"0x{size:X}", what)
853
892
  edits.append("memory nodes (Cpu/IROM/IRAM/OCR_RVCT4/OCR_RVCT9) from recipe model")
854
- # umfTarg:使用 Target Dialog 内存布局生成容量受限 scatter(报告 §4.4-6)
855
- if "<umfTarg>0</umfTarg>" in text:
856
- text = text.replace("<umfTarg>0</umfTarg>", "<umfTarg>1</umfTarg>", 1)
857
- edits.append("umfTarg 0->1 (Use Memory Layout from Target Dialog)")
893
+ # umfTarg:与官方逐 IC 生成工程一致(0 = 不使用 Target Dialog 内存布局);
894
+ # M4 板级模板原值是 1,由 recipe 的 project_edits 声明式改为 0;
895
+ # M0+/M3 官方原值已是 0,这里不再强制改写。
858
896
  # FlashDriverDll:-FL 与裁决容量一致(官方逐 IC 值优先;仅裁决覆盖时改写)
859
897
  # 注意 UL2CM3 参数是**按算法块分块**的:
860
898
  # UL2CM3(... -FF0HT32F -FS00 -FL<主 Flash 容量> -FF1HT32F_OPT -FS11FF00000 -FL<OPT 区长度>)
@@ -904,6 +942,28 @@ class Materializer:
904
942
  # e2) 声明式 uvprojx 选项编辑(如 M4 统一 -O2;命中数必须精确)
905
943
  for e in cfg.get("project_edits", []):
906
944
  tag = e["tag"]
945
+ if e.get("op") == "ensure":
946
+ # 节点存在则改值,不存在则在指定锚点后插入(M0+/M3 的 uC99)
947
+ m0 = re.search(rf"<{tag}>([^<]*)</{tag}>", text)
948
+ old_val = m0.group(1) if m0 else None
949
+ if m0:
950
+ text = rewrite_tag_value(text, tag, e["new_value"], what)
951
+ edits.append(f"{tag} ensure -> {e['new_value']}")
952
+ else:
953
+ anchor = e["insert_after_tag"]
954
+ m = re.search(rf"([ \t]*)<{anchor}>[^<]*</{anchor}>(\r?\n)", text)
955
+ if not m:
956
+ raise ContractError(
957
+ RECIPE_MISMATCH,
958
+ f"{what}: <{tag}> 不存在且找不到插入锚点 <{anchor}>")
959
+ ins = f"{m.group(1)}<{tag}>{e['new_value']}</{tag}>{m.group(2)}"
960
+ text = text[:m.end()] + ins + text[m.end():]
961
+ edits.append(f"{tag} inserted -> {e['new_value']}")
962
+ deviations.append(dict(
963
+ kind="project-option-edit", file=f"{stem}.uvprojx", tag=tag,
964
+ old=old_val, new=e["new_value"],
965
+ reason=e.get("reason") or "声明式项目选项编辑"))
966
+ continue
907
967
  old = f"<{tag}>{e['old_value']}</{tag}>"
908
968
  new = f"<{tag}>{e['new_value']}</{tag}>"
909
969
  hits = text.count(old)
@@ -1139,19 +1199,36 @@ class Materializer:
1139
1199
  lines.append(" - printf 经 C 库 + 官方 retarget 钩子输出;波特率 115200")
1140
1200
  else:
1141
1201
  lines.append(" - 官方 `_RETARGET=0`:printf/getchar 退化为空操作,不占用 UART")
1142
- lines += [
1143
- "",
1144
- "## 目录",
1145
- "",
1146
- "| 目录 | 内容 |",
1147
- "|---|---|",
1202
+ dir_rows = [
1148
1203
  "| `library/` | 完整官方库(字节冻结,manifest/files.json 可逐文件核对) |",
1204
+ ]
1205
+ if kernel == "m4":
1206
+ dir_rows.append(
1207
+ "| `middlewares/` | 官方中间件:USB drivers/classes、FatFS、"
1208
+ "I2C application library(官方式 IncludePath 引用 "
1209
+ "`middlewares\\usb_drivers\\inc`) |")
1210
+ dir_rows += [
1149
1211
  "| `utilities/` | 官方板级/公共构建资产 + host 工具(`gsar/`、`elink32pro/`)"
1150
1212
  "+ DFP 安装包(`pack/`,见上「前置条件」) |",
1151
- "| `project/MDK_ARMv5/` | 工程文件(由官方逐 IC 工程声明式物化) |",
1152
- "| `project/Src_USER/` | 用户源码:自有 `main.c` + 官方配置/中断文件 |",
1213
+ "| `project/MDK_ARMv5/` | 工程文件(官方工程声明式物化)"
1214
+ " + 工程自有 `startup_*.s`(栈配置) |",
1215
+ "| `project/Src_USER/` | 用户源码:自有 `main.c` + 官方配置/中断文件"
1216
+ " + 工程自有 `system_*.c`(时钟/看门狗配置) |",
1153
1217
  "| `licenses/` | 官方许可文件 |",
1154
1218
  "| `manifest/` | project-lock / files / deviations / validation 四件套 |",
1219
+ ]
1220
+ lines += ["", "## 目录", "", "| 目录 | 内容 |", "|---|---|", *dir_rows]
1221
+ lines += [
1222
+ "",
1223
+ "## 更新官方库",
1224
+ "",
1225
+ "`library/`、`utilities/`"
1226
+ + ("、`middlewares/`" if kernel == "m4" else "")
1227
+ + " 是官方资产,可整体替换为同版本或更新的官方包;"
1228
+ "工程自己的配置都在 `project/` 下(`system_*.c` 时钟/看门狗、"
1229
+ "`startup_*.s` 栈、`<series>_conf.h`、`main.c`),**不会被库更新覆盖**。"
1230
+ "替换库后如需合并官方改动,请只 diff `system_*.c` / `startup_*.s`,"
1231
+ "不要直接用官方原档覆盖工程自有副本。",
1155
1232
  "",
1156
1233
  "## 自校验",
1157
1234
  "",
@@ -0,0 +1,176 @@
1
+ """回归测试:导出侧实测暴露的三个缺陷。
2
+
3
+ 1) 云端 zip 模式下响应格式化用了未定义的 `_fmt_time` → 线上 `export_project_bundle`
4
+ 100% 失败(本地模式与直接调 `exporter.export_zip` 都覆盖不到这行)。
5
+ 2) 物化器的单次调用状态(`_stage` / `_project_rel`)曾挂在进程级单例实例上,
6
+ 并发导出互相覆盖 → "…is not in the subpath of…" 串台错误。
7
+ 3) 默认时钟 profile `hsi-safe` 曾整体失效:官方 system 文件只存在于 `library/`,由完整
8
+ 官方树按字节拷贝,而选项编辑只作用于 `_resolve_target` 能定位到 staging 的文件 →
9
+ 声明式编辑从未落到 system 文件,导出工程仍依赖外部晶振(HSE)。现在的口径是:
10
+ system/startup 属于**工程自有资产**,导出到 `project/Src_USER` 与
11
+ `project/MDK_ARMv5`;`library/` 保持官方原档字节,用户整体更新库不会覆盖工程配置。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import concurrent.futures
16
+ import json
17
+ import re
18
+
19
+ from ht32_code_mcp import server as srv
20
+
21
+ CFG_F52352 = "ht32f52352.ht32f52352-sk.ac5.hsi-safe.retarget"
22
+ CFG_F50241 = "ht32f50241.ht32f50241-sk.ac5.hsi-safe.retarget"
23
+ CFG_L52241 = "ht32l52241.ht32l52241-sk.ac5.hsi-safe.retarget"
24
+ CFG_F49163 = "ht32f49163-100lqfp.ht32f49163-sk.ac5.hick-safe.retarget"
25
+
26
+
27
+ def test_cloud_zip_response_formats_expiry(tmp_path, monkeypatch):
28
+ """云端模式必须能给出下载 URL 与有效期(不得因未定义的 helper 直接抛 NameError)。"""
29
+ monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
30
+ monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
31
+ monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "http://example.test/ht32-bundles")
32
+ text = srv.export_project_bundle(config_id=CFG_F52352, project_name="CloudMode")
33
+ assert "导出失败" not in text, text
34
+ assert "下载 URL" in text and "http://example.test/ht32-bundles/" in text
35
+ assert "有效期" in text
36
+ assert list(tmp_path.joinpath("bundles").glob("*.zip")), "未生成 zip 产物"
37
+
38
+
39
+ def test_concurrent_exports_do_not_cross_talk(tmp_path):
40
+ """并发导出必须各自落到自己的目录(不得因共享实例状态串台)。"""
41
+ jobs = [(CFG_F50241, "ConcA"), (CFG_L52241, "ConcB")]
42
+
43
+ def run(item):
44
+ idx, (cid, name) = item
45
+ return srv.export_project_bundle(
46
+ config_id=cid, project_name=name, out_dir=str(tmp_path / f"out{idx}"))
47
+
48
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
49
+ texts = list(pool.map(run, enumerate(jobs)))
50
+
51
+ for text, (_, name) in zip(texts, jobs):
52
+ assert "导出失败" not in text, text
53
+ for idx, (_, name) in enumerate(jobs):
54
+ proj = tmp_path / f"out{idx}" / "project" / "MDK_ARMv5" / f"{name}.uvprojx"
55
+ assert proj.is_file(), f"并发导出产物缺失:{proj}"
56
+
57
+
58
+ def test_hsi_safe_profile_edits_project_owned_system_file(tmp_path):
59
+ """hsi-safe 改写工程自有 system 副本,library/ 必须保持官方字节。
60
+
61
+ F50241 的官方 system 文件属 hse-direct 家族(HSE_ENABLE=1 / HCLK_SRC=2),
62
+ 导出后 `project/Src_USER` 下的副本应改为 HSE_ENABLE=0 + HCLK_SRC=3(HSI,
63
+ 20 MHz 满频),并在 deviations 留 option-edit 记录;`library/` 下的同名
64
+ 官方文件保持原样,这样用户整体更新库时不会把工程配置清掉。
65
+ """
66
+ out = tmp_path / "out"
67
+ text = srv.export_project_bundle(config_id=CFG_F50241, project_name="HsiSafe",
68
+ out_dir=str(out))
69
+ assert "导出失败" not in text, text
70
+
71
+ dev = json.loads((out / "manifest" / "deviations.json").read_text(encoding="utf-8"))
72
+ edits = [d for d in dev["deviations"] if d["kind"] == "option-edit"]
73
+ assert {d["selector"] for d in edits} >= {"HSE_ENABLE", "HCLK_SRC"}, f"缺 option-edit 记录:{edits}"
74
+ assert len({d["file"] for d in edits}) == 1, f"option-edit 跨了多个文件:{edits}"
75
+ sys_name = edits[0]["file"]
76
+
77
+ owned = out / "project" / "Src_USER" / sys_name
78
+ assert owned.is_file(), f"工程自有副本缺失:{owned}"
79
+ code = owned.read_text(encoding="utf-8", errors="replace")
80
+ assert re.search(r"#define\s+HSE_ENABLE\s+\(0\)", code), "HSE_ENABLE 未被改写为 0"
81
+ assert re.search(r"#define\s+HCLK_SRC\s+\(3\)", code), "HCLK_SRC 未被改写为 3(HSI)"
82
+
83
+ library_copy = next((p for p in out.glob("library/Device/**/system_*.c")
84
+ if p.name == sys_name), None)
85
+ assert library_copy is not None, f"完整官方库缺少 {sys_name}"
86
+ lib_code = library_copy.read_text(encoding="utf-8", errors="replace")
87
+ assert re.search(r"#define\s+HSE_ENABLE\s+\(1\)", lib_code), \
88
+ "library/ 下的官方 system 文件被改写(更新库会覆盖工程配置)"
89
+ assert re.search(r"#define\s+HCLK_SRC\s+\(2\)", lib_code), \
90
+ "library/ 下的官方 system 文件被改写(更新库会覆盖工程配置)"
91
+
92
+ files = json.loads((out / "manifest" / "files.json").read_text(encoding="utf-8"))
93
+ paths = [f["path"] for f in files["files"]]
94
+ assert len(paths) == len(set(paths)), "同一路径被重复登记(树拷贝 + 源集各一次)"
95
+ owned_entry = next(f for f in files["files"]
96
+ if f["path"] == f"project/Src_USER/{sys_name}")
97
+ assert owned_entry.get("edited") is True, "工程自有 system 副本未标记 edited"
98
+ lib_entry = next(f for f in files["files"]
99
+ if f["path"].startswith("library/") and f["path"].endswith("/" + sys_name))
100
+ assert not lib_entry.get("edited"), "library/ 官方文件不应被标记 edited"
101
+
102
+
103
+ def test_m4_system_startup_are_project_owned(tmp_path):
104
+ """M4 与 M0+/M3 同口径:system/startup 放 project/,library 保持官方字节。
105
+
106
+ 官方 M4 生成工程把这两个文件放在 libraries/cmsis 下;若导出仍引用库内副本,
107
+ 用户整体更新 library/ 会覆盖工程配置(与 M0+/M3 修复前同一个问题)。
108
+ """
109
+ out = tmp_path / "m4"
110
+ text = srv.export_project_bundle(config_id=CFG_F49163, project_name="M4Owned",
111
+ out_dir=str(out))
112
+ assert "导出失败" not in text, text
113
+
114
+ sys_name = "system_ht32f491x3.c"
115
+ startup_name = "startup_ht32f491x3.s"
116
+ owned_sys = out / "project" / "Src_USER" / sys_name
117
+ owned_startup = out / "project" / "MDK_ARMv5" / startup_name
118
+ assert owned_sys.is_file(), f"缺工程自有 system 副本:{owned_sys}"
119
+ assert owned_startup.is_file(), f"缺工程自有 startup 副本:{owned_startup}"
120
+
121
+ # 完整官方库仍保留同名文件(作为官方资产),但构建引用的是 project/ 副本
122
+ assert next(out.glob(f"library/**/{sys_name}"), None) is not None
123
+ assert next(out.glob(f"library/**/{startup_name}"), None) is not None
124
+ uv = (out / "project" / "MDK_ARMv5" / "M4Owned.uvprojx").read_text(
125
+ encoding="utf-8", errors="replace")
126
+ assert f"..\\Src_USER\\{sys_name}" in uv, "uvprojx 未引用工程自有 system 副本"
127
+ assert f".\\{startup_name}" in uv, "uvprojx 未引用工程自有 startup 副本"
128
+
129
+ files = json.loads((out / "manifest" / "files.json").read_text(encoding="utf-8"))
130
+ paths = [f["path"] for f in files["files"]]
131
+ assert len(paths) == len(set(paths)), "同一路径被重复登记"
132
+ entry = next(f for f in files["files"] if f["path"] == f"project/Src_USER/{sys_name}")
133
+ assert not entry.get("edited"), "M4 默认 profile 不应改写 system 文件"
134
+
135
+
136
+ def test_m4_export_carries_official_middlewares(tmp_path):
137
+ """M4 官方包自带 middlewares/(USB/FatFS/I2C),导出必须携带、登记并补 IncludePath。"""
138
+ out = tmp_path / "m4mw"
139
+ text = srv.export_project_bundle(config_id=CFG_F49163, project_name="M4Mid",
140
+ out_dir=str(out))
141
+ assert "导出失败" not in text, text
142
+
143
+ usb_inc = out / "middlewares" / "usb_drivers" / "inc" / "usbd_core.h"
144
+ assert usb_inc.is_file(), "缺少 middlewares/usb_drivers/inc(官方 M4 包自带)"
145
+
146
+ files = json.loads((out / "manifest" / "files.json").read_text(encoding="utf-8"))
147
+ paths = {f["path"] for f in files["files"]}
148
+ assert "middlewares/usb_drivers/inc/usbd_core.h" in paths, \
149
+ "middlewares 未登记在 manifest/files.json"
150
+
151
+ uv = (out / "project" / "MDK_ARMv5" / "M4Mid.uvprojx").read_text(encoding="utf-8")
152
+ assert "..\\..\\middlewares\\usb_drivers\\inc" in uv, \
153
+ "IncludePath 未指向 middlewares/usb_drivers/inc"
154
+ assert "<uC99>1</uC99>" in uv, "M4 未启用 C99(应与官方生成工程对齐)"
155
+ assert "<Misc>--entry Reset_Handler</Misc>" in uv, \
156
+ "M4 链接入口未与官方生成工程对齐"
157
+ assert "<umfTarg>0</umfTarg>" in uv, "M4 umfTarg 应与官方生成工程一致(0)"
158
+
159
+ # M0+ 官方包没有 middlewares,不应凭空出现
160
+ out2 = tmp_path / "m0nomid"
161
+ srv.export_project_bundle(config_id=CFG_F52352, project_name="M0NoMid",
162
+ out_dir=str(out2))
163
+ assert not (out2 / "middlewares").exists(), "M0+ 工程不应携带 middlewares"
164
+
165
+
166
+ def test_c99_enabled_for_all_series(tmp_path):
167
+ """全系列统一开启 C99;M0+/M3 由 ensure 编辑插入 uC99 节点。"""
168
+ cases = [(CFG_F52352, "F5C99"), (CFG_L52241, "L5C99"), (CFG_F49163, "M4C99")]
169
+ for cfg, name in cases:
170
+ out = tmp_path / name
171
+ text = srv.export_project_bundle(config_id=cfg, project_name=name,
172
+ out_dir=str(out))
173
+ assert "导出失败" not in text, text
174
+ uv = next((out / "project" / "MDK_ARMv5").glob("*.uvprojx"))
175
+ xml = uv.read_text(encoding="utf-8")
176
+ assert "<uC99>1</uC99>" in xml, f"{cfg} 未开启 C99"
@@ -273,7 +273,7 @@ def _bundle_checks_v2(out_dir: Path, device: str, config_id: str = ""):
273
273
  assert m and int(m.group(1), 16) == flash_size, "IROM 与锁定 memory 不一致"
274
274
  m = re.search(r"<IRAM>.*?<Size>(0x[0-9A-Fa-f]+)</Size>", text, re.S)
275
275
  assert m and int(m.group(1), 16) == ram_size, "IRAM 与锁定 memory 不一致"
276
- assert "<umfTarg>1</umfTarg>" in text, "未启用 Use Memory Layout from Target Dialog"
276
+ assert "<umfTarg>0</umfTarg>" in text, "umfTarg 应与官方生成工程一致(0)"
277
277
  # 模板自用文件不进用户工程;README 属于用户工程
278
278
  assert not (out_dir / "devices.json").exists()
279
279
  assert not (out_dir / "TEMPLATE.md").exists()
@@ -381,7 +381,9 @@ def test_export_positive_default_chip(tmp_path):
381
381
  out = tmp_path / "bundle_default"
382
382
  result = srv.export_project_bundle("HT32F52352", str(out))
383
383
  assert "已导出" in result and "contract_version: 2" in result
384
- assert "config_id: ht32f52352." in result and "recipe_revision: 2.0.0" in result
384
+ rev = json.loads((REPO / ".work/template-recipes/recipes/ht32f5.json")
385
+ .read_text(encoding="utf-8"))["recipe_revision"]
386
+ assert "config_id: ht32f52352." in result and f"recipe_revision: {rev}" in result
385
387
  assert "v1 chip 参数已弃用" in result # 旧参数走只读适配层
386
388
  assert "flash" not in result.lower() or "Flash" in result
387
389
  _bundle_checks_v2(out, "HT32F52352")
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.4.3"
443
+ version = "0.4.4"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
@@ -1,83 +0,0 @@
1
- """回归测试:导出侧实测暴露的三个缺陷。
2
-
3
- 1) 云端 zip 模式下响应格式化用了未定义的 `_fmt_time` → 线上 `export_project_bundle`
4
- 100% 失败(本地模式与直接调 `exporter.export_zip` 都覆盖不到这行)。
5
- 2) 物化器的单次调用状态(`_stage` / `_project_rel`)曾挂在进程级单例实例上,
6
- 并发导出互相覆盖 → "…is not in the subpath of…" 串台错误。
7
- 3) 默认时钟 profile `hsi-safe` 曾整体失效:官方 system 文件在 `library/` 下,由完整官方树
8
- 按字节拷贝,而选项编辑只作用于 `_resolve_target` 能定位到 staging 的文件 → 声明式编辑
9
- 从未落到 system 文件,导出工程仍依赖外部晶振(HSE)。
10
- """
11
- from __future__ import annotations
12
-
13
- import concurrent.futures
14
- import json
15
- import re
16
-
17
- from ht32_code_mcp import server as srv
18
-
19
- CFG_F52352 = "ht32f52352.ht32f52352-sk.ac5.hsi-safe.retarget"
20
- CFG_F50241 = "ht32f50241.ht32f50241-sk.ac5.hsi-safe.retarget"
21
- CFG_L52241 = "ht32l52241.ht32l52241-sk.ac5.hsi-safe.retarget"
22
-
23
-
24
- def test_cloud_zip_response_formats_expiry(tmp_path, monkeypatch):
25
- """云端模式必须能给出下载 URL 与有效期(不得因未定义的 helper 直接抛 NameError)。"""
26
- monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
27
- monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
28
- monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "http://example.test/ht32-bundles")
29
- text = srv.export_project_bundle(config_id=CFG_F52352, project_name="CloudMode")
30
- assert "导出失败" not in text, text
31
- assert "下载 URL" in text and "http://example.test/ht32-bundles/" in text
32
- assert "有效期" in text
33
- assert list(tmp_path.joinpath("bundles").glob("*.zip")), "未生成 zip 产物"
34
-
35
-
36
- def test_concurrent_exports_do_not_cross_talk(tmp_path):
37
- """并发导出必须各自落到自己的目录(不得因共享实例状态串台)。"""
38
- jobs = [(CFG_F50241, "ConcA"), (CFG_L52241, "ConcB")]
39
-
40
- def run(item):
41
- idx, (cid, name) = item
42
- return srv.export_project_bundle(
43
- config_id=cid, project_name=name, out_dir=str(tmp_path / f"out{idx}"))
44
-
45
- with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
46
- texts = list(pool.map(run, enumerate(jobs)))
47
-
48
- for text, (_, name) in zip(texts, jobs):
49
- assert "导出失败" not in text, text
50
- for idx, (_, name) in enumerate(jobs):
51
- proj = tmp_path / f"out{idx}" / "project" / "MDK_ARMv5" / f"{name}.uvprojx"
52
- assert proj.is_file(), f"并发导出产物缺失:{proj}"
53
-
54
-
55
- def test_hsi_safe_profile_edits_system_file_in_library_tree(tmp_path):
56
- """hsi-safe 默认 profile 必须真正改写官方 system 文件(不得因 tree 拷贝路径整条跳过)。
57
-
58
- F50241 的官方 system 文件属 hse-direct 家族(HCLK_SRC=2/HSE),编辑器应把它改成
59
- HSE_ENABLE=0 + HCLK_SRC=3(HSI,20 MHz 满频),并在 deviations 留 option-edit 记录。
60
- """
61
- out = tmp_path / "out"
62
- text = srv.export_project_bundle(config_id=CFG_F50241, project_name="HsiSafe",
63
- out_dir=str(out))
64
- assert "导出失败" not in text, text
65
-
66
- # 完整官方树里有 ~20 个 system_*.c;只有本配置选中的那个应被改写
67
- dev = json.loads((out / "manifest" / "deviations.json").read_text(encoding="utf-8"))
68
- edits = [d for d in dev["deviations"] if d["kind"] == "option-edit"]
69
- assert {d["selector"] for d in edits} >= {"HSE_ENABLE", "HCLK_SRC"}, f"缺 option-edit 记录:{edits}"
70
- assert len({d["file"] for d in edits}) == 1, f"option-edit 跨了多个文件:{edits}"
71
- sys_name = edits[0]["file"]
72
-
73
- target = next((p for p in out.glob("library/Device/**/system_*.c") if p.name == sys_name), None)
74
- assert target is not None, f"导出物里找不到 {sys_name}"
75
- code = target.read_text(encoding="utf-8", errors="replace")
76
- assert re.search(r"#define\s+HSE_ENABLE\s+\(0\)", code), "HSE_ENABLE 未被改写为 0"
77
- assert re.search(r"#define\s+HCLK_SRC\s+\(3\)", code), "HCLK_SRC 未被改写为 3(HSI)"
78
-
79
- files = json.loads((out / "manifest" / "files.json").read_text(encoding="utf-8"))
80
- paths = [f["path"] for f in files["files"]]
81
- assert len(paths) == len(set(paths)), "同一路径被重复登记(树拷贝 + 源集各一次)"
82
- entry = next(f for f in files["files"] if f["path"].endswith("/" + sys_name))
83
- assert entry.get("edited") is True, "system 文件的 manifest 条目未标记 edited"
File without changes
File without changes
File without changes
File without changes