ht32-code-mcp 0.4.2__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.2 → ht32_code_mcp-0.4.4}/PKG-INFO +1 -1
  2. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/pyproject.toml +1 -1
  3. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/materializer.py +90 -13
  4. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/server.py +8 -2
  5. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/usage.py +5 -2
  6. ht32_code_mcp-0.4.4/tests/test_export_cloud_mode.py +176 -0
  7. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/tests/test_tools.py +19 -3
  8. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/uv.lock +1 -1
  9. ht32_code_mcp-0.4.2/tests/test_export_cloud_mode.py +0 -83
  10. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/.gitignore +0 -0
  11. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/.gitkeep +0 -0
  12. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/.pytest_base_final_run.log +0 -0
  13. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/DEPLOY.md +0 -0
  14. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/README.md +0 -0
  15. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/e2e_scenario_a.py +0 -0
  16. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/e2e_scenario_b.py +0 -0
  17. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/smoke_http.py +0 -0
  18. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/smoke_stdio.py +0 -0
  19. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/__init__.py +0 -0
  20. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/__main__.py +0 -0
  21. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/exporter.py +0 -0
  22. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/index.py +0 -0
  23. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/knowledge.py +0 -0
  24. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/release.py +0 -0
  25. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/tests/conftest.py +0 -0
  26. {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/tests/test_knowledge_grounding.py +0 -0
  27. {ht32_code_mcp-0.4.2 → 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.2
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.2"
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
  "",
@@ -98,7 +98,8 @@ M0+(HT32F5 / L5)、M3(HT32F1 / F175x-275x)、M4(F490x1 / F491x3 / F493
98
98
  - 知识库缺了用户需要的内容,你只能用推测回答
99
99
  - 导出工程有缺陷(编译失败、型号参数错、缺文件)
100
100
  - **ht32-skills(编排层:流程/脚本/模板/环境检测)本身有缺陷**,用 issue_type=skill_defect
101
- 上报(本渠道是 skill 问题的统一入口),details 里附 ht32-skills 版本号与现象
101
+ 上报(本渠道是 skill 问题的统一入口),`skill_version` 传本机 `ht32.py version` 的版本号,
102
+ details 里附现象与日志路径
102
103
  务必附 model_id;submit_feedback 之外的调用已由服务端自动记入用量日志,无需你重复上报。
103
104
  """
104
105
 
@@ -561,6 +562,7 @@ def submit_feedback(
561
562
  details: str = "",
562
563
  model_id: str = "",
563
564
  tools_called: list[str] | None = None,
565
+ skill_version: str = "",
564
566
  ) -> str:
565
567
  """提交纠正反馈,改进 HT32 代码知识库 / ht32-skills 编排层。
566
568
 
@@ -576,9 +578,12 @@ def submit_feedback(
576
578
  issue_type: 问题类型,枚举上述其一(skill_defect = ht32-skills 编排层缺陷)
577
579
  answer: 你给出的回答原文
578
580
  correction: 你认为正确的信息或用户指出的正确内容
579
- details: 补充上下文(如编译报错原文;skill_defect 请附 ht32-skills 版本号与现象)
581
+ details: 补充上下文(如编译报错原文、复现步骤、日志路径)
580
582
  model_id: 你的模型标识,如 deepseek-v4-flash
581
583
  tools_called: 本次调过的查询工具列表(不含 submit_feedback 自身)
584
+ skill_version: 本机 ht32-skills 版本,取 `ht32.py version` 的 skill_version
585
+ (如 "v0.1.19")。服务端跨进程拿不到客户端 skill 版本,**skill_defect 必填**,
586
+ 否则无从判断缺陷属于哪个版本
582
587
  """
583
588
  if not question or not question.strip():
584
589
  return "错误:question 参数不能为空,请提供用户原始需求。"
@@ -595,6 +600,7 @@ def submit_feedback(
595
600
  tools_called=tools_called,
596
601
  mcp_version=_MCP_VERSION,
597
602
  kb_version=index.data.get("code_version", ""),
603
+ skill_version=skill_version,
598
604
  )
599
605
  if path is None:
600
606
  return ("错误:反馈未能写入。服务端可能未配置 FEEDBACK_ROOT,"
@@ -9,7 +9,8 @@
9
9
  - **tool_call**(`UsageMiddleware`,服务端自动):拦截所有工具调用,
10
10
  记录工具名、参数原文、耗时、成败。不依赖 Agent 配合,数据完整。
11
11
  - **feedback**(`record_feedback`,由各 server 的 `submit_feedback` 工具调用):
12
- 记录 Agent 主动上报的语义性问题,middleware 无法判断这些。
12
+ 记录 Agent 主动上报的语义性问题,middleware 无法判断这些。其中 `skill_version`
13
+ 由 Agent 自报(服务端跨进程拿不到客户端 skill 版本),用于定位 ht32-skills 缺陷版本。
13
14
 
14
15
  目录布局(同根 + 服务子目录):
15
16
 
@@ -38,7 +39,7 @@ from typing import Any
38
39
  from fastmcp.server.middleware import Middleware, MiddlewareContext
39
40
 
40
41
  # 实现版本号:两仓库副本必须一致(各仓测试会校验,防止只改一边造成漂移)
41
- _IMPL_VERSION = "1.1.0"
42
+ _IMPL_VERSION = "1.2.0"
42
43
 
43
44
  # 服务名 → 子目录名。新增服务在此登记。
44
45
  SERVICE_WIKI = "wiki"
@@ -203,6 +204,7 @@ def record_feedback(
203
204
  tools_called: list[str] | None = None,
204
205
  mcp_version: str = "",
205
206
  kb_version: str = "",
207
+ skill_version: str = "",
206
208
  ) -> Path | None:
207
209
  """记录一条 Agent 主动提交的反馈。"""
208
210
  record = {
@@ -219,5 +221,6 @@ def record_feedback(
219
221
  "tools_called": [t for t in (tools_called or []) if t and t != "submit_feedback"],
220
222
  "mcp_version": mcp_version,
221
223
  "kb_version": kb_version,
224
+ "skill_version": skill_version.strip(),
222
225
  }
223
226
  return write_record(log_dir, FEEDBACK_PREFIX, record)
@@ -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")
@@ -677,7 +679,7 @@ def test_version_reports_skill_latest(monkeypatch):
677
679
  def test_usage_impl_version_pinned():
678
680
  """两仓库的 usage.py 是逐字节相同副本,实现版本号用于防止只改一边。"""
679
681
  from ht32_code_mcp import usage
680
- assert usage._IMPL_VERSION == "1.1.0"
682
+ assert usage._IMPL_VERSION == "1.2.0"
681
683
  assert usage.SERVICE_CODE == "code"
682
684
  assert usage.SERVICE_WIKI == "wiki"
683
685
  # 噪声参数含 out_dir(code 特有:可能含用户本地路径)
@@ -735,6 +737,7 @@ def test_submit_feedback_writes_jsonl(tmp_path, monkeypatch):
735
737
  details="编译报错 too few arguments",
736
738
  model_id="deepseek-v4-flash",
737
739
  tools_called=["search_code_knowledge", "submit_feedback", "get_file"],
740
+ skill_version="v0.1.19",
738
741
  )
739
742
  assert "反馈已记录" in out
740
743
  f = next((tmp_path / "logs").glob("feedback_*.jsonl"))
@@ -747,6 +750,8 @@ def test_submit_feedback_writes_jsonl(tmp_path, monkeypatch):
747
750
  # submit_feedback 自身被过滤,只留查询类工具
748
751
  assert rec["tools_called"] == ["search_code_knowledge", "get_file"]
749
752
  assert rec["mcp_version"] and rec["kb_version"]
753
+ # 客户端 skill 版本是结构化字段(不再靠 details 散文),未传时为空串而非缺键
754
+ assert rec["skill_version"] == "v0.1.19"
750
755
 
751
756
 
752
757
  def test_submit_feedback_accepts_skill_defect(tmp_path, monkeypatch):
@@ -757,11 +762,22 @@ def test_submit_feedback_accepts_skill_defect(tmp_path, monkeypatch):
757
762
  issue_type="skill_defect",
758
763
  details="ht32-skills v0.1.0;导出后 uvprojx 未按工程名重命名",
759
764
  model_id="deepseek-v4-flash",
765
+ skill_version="v0.1.0",
760
766
  )
761
767
  assert "skill_defect" in out
762
768
  f = next((tmp_path / "logs").glob("feedback_*.jsonl"))
763
769
  rec = json.loads(f.read_text(encoding="utf-8").splitlines()[0])
764
770
  assert rec["issue_type"] == "skill_defect"
771
+ assert rec["skill_version"] == "v0.1.0"
772
+
773
+
774
+ def test_feedback_skill_version_key_always_present(tmp_path, monkeypatch):
775
+ """没传 skill_version 时字段仍存在(空串),日志消费方不必分支判键。"""
776
+ monkeypatch.setattr(srv, "LOG_DIR", str(tmp_path / "logs"))
777
+ srv.submit_feedback(question="x", issue_type="other")
778
+ f = next((tmp_path / "logs").glob("feedback_*.jsonl"))
779
+ rec = json.loads(f.read_text(encoding="utf-8").splitlines()[0])
780
+ assert rec["skill_version"] == ""
765
781
 
766
782
 
767
783
  def test_submit_feedback_empty_question(tmp_path, monkeypatch):
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.4.2"
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