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.
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/PKG-INFO +1 -1
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/pyproject.toml +1 -1
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/materializer.py +90 -13
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/server.py +8 -2
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/usage.py +5 -2
- ht32_code_mcp-0.4.4/tests/test_export_cloud_mode.py +176 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/tests/test_tools.py +19 -3
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/uv.lock +1 -1
- ht32_code_mcp-0.4.2/tests/test_export_cloud_mode.py +0 -83
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/.gitignore +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/.gitkeep +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/.pytest_base_final_run.log +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/DEPLOY.md +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/README.md +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/smoke_http.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/tests/test_knowledge_grounding.py +0 -0
- {ht32_code_mcp-0.4.2 → ht32_code_mcp-0.4.4}/tests/test_recipe_claims.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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/` |
|
|
1152
|
-
"
|
|
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
|
|
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:
|
|
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.
|
|
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>
|
|
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
|
-
|
|
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.
|
|
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):
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|