ht32-code-mcp 0.3.2__tar.gz → 0.4.0__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 (25) hide show
  1. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/PKG-INFO +1 -1
  2. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/pyproject.toml +1 -1
  3. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/exporter.py +8 -4
  4. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/materializer.py +128 -20
  5. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/server.py +10 -3
  6. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/test_tools.py +84 -0
  7. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/uv.lock +1 -1
  8. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/.gitignore +0 -0
  9. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/.gitkeep +0 -0
  10. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/.pytest_base_final_run.log +0 -0
  11. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/DEPLOY.md +0 -0
  12. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/README.md +0 -0
  13. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/e2e_scenario_a.py +0 -0
  14. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/e2e_scenario_b.py +0 -0
  15. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/smoke_http.py +0 -0
  16. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/smoke_stdio.py +0 -0
  17. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__init__.py +0 -0
  18. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__main__.py +0 -0
  19. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/index.py +0 -0
  20. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/knowledge.py +0 -0
  21. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/release.py +0 -0
  22. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/usage.py +0 -0
  23. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/conftest.py +0 -0
  24. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/test_export_cloud_mode.py +0 -0
  25. {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/test_knowledge_grounding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.3.2
3
+ Version: 0.4.0
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.3.2"
7
+ version = "0.4.0"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -69,7 +69,7 @@ class ProjectExporter:
69
69
  def export_dir(self, *, out_dir: str, project_name: str = "", config_id: str = "",
70
70
  chip: str = "", device: str = "", board: str = "",
71
71
  clock_profile: str = "", retarget_profile: str = "",
72
- include_host_tools: bool = False) -> ExportResult:
72
+ include_host_tools: bool = True) -> ExportResult:
73
73
  r = self.resolve(config_id=config_id, chip=chip, device=device, board=board,
74
74
  clock_profile=clock_profile, retarget_profile=retarget_profile)
75
75
  res = self.materializer.materialize(
@@ -81,7 +81,7 @@ class ProjectExporter:
81
81
  project_name: str = "", config_id: str = "", chip: str = "",
82
82
  device: str = "", board: str = "", clock_profile: str = "",
83
83
  retarget_profile: str = "",
84
- include_host_tools: bool = False) -> ExportResult:
84
+ include_host_tools: bool = True) -> ExportResult:
85
85
  r = self.resolve(config_id=config_id, chip=chip, device=device, board=board,
86
86
  clock_profile=clock_profile, retarget_profile=retarget_profile)
87
87
  bdir = Path(bundle_dir).expanduser().resolve()
@@ -111,7 +111,9 @@ class ProjectExporter:
111
111
  lock = res.out_dir / "manifest" / "project-lock.json"
112
112
  manifest_rel = "manifest/project-lock.json"
113
113
  requires = {
114
- "pack": r.recipe["pack"]["id"],
114
+ "pack": res.pack["id"],
115
+ "pack_min_version": res.pack["min_version"],
116
+ "pack_file": res.pack["rel"],
115
117
  "pack_note": r.recipe["pack"]["device_macro_note"],
116
118
  "keil": "MDK-ARM 5.x (AC5)",
117
119
  }
@@ -151,7 +153,9 @@ class ProjectExporter:
151
153
  + (f";冲突记录 {len(mem['conflicts'])} 条" if mem["conflicts"] else ""),
152
154
  f"- 文件数/字节: {res.files} / {res.bytes}",
153
155
  f"- 树 SHA256: {res.sha256}",
154
- f"- 前置: {r.recipe['pack']['device_macro_note']}",
156
+ f"- 前置 DFP: 随包携带 `{res.pack['rel']}`({res.pack['id']} v{res.pack['version']},"
157
+ f"SHA256 {res.pack['sha256'][:16]}…);本机已装同名 DFP 且版本 "
158
+ f">= {res.pack['min_version']} 可跳过安装",
155
159
  ]
156
160
  if r.retarget_profile == "retarget":
157
161
  claims = cfg["retarget"]["uart_pin_claims"]
@@ -35,7 +35,13 @@ PACK_REQUIRED = "PACK_REQUIRED"
35
35
  CONFIG_CLOSURE_FAILED = "CONFIG_CLOSURE_FAILED"
36
36
  RECIPE_MISMATCH = "RECIPE_MISMATCH"
37
37
 
38
- HOST_TOOL_DIRS = {"gsar", "gsars", "elink", "elink32pro", "pack"}
38
+ # host 组件目录(官方包 utilities/ 下):独立 host 工具,opt-out 时排除。
39
+ # pack/ 不在此列——DFP 安装包由 recipe 的 pack.asset 显式物化锁定版本,
40
+ # 官方包内那份未必是锁定版本(L5 包内为 1.0.77),故一律不直接拷贝。
41
+ HOST_TOOL_DIRS = {"gsar", "gsars", "elink", "elink32pro"}
42
+
43
+ # <Vendor>.<Name>.<Major.Minor.Patch>;版本号取末尾三段,故不能用 rsplit(".", 1)
44
+ PACK_ID_RE = re.compile(r"^(?P<vendor>[^.]+)\.(?P<name>.+?)\.(?P<version>\d+\.\d+\.\d+)$")
39
45
 
40
46
  # 不得外发的文件/目录(隐私与卫生,W4;由 manifest 驱动而非目录黑名单)
41
47
  FORBIDDEN_PATTERNS = [
@@ -67,6 +73,28 @@ def sha256_file(path: Path) -> str:
67
73
  return h.hexdigest()
68
74
 
69
75
 
76
+ def parse_version(text: str) -> tuple[int, ...]:
77
+ """"1.0.78" -> (1, 0, 78);DFP 版本只做数字段比较(用户口径:相同或更高即可)。"""
78
+ return tuple(int(x) for x in re.findall(r"\d+", text or "")[:3]) or (0,)
79
+
80
+
81
+ def pack_release_version(pack_path: Path) -> str:
82
+ """读 .pack(zip)内 pdsc 的首个 <release> 版本号 = 该安装包提供的 DFP 版本。"""
83
+ try:
84
+ with zipfile.ZipFile(pack_path) as z:
85
+ names = [n for n in z.namelist() if n.lower().endswith(".pdsc")]
86
+ if not names:
87
+ raise ContractError(PACK_REQUIRED,
88
+ f"{pack_path.name} 内不含 pdsc,无法确认 DFP 版本")
89
+ data = z.read(names[0])
90
+ except zipfile.BadZipFile as e: # 截断/损坏的 pack 不能被当成有效资产
91
+ raise ContractError(PACK_REQUIRED, f"{pack_path.name} 不是有效的 pack(zip): {e}")
92
+ m = re.search(rb'<release version="([0-9]+\.[0-9]+\.[0-9]+)"', data)
93
+ if not m:
94
+ raise ContractError(PACK_REQUIRED, f"{pack_path.name} 的 pdsc 未声明 release 版本")
95
+ return m.group(1).decode()
96
+
97
+
70
98
  # ------------------------------------------------------------------ recipe 装载
71
99
  def load_recipes(recipes_dir: Path) -> dict[str, dict]:
72
100
  recipes: dict[str, dict] = {}
@@ -374,6 +402,7 @@ class MaterialResult:
374
402
  manifest_dir: str
375
403
  warnings: list[str]
376
404
  deviations: list[dict]
405
+ pack: dict
377
406
 
378
407
 
379
408
  class Materializer:
@@ -426,14 +455,63 @@ class Materializer:
426
455
  notes.append(f"origin={origin['kind']} package={origin['package']}")
427
456
  return notes
428
457
 
458
+ # ------------------------------------------------------------ 随包 DFP(存在性检查)
459
+ def _resolve_pack(self, r: Resolved) -> dict:
460
+ """解析并校验 recipe 声明的 DFP 安装包资产(导出工程默认随包携带)。
461
+
462
+ 三项机器校验缺一不可,任一不成立即 PACK_REQUIRED(不静默降级、不跳过):
463
+ 1. 资产存在(仓库内 .pack 文件,随仓库检出);
464
+ 2. 资产 SHA256 == recipe 的 asset_sha256(来源锁未破);
465
+ 3. pack 内 pdsc 声明的 DFP 版本 >= 要求版本,且与锁定 id 的版本段一致
466
+ (保证工程 <PackID> 与随包安装的 DFP 版本对得上)。
467
+ """
468
+ spec = r.recipe["pack"]
469
+ if spec.get("install_policy") != "bundled":
470
+ raise ContractError(
471
+ PACK_REQUIRED,
472
+ f"{r.config_id}: pack.install_policy={spec.get('install_policy')!r} 不支持"
473
+ "(本契约只接受 bundled:DFP 必须随包携带)")
474
+ asset_rel = spec.get("asset") or ""
475
+ asset = (self.root / asset_rel).resolve() if asset_rel else None
476
+ if not asset_rel or not asset or not asset.is_file():
477
+ raise ContractError(
478
+ PACK_REQUIRED,
479
+ f"{r.config_id}: 随包 DFP 资产缺失(recipe pack.asset={asset_rel or '未声明'});"
480
+ "确认 .work/template-recipes/assets/packs/ 已随仓库检入")
481
+ digest = sha256_file(asset)
482
+ if spec.get("asset_sha256") and digest != spec["asset_sha256"]:
483
+ raise ContractError(
484
+ PACK_REQUIRED,
485
+ f"{r.config_id}: 随包 DFP 资产 SHA256 不符:{asset.name} 实测 {digest[:16]}…,"
486
+ f"recipe 锁 {spec['asset_sha256'][:16]}…(资产被替换或损坏)")
487
+ version = pack_release_version(asset)
488
+ min_version = spec.get("min_version") or ""
489
+ if parse_version(version) < parse_version(min_version):
490
+ raise ContractError(
491
+ PACK_REQUIRED,
492
+ f"{r.config_id}: 随包 DFP 版本 {version} 低于要求 {min_version}")
493
+ pid = PACK_ID_RE.match(spec["id"])
494
+ if not pid or pid.group("version") != version:
495
+ raise ContractError(
496
+ PACK_REQUIRED,
497
+ f"{r.config_id}: 随包 DFP 版本 {version} 与锁定 id {spec['id']} 不一致"
498
+ "(工程 <PackID> 将指向未随包的版本)")
499
+ return dict(id=spec["id"], version=version, min_version=min_version,
500
+ asset=asset, rel=f"utilities/pack/{asset.name}",
501
+ sha256=digest, bytes=asset.stat().st_size)
502
+
429
503
  # ------------------------------------------------------------ 文件计划
430
504
  def _copy_official_tree(self, src: Path, dst: Path, manifest_files: list[dict],
431
- pkg_name: str, *, skip_host_tools: bool = True) -> None:
505
+ pkg_name: str, *, skip_host_tools: bool = True,
506
+ skip_top: set[str] | None = None) -> None:
432
507
  for p in sorted(src.rglob("*")):
433
508
  if not p.is_file():
434
509
  continue
435
510
  rel = p.relative_to(src).as_posix()
436
- if skip_host_tools and rel.split("/")[0] in HOST_TOOL_DIRS:
511
+ top = rel.split("/")[0]
512
+ if top in (skip_top or set()):
513
+ continue
514
+ if skip_host_tools and top in HOST_TOOL_DIRS:
437
515
  continue
438
516
  target = dst / rel
439
517
  target.parent.mkdir(parents=True, exist_ok=True)
@@ -444,11 +522,13 @@ class Materializer:
444
522
  origin=f"{pkg_name}/{src.relative_to(self.raw / pkg_name).as_posix()}/{rel}"))
445
523
 
446
524
  def materialize(self, r: Resolved, out_dir: str | Path, *, project_name: str = "",
447
- include_host_tools: bool = False, verify_pack: bool = False) -> MaterialResult:
525
+ include_host_tools: bool = True) -> MaterialResult:
448
526
  out = Path(out_dir).expanduser().resolve()
449
527
  if out.exists() and any(out.iterdir()):
450
528
  raise ContractError("TARGET_EXISTS", f"目标目录非空,拒绝覆盖:{out}")
451
529
  notes = self.preflight(r)
530
+ # 随包 DFP:存在性 / 资产 SHA256 / 版本 >= min_version 三项校验,先于 staging
531
+ pack = self._resolve_pack(r)
452
532
  cfg = r.config
453
533
  origin = cfg["origin"]
454
534
  pkg = self.raw / origin["package"]
@@ -466,7 +546,7 @@ class Materializer:
466
546
  deviations: list[dict] = []
467
547
  try:
468
548
  self._build_tree(r, stage, stem, kernel, board_dir, manifest_files,
469
- deviations, include_host_tools)
549
+ deviations, include_host_tools, pack)
470
550
  # 产物卫生终检(W4):不得外发的模式
471
551
  bad = [f["path"] for f in manifest_files
472
552
  if any(re.search(p, f["path"]) for p in FORBIDDEN_PATTERNS)]
@@ -481,7 +561,7 @@ class Materializer:
481
561
  mdir = stage / "manifest"
482
562
  mdir.mkdir(parents=True, exist_ok=True)
483
563
  self._write_manifests(r, mdir, stem, manifest_files, deviations, notes,
484
- total_bytes, tree_hash, len(manifest_files))
564
+ total_bytes, tree_hash, len(manifest_files), pack)
485
565
  # 原子发布(Windows 上目录重命名可能被 AV/索引器瞬态拒绝,长退避重试)
486
566
  last_err = None
487
567
  for attempt in range(10):
@@ -505,12 +585,12 @@ class Materializer:
505
585
  return MaterialResult(out_dir=out, project_file=self._local.project_rel,
506
586
  files=len(manifest_files), bytes=total_bytes,
507
587
  sha256=tree_hash, manifest_dir="manifest",
508
- warnings=r.warnings, deviations=deviations)
588
+ warnings=r.warnings, deviations=deviations, pack=pack)
509
589
 
510
590
  # ------------------------------------------------------------ 树构建
511
591
  def _build_tree(self, r: Resolved, stage: Path, stem: str, kernel: str,
512
592
  board_dir: str, manifest_files: list[dict], deviations: list[dict],
513
- include_host_tools: bool) -> None:
593
+ include_host_tools: bool, pack: dict) -> None:
514
594
  cfg = r.config
515
595
  origin = cfg["origin"]
516
596
  pkg = self.raw / origin["package"]
@@ -520,7 +600,7 @@ class Materializer:
520
600
  lib_src = pkg / ("libraries" if kernel == "m4" else "library")
521
601
  lib_dst = stage / "library"
522
602
  self._copy_official_tree(lib_src, lib_dst, manifest_files, origin["package"])
523
- # 2) utilities:官方构建资产;host 工具默认排除(D2 已授权,opt-in 携带)
603
+ # 2) utilities:官方构建资产;host 工具(gsar/eLink32pro)默认随包
524
604
  if kernel == "m4":
525
605
  board_asset = r.recipe.get("board_asset_dir") or board_dir
526
606
  util_src = pkg / "project" / board_asset
@@ -529,14 +609,33 @@ class Materializer:
529
609
  util_src = pkg / "utilities"
530
610
  util_dst = stage / "utilities"
531
611
  if util_src.is_dir():
612
+ # 官方包内的 DFP 安装包(utilities/pack/)不直接拷:L5 包内是 1.0.77,
613
+ # 与锁定版本不符;统一由 recipe 的 pack.asset 物化锁定版本(见 2b)
532
614
  self._copy_official_tree(util_src, util_dst, manifest_files, origin["package"],
533
- skip_host_tools=not include_host_tools)
615
+ skip_host_tools=not include_host_tools,
616
+ skip_top={"pack"})
534
617
  if not include_host_tools and kernel != "m4":
535
618
  deviations.append(dict(
536
619
  kind="host-tools-excluded",
537
- detail="utilities/{gsar,elink32pro,pack} 为独立 host 组件,默认 profile 不分发"
538
- "(D2 已授权可分发;opt-in include_host_tools 携带)",
620
+ detail="utilities/{gsar,elink32pro} 为独立 host 组件,本 profile 不分发"
621
+ "(D2 已授权可分发;include_host_tools=true 时携带)",
539
622
  reason="report section 4.7 project boundary; licensing approved (D2)"))
623
+ # 2b) 随包 DFP 安装包(已通过存在性/SHA256/版本三项校验)
624
+ pack_dst = stage / pack["rel"]
625
+ pack_dst.parent.mkdir(parents=True, exist_ok=True)
626
+ shutil.copyfile(pack["asset"], pack_dst)
627
+ manifest_files.append(dict(
628
+ path=pack["rel"], bytes=pack["bytes"], sha256=pack["sha256"],
629
+ classification="official",
630
+ origin=f"vendor-pack:{pack['id']}(Holtek 官方 DFP 安装包,"
631
+ "https://www.holtek.com.tw/pack)"))
632
+ deviations.append(dict(
633
+ kind="dfp-pack-bundled",
634
+ file=pack["rel"],
635
+ detail=f"{pack['id']} v{pack['version']},{pack['bytes']} B,"
636
+ f"SHA256 {pack['sha256'][:16]}…(源 holtek.com.tw/pack,资产冻结在仓库)",
637
+ reason="用户决策 2026-10-18:DFP 默认随范例工程携带,免去用户自行寻找安装包;"
638
+ f"本机已装同名 DFP 且版本 >= {pack['min_version']} 时可跳过安装"))
540
639
  # 3) licenses:官方许可文件
541
640
  lic_dst = stage / "licenses"
542
641
  lic_dst.mkdir(parents=True, exist_ok=True)
@@ -646,7 +745,7 @@ class Materializer:
646
745
  origin=str(uvoptx_src.relative_to(self.root).as_posix())))
647
746
 
648
747
  # 6) README(用户工程说明,与真实策略一致)
649
- readme = self._readme(r, stem)
748
+ readme = self._readme(r, stem, pack)
650
749
  (stage / "README.md").write_text(readme, encoding="utf-8")
651
750
  manifest_files.append(dict(path="README.md", bytes=len(readme.encode()),
652
751
  sha256=sha256_bytes(readme.encode()),
@@ -801,7 +900,7 @@ class Materializer:
801
900
  # ------------------------------------------------------------ manifest
802
901
  def _write_manifests(self, r: Resolved, mdir: Path, stem: str, files: list[dict],
803
902
  deviations: list[dict], notes: list[str], total_bytes: int,
804
- tree_hash: str, file_count: int) -> None:
903
+ tree_hash: str, file_count: int, pack: dict) -> None:
805
904
  cfg = r.config
806
905
  lock = {
807
906
  "contract_version": CONTRACT_VERSION,
@@ -814,8 +913,14 @@ class Materializer:
814
913
  "profiles": {"clock": r.clock_profile, "retarget": r.retarget_profile},
815
914
  "origin": cfg["origin"],
816
915
  "memory": cfg["memory"],
817
- "pack": r.recipe["pack"],
818
- "requires": {"pack": r.recipe["pack"]["id"],
916
+ "pack": {"id": pack["id"], "version": pack["version"],
917
+ "min_version": pack["min_version"], "file": pack["rel"],
918
+ "sha256": pack["sha256"], "bytes": pack["bytes"],
919
+ "source": "Holtek 官方 pack(https://www.holtek.com.tw/pack)",
920
+ "install_hint": "首次使用在本工程内双击安装;"
921
+ f"本机已装同名 DFP 且版本 >= {pack['min_version']} 时可跳过"},
922
+ "requires": {"pack": pack["id"],
923
+ "pack_min_version": pack["min_version"],
819
924
  "pack_note": r.recipe["pack"]["device_macro_note"],
820
925
  "keil": "MDK-ARM 5.x (AC5), UV4 command line verified"},
821
926
  "licensing": cfg["licensing"],
@@ -839,7 +944,7 @@ class Materializer:
839
944
  "compile_command": f'UV4 -b "{self._local.project_rel}" -j0 -o build.log',
840
945
  }, ensure_ascii=False, indent=1), encoding="utf-8")
841
946
 
842
- def _readme(self, r: Resolved, stem: str) -> str:
947
+ def _readme(self, r: Resolved, stem: str, pack: dict) -> str:
843
948
  cfg = r.config
844
949
  claims = cfg["retarget"]["uart_pin_claims"]
845
950
  kernel = "m4" if r.series in ("ht32f493x5", "ht32f490x1", "ht32f491x3") else "m0m3"
@@ -855,8 +960,10 @@ class Materializer:
855
960
  f"- Keil MDK-ARM 5.x,编译器 **AC5(armcc)**(本地以 ARMCC V5.06 update 4/7 实测)"
856
961
  f"——**本工程暂不支持 AC6(armclang)**,请在 Options for Target → Target 保持"
857
962
  f"编译器版本为 AC5;切到 AC6 可能在编译/链接阶段报错",
858
- f"- {r.recipe['pack']['device_macro_note']}(manifest 锁定 "
859
- f"`{r.recipe['pack']['id']}`)",
963
+ f"- DFP(器件支持包)**已随本工程提供**:`{pack['rel']}`"
964
+ f"({pack['id']},版本 {pack['version']},SHA256 `{pack['sha256'][:16]}…`)"
965
+ f"——首次使用在本工程内双击该文件安装(或 Keil Pack Installer → File → Import);"
966
+ f"**本机已装同名 DFP 且版本 ≥ {pack['min_version']} 时可跳过安装**",
860
967
  "",
861
968
  "## 打开与编译",
862
969
  "",
@@ -894,7 +1001,8 @@ class Materializer:
894
1001
  "| 目录 | 内容 |",
895
1002
  "|---|---|",
896
1003
  "| `library/` | 完整官方库(字节冻结,manifest/files.json 可逐文件核对) |",
897
- "| `utilities/` | 官方板级/公共构建资产(默认不含 gsar/eLink32pro/pack 等 host 工具) |",
1004
+ "| `utilities/` | 官方板级/公共构建资产 + host 工具(`gsar/`、`elink32pro/`)"
1005
+ "+ DFP 安装包(`pack/`,见上「前置条件」) |",
898
1006
  "| `project/MDK_ARMv5/` | 工程文件(由官方逐 IC 工程声明式物化) |",
899
1007
  "| `project/Src_USER/` | 用户源码:自有 `main.c` + 官方配置/中断文件 |",
900
1008
  "| `licenses/` | 官方许可文件 |",
@@ -441,6 +441,9 @@ def get_project_manifest() -> str:
441
441
  "工程文件按工程名重命名为 <project_name>.uvprojx,模板自用文件"
442
442
  "(devices.json / 模板维护文档)不外发,根目录改为生成属于用户工程的 README.md;"
443
443
  "project_name 缺省为型号——**应先向用户确认工程名**,用户同意后才用型号兜底。")
444
+ lines.append("DFP 随包:导出工程 `utilities/pack/` 内即为 recipe 锁定的官方 DFP 安装包"
445
+ "(版本与资产 SHA256 在导出前机器校验,不符即报 PACK_REQUIRED),"
446
+ "用户首次使用在本工程内安装即可;已装同名 DFP 且版本不低于要求版本时可跳过。")
444
447
  lines.append("交付方式:" + (
445
448
  f"云端 zip 下载(随机 token + {BUNDLE_TTL}s TTL,返回下载 URL,out_dir 忽略)"
446
449
  if CLOUD_BUNDLE else "本地目录导出(out_dir 必填)"))
@@ -457,7 +460,7 @@ def export_project_bundle(
457
460
  board: str = "",
458
461
  clock_profile: str = "",
459
462
  retarget_profile: str = "",
460
- include_host_tools: bool = False,
463
+ include_host_tools: bool = True,
461
464
  ) -> str:
462
465
  """导出自包含可编译 Keil 工程(v2 契约:recipe 物化 + manifest + 原子发布)。
463
466
 
@@ -467,6 +470,10 @@ def export_project_bundle(
467
470
  响应含 contract_version/config_id/recipe_revision/device/board/compiler/
468
471
  profiles/工程文件/manifest/树 SHA256/前置依赖/warnings/validation。
469
472
 
473
+ DFP(器件支持包)随包携带:工程 `utilities/pack/` 内即为 recipe 锁定的官方 DFP
474
+ 安装包(资产 SHA256 与版本在物化前机器校验,不符即 PACK_REQUIRED 报错),
475
+ 用户首次使用在本工程内安装即可;本机已装同名 DFP 且版本不低于要求版本时可跳过。
476
+
470
477
  两种交付形态(由服务端环境决定,无需调用方判断):
471
478
  - 云端模式:返回带 TTL 的 zip 下载 URL(含 SHA256 与大小),out_dir 被忽略。
472
479
  - 本地模式:物化到 out_dir(须为空目录或不存在;不会覆盖已有内容)。
@@ -485,8 +492,8 @@ def export_project_bundle(
485
492
  out_dir: 输出目录(本地模式必填;云端模式忽略)。
486
493
  project_name: 工程名(可选)——工程文件名主干、Target 名与输出名;
487
494
  缺省取型号;**调用方应先向用户确认工程名**。
488
- include_host_tools: 是否随包携带 host 工具(gsar/eLink32pro/pack,
489
- 默认不携带;D2 已授权可分发)。
495
+ include_host_tools: 是否随包携带 host 工具(gsar/eLink32pro;默认 True,
496
+ D2 已授权可分发)。DFP 安装包不受此开关影响——它始终随包携带。
490
497
  """
491
498
  try:
492
499
  if CLOUD_BUNDLE:
@@ -4,6 +4,7 @@
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
+ import hashlib
7
8
  import json
8
9
  import os
9
10
  import re
@@ -15,6 +16,7 @@ from pathlib import Path
15
16
  import pytest
16
17
 
17
18
  from ht32_code_mcp import server as srv
19
+ from ht32_code_mcp.materializer import pack_release_version
18
20
  from ht32_code_mcp.usage import UsageMiddleware
19
21
 
20
22
  FWLIB = "raw/HT32_STD_5xxxx_FWLib_V1.21.1"
@@ -287,12 +289,94 @@ def _bundle_checks_v2(out_dir: Path, device: str, config_id: str = ""):
287
289
  official = [f for f in files["files"] if f["classification"] == "official"]
288
290
  assert official, "没有任何 official 分类文件"
289
291
  assert all(f.get("origin") for f in official), "official 文件缺 origin"
292
+ # DFP 随包(用户决策 2026-10-18):安装包在工程内,版本/SHA256/包内 pdsc 三者与 lock 一致
293
+ pack = lock["pack"]
294
+ assert lock["requires"]["pack"] == pack["id"]
295
+ assert lock["requires"]["pack_min_version"] == pack["min_version"]
296
+ assert pack["version"] == pack["min_version"], "随包 DFP 版本必须等于要求版本(锁定版本)"
297
+ assert pack["file"].startswith("utilities/pack/")
298
+ pack_file = out_dir / pack["file"]
299
+ assert pack_file.is_file(), f"随包 DFP 缺失:{pack['file']}"
300
+ assert pack_file.stat().st_size == pack["bytes"]
301
+ assert hashlib.sha256(pack_file.read_bytes()).hexdigest() == pack["sha256"]
302
+ assert pack_release_version(pack_file) == pack["version"], "包内 pdsc 版本与 lock 不符"
303
+ devs = json.loads((mdir / "deviations.json").read_text(encoding="utf-8"))["deviations"]
304
+ assert any(d["kind"] == "dfp-pack-bundled" for d in devs), "deviations 未登记随包 DFP"
305
+ assert pack["file"] in readme, "导出 README 未指明随包 DFP 安装包"
290
306
  # 卫生:不得含 GUI 状态/构建产物/个人痕迹
291
307
  for f in files["files"]:
292
308
  assert not re.search(r"(^|/)\.uvguix\.|(^|/)Obj/|(^|/)List/", f["path"])
293
309
  assert not re.search(r"\.(axf|hex|bin|map|dep|crf|o|bak|iex|lnp)$", f["path"], re.I)
294
310
 
295
311
 
312
+ def _pack_spec(chip: str = "HT32F52352") -> dict:
313
+ """取(就地可改的)recipe pack 块:Resolved.recipe 直接引用装载后的 recipe 字典。"""
314
+ return srv.exporter.materializer.resolve(chip=chip).recipe["pack"]
315
+
316
+
317
+ def test_export_l5_ships_locked_dfp_not_package_copy(tmp_path):
318
+ """L5 官方包内自带的 .pack 是 1.0.77(低于锁定 1.0.78);随包必须是锁定的 1.0.78。"""
319
+ out = tmp_path / "l5_pack"
320
+ srv.export_project_bundle("HT32L52241", str(out))
321
+ lock = json.loads((out / "manifest" / "project-lock.json").read_text(encoding="utf-8"))
322
+ assert lock["pack"]["version"] == "1.0.78"
323
+ assert lock["pack"]["file"] == "utilities/pack/Holtek.HT32_DFP.1.0.78.pack"
324
+ in_pkg = (REPO / "raw" / "HT32_STD_L5xxxx_FWLib_V1.6.1" / "utilities" / "pack"
325
+ / "Holtek.HT32_DFP.latest.pack")
326
+ assert pack_release_version(in_pkg) == "1.0.77" # 登记事实:官方包内那份低一版
327
+ shipped = out / lock["pack"]["file"]
328
+ assert shipped.read_bytes() != in_pkg.read_bytes(), "不得直接沿用官方包内的 1.0.77"
329
+
330
+
331
+ def test_export_pack_required_when_asset_missing(tmp_path, monkeypatch):
332
+ """随包 DFP 资产缺失必须失败(PACK_REQUIRED),且不留半成品目录。"""
333
+ monkeypatch.setitem(_pack_spec(), "asset",
334
+ ".work/template-recipes/assets/packs/missing.pack")
335
+ out = tmp_path / "missing_pack"
336
+ text = srv.export_project_bundle("HT32F52352", str(out))
337
+ assert "[PACK_REQUIRED]" in text and "资产缺失" in text, text
338
+ assert not out.exists(), "失败不得留下半成品目录"
339
+
340
+
341
+ def test_export_pack_required_when_asset_hash_mismatch(tmp_path, monkeypatch):
342
+ """资产 SHA256 与 recipe 锁不一致必须失败(资产被替换/损坏)。"""
343
+ monkeypatch.setitem(_pack_spec(), "asset_sha256", "0" * 64)
344
+ out = tmp_path / "bad_hash"
345
+ text = srv.export_project_bundle("HT32F52352", str(out))
346
+ assert "[PACK_REQUIRED]" in text and "SHA256 不符" in text, text
347
+ assert not out.exists()
348
+
349
+
350
+ def test_export_pack_required_when_version_below_or_id_mismatch(tmp_path, monkeypatch):
351
+ """版本门槛:随包版本低于 min_version、或与锁定 id 版本不一致,都必须失败。"""
352
+ pack = _pack_spec()
353
+ with monkeypatch.context() as m:
354
+ m.setitem(pack, "min_version", "9.9.9")
355
+ text = srv.export_project_bundle("HT32F52352", str(tmp_path / "low_ver"))
356
+ assert "[PACK_REQUIRED]" in text and "低于要求" in text, text
357
+ with monkeypatch.context() as m:
358
+ m.setitem(pack, "id", "Holtek.HT32_DFP.9.9.9")
359
+ text = srv.export_project_bundle("HT32F52352", str(tmp_path / "id_mismatch"))
360
+ assert "[PACK_REQUIRED]" in text and "与锁定 id" in text, text
361
+ assert not (tmp_path / "low_ver").exists()
362
+ assert not (tmp_path / "id_mismatch").exists()
363
+
364
+
365
+ def test_export_host_tools_default_included(tmp_path):
366
+ """host 工具(gsar/eLink32pro)默认随包;置 false 时排除并登记 deviations。"""
367
+ out = tmp_path / "host_default"
368
+ srv.export_project_bundle("HT32F52352", str(out))
369
+ assert (out / "utilities" / "gsar").is_dir()
370
+ assert (out / "utilities" / "elink32pro" / "eLink32pro.exe").is_file()
371
+ out2 = tmp_path / "host_excluded"
372
+ srv.export_project_bundle("HT32F52352", str(out2), include_host_tools=False)
373
+ assert not (out2 / "utilities" / "gsar").exists()
374
+ assert (out2 / "utilities" / "pack").is_dir(), "host 开关不得影响随包 DFP"
375
+ devs = json.loads((out2 / "manifest" / "deviations.json")
376
+ .read_text(encoding="utf-8"))["deviations"]
377
+ assert any(d["kind"] == "host-tools-excluded" for d in devs)
378
+
379
+
296
380
  def test_export_positive_default_chip(tmp_path):
297
381
  out = tmp_path / "bundle_default"
298
382
  result = srv.export_project_bundle("HT32F52352", str(out))
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.3.2"
443
+ version = "0.4.0"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
File without changes
File without changes
File without changes
File without changes