ht32-code-mcp 0.3.1__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.1 → ht32_code_mcp-0.4.0}/PKG-INFO +1 -1
  2. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/pyproject.toml +1 -1
  3. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/exporter.py +8 -4
  4. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/materializer.py +142 -29
  5. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/server.py +16 -3
  6. ht32_code_mcp-0.4.0/tests/test_export_cloud_mode.py +47 -0
  7. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/tests/test_tools.py +84 -0
  8. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/uv.lock +1 -1
  9. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/.gitignore +0 -0
  10. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/.gitkeep +0 -0
  11. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/.pytest_base_final_run.log +0 -0
  12. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/DEPLOY.md +0 -0
  13. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/README.md +0 -0
  14. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/e2e_scenario_a.py +0 -0
  15. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/e2e_scenario_b.py +0 -0
  16. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/smoke_http.py +0 -0
  17. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/smoke_stdio.py +0 -0
  18. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__init__.py +0 -0
  19. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__main__.py +0 -0
  20. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/index.py +0 -0
  21. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/knowledge.py +0 -0
  22. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/release.py +0 -0
  23. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/usage.py +0 -0
  24. {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/tests/conftest.py +0 -0
  25. {ht32_code_mcp-0.3.1 → 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.1
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.1"
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"]
@@ -20,6 +20,7 @@ import os
20
20
  import re
21
21
  import shutil
22
22
  import tempfile
23
+ import threading
23
24
  import time
24
25
  import zipfile
25
26
  from pathlib import Path
@@ -34,7 +35,13 @@ PACK_REQUIRED = "PACK_REQUIRED"
34
35
  CONFIG_CLOSURE_FAILED = "CONFIG_CLOSURE_FAILED"
35
36
  RECIPE_MISMATCH = "RECIPE_MISMATCH"
36
37
 
37
- 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+)$")
38
45
 
39
46
  # 不得外发的文件/目录(隐私与卫生,W4;由 manifest 驱动而非目录黑名单)
40
47
  FORBIDDEN_PATTERNS = [
@@ -66,6 +73,28 @@ def sha256_file(path: Path) -> str:
66
73
  return h.hexdigest()
67
74
 
68
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
+
69
98
  # ------------------------------------------------------------------ recipe 装载
70
99
  def load_recipes(recipes_dir: Path) -> dict[str, dict]:
71
100
  recipes: dict[str, dict] = {}
@@ -373,6 +402,7 @@ class MaterialResult:
373
402
  manifest_dir: str
374
403
  warnings: list[str]
375
404
  deviations: list[dict]
405
+ pack: dict
376
406
 
377
407
 
378
408
  class Materializer:
@@ -381,6 +411,10 @@ class Materializer:
381
411
  self.raw = self.root / "raw"
382
412
  self.recipes = load_recipes(recipes_dir)
383
413
  self.recipes["__dir__"] = recipes_dir # resolve() 需要定位 adapter
414
+ # 单次调用的中间状态必须放 thread-local:物化器是进程级单例,
415
+ # 并发导出(MCP 工具在线程池里执行)会互相覆盖 _stage / _project_rel,
416
+ # 导致 "…is not in the subpath of…" 之类的相对路径串台错误。
417
+ self._local = threading.local()
384
418
 
385
419
  def resolve(self, *, config_id: str = "", chip: str = "", device: str = "",
386
420
  board: str = "", variant: str = "", clock_profile: str = "",
@@ -421,29 +455,80 @@ class Materializer:
421
455
  notes.append(f"origin={origin['kind']} package={origin['package']}")
422
456
  return notes
423
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
+
424
503
  # ------------------------------------------------------------ 文件计划
425
504
  def _copy_official_tree(self, src: Path, dst: Path, manifest_files: list[dict],
426
- 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:
427
507
  for p in sorted(src.rglob("*")):
428
508
  if not p.is_file():
429
509
  continue
430
510
  rel = p.relative_to(src).as_posix()
431
- 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:
432
515
  continue
433
516
  target = dst / rel
434
517
  target.parent.mkdir(parents=True, exist_ok=True)
435
518
  shutil.copyfile(p, target)
436
519
  manifest_files.append(dict(
437
- path=(dst.relative_to(self._stage) / rel).as_posix(), bytes=p.stat().st_size,
520
+ path=(dst.relative_to(self._local.stage) / rel).as_posix(), bytes=p.stat().st_size,
438
521
  sha256=sha256_file(p), classification="official",
439
522
  origin=f"{pkg_name}/{src.relative_to(self.raw / pkg_name).as_posix()}/{rel}"))
440
523
 
441
524
  def materialize(self, r: Resolved, out_dir: str | Path, *, project_name: str = "",
442
- include_host_tools: bool = False, verify_pack: bool = False) -> MaterialResult:
525
+ include_host_tools: bool = True) -> MaterialResult:
443
526
  out = Path(out_dir).expanduser().resolve()
444
527
  if out.exists() and any(out.iterdir()):
445
528
  raise ContractError("TARGET_EXISTS", f"目标目录非空,拒绝覆盖:{out}")
446
529
  notes = self.preflight(r)
530
+ # 随包 DFP:存在性 / 资产 SHA256 / 版本 >= min_version 三项校验,先于 staging
531
+ pack = self._resolve_pack(r)
447
532
  cfg = r.config
448
533
  origin = cfg["origin"]
449
534
  pkg = self.raw / origin["package"]
@@ -456,12 +541,12 @@ class Materializer:
456
541
  # staging:与目标同文件系统的临时目录(保证原子重命名)
457
542
  out.parent.mkdir(parents=True, exist_ok=True)
458
543
  stage = Path(tempfile.mkdtemp(prefix=f".ht32-stage-{stem}-", dir=str(out.parent)))
459
- self._stage = stage
544
+ self._local.stage = stage
460
545
  manifest_files: list[dict] = []
461
546
  deviations: list[dict] = []
462
547
  try:
463
548
  self._build_tree(r, stage, stem, kernel, board_dir, manifest_files,
464
- deviations, include_host_tools)
549
+ deviations, include_host_tools, pack)
465
550
  # 产物卫生终检(W4):不得外发的模式
466
551
  bad = [f["path"] for f in manifest_files
467
552
  if any(re.search(p, f["path"]) for p in FORBIDDEN_PATTERNS)]
@@ -476,7 +561,7 @@ class Materializer:
476
561
  mdir = stage / "manifest"
477
562
  mdir.mkdir(parents=True, exist_ok=True)
478
563
  self._write_manifests(r, mdir, stem, manifest_files, deviations, notes,
479
- total_bytes, tree_hash, len(manifest_files))
564
+ total_bytes, tree_hash, len(manifest_files), pack)
480
565
  # 原子发布(Windows 上目录重命名可能被 AV/索引器瞬态拒绝,长退避重试)
481
566
  last_err = None
482
567
  for attempt in range(10):
@@ -497,25 +582,25 @@ class Materializer:
497
582
  shutil.rmtree(stage, ignore_errors=True)
498
583
  except BaseException: # noqa: BLE001 - best-effort cleanup
499
584
  pass
500
- return MaterialResult(out_dir=out, project_file=self._project_rel,
585
+ return MaterialResult(out_dir=out, project_file=self._local.project_rel,
501
586
  files=len(manifest_files), bytes=total_bytes,
502
587
  sha256=tree_hash, manifest_dir="manifest",
503
- warnings=r.warnings, deviations=deviations)
588
+ warnings=r.warnings, deviations=deviations, pack=pack)
504
589
 
505
590
  # ------------------------------------------------------------ 树构建
506
591
  def _build_tree(self, r: Resolved, stage: Path, stem: str, kernel: str,
507
592
  board_dir: str, manifest_files: list[dict], deviations: list[dict],
508
- include_host_tools: bool) -> None:
593
+ include_host_tools: bool, pack: dict) -> None:
509
594
  cfg = r.config
510
595
  origin = cfg["origin"]
511
596
  pkg = self.raw / origin["package"]
512
- self._stage = stage
597
+ self._local.stage = stage
513
598
 
514
599
  # 1) library:完整官方树(complete-official-tree)
515
600
  lib_src = pkg / ("libraries" if kernel == "m4" else "library")
516
601
  lib_dst = stage / "library"
517
602
  self._copy_official_tree(lib_src, lib_dst, manifest_files, origin["package"])
518
- # 2) utilities:官方构建资产;host 工具默认排除(D2 已授权,opt-in 携带)
603
+ # 2) utilities:官方构建资产;host 工具(gsar/eLink32pro)默认随包
519
604
  if kernel == "m4":
520
605
  board_asset = r.recipe.get("board_asset_dir") or board_dir
521
606
  util_src = pkg / "project" / board_asset
@@ -524,14 +609,33 @@ class Materializer:
524
609
  util_src = pkg / "utilities"
525
610
  util_dst = stage / "utilities"
526
611
  if util_src.is_dir():
612
+ # 官方包内的 DFP 安装包(utilities/pack/)不直接拷:L5 包内是 1.0.77,
613
+ # 与锁定版本不符;统一由 recipe 的 pack.asset 物化锁定版本(见 2b)
527
614
  self._copy_official_tree(util_src, util_dst, manifest_files, origin["package"],
528
- skip_host_tools=not include_host_tools)
615
+ skip_host_tools=not include_host_tools,
616
+ skip_top={"pack"})
529
617
  if not include_host_tools and kernel != "m4":
530
618
  deviations.append(dict(
531
619
  kind="host-tools-excluded",
532
- detail="utilities/{gsar,elink32pro,pack} 为独立 host 组件,默认 profile 不分发"
533
- "(D2 已授权可分发;opt-in include_host_tools 携带)",
620
+ detail="utilities/{gsar,elink32pro} 为独立 host 组件,本 profile 不分发"
621
+ "(D2 已授权可分发;include_host_tools=true 时携带)",
534
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']} 时可跳过安装"))
535
639
  # 3) licenses:官方许可文件
536
640
  lic_dst = stage / "licenses"
537
641
  lic_dst.mkdir(parents=True, exist_ok=True)
@@ -618,9 +722,9 @@ class Materializer:
618
722
  proj_dir = stage / "project" / "MDK_ARMv5"
619
723
  proj_dir.mkdir(parents=True, exist_ok=True)
620
724
  uvprojx = self._materialize_uvprojx(r, proj_dir, stem, kernel, board_dir, deviations)
621
- self._project_rel = uvprojx.relative_to(stage).as_posix()
725
+ self._local.project_rel = uvprojx.relative_to(stage).as_posix()
622
726
  uv_data = uvprojx.read_bytes()
623
- manifest_files.append(dict(path=self._project_rel, bytes=len(uv_data),
727
+ manifest_files.append(dict(path=self._local.project_rel, bytes=len(uv_data),
624
728
  sha256=sha256_bytes(uv_data),
625
729
  classification="materialized-project",
626
730
  origin=f"{origin['package']}/{origin['project']}"))
@@ -641,7 +745,7 @@ class Materializer:
641
745
  origin=str(uvoptx_src.relative_to(self.root).as_posix())))
642
746
 
643
747
  # 6) README(用户工程说明,与真实策略一致)
644
- readme = self._readme(r, stem)
748
+ readme = self._readme(r, stem, pack)
645
749
  (stage / "README.md").write_text(readme, encoding="utf-8")
646
750
  manifest_files.append(dict(path="README.md", bytes=len(readme.encode()),
647
751
  sha256=sha256_bytes(readme.encode()),
@@ -796,7 +900,7 @@ class Materializer:
796
900
  # ------------------------------------------------------------ manifest
797
901
  def _write_manifests(self, r: Resolved, mdir: Path, stem: str, files: list[dict],
798
902
  deviations: list[dict], notes: list[str], total_bytes: int,
799
- tree_hash: str, file_count: int) -> None:
903
+ tree_hash: str, file_count: int, pack: dict) -> None:
800
904
  cfg = r.config
801
905
  lock = {
802
906
  "contract_version": CONTRACT_VERSION,
@@ -809,8 +913,14 @@ class Materializer:
809
913
  "profiles": {"clock": r.clock_profile, "retarget": r.retarget_profile},
810
914
  "origin": cfg["origin"],
811
915
  "memory": cfg["memory"],
812
- "pack": r.recipe["pack"],
813
- "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"],
814
924
  "pack_note": r.recipe["pack"]["device_macro_note"],
815
925
  "keil": "MDK-ARM 5.x (AC5), UV4 command line verified"},
816
926
  "licensing": cfg["licensing"],
@@ -831,10 +941,10 @@ class Materializer:
831
941
  (mdir / "validation.json").write_text(json.dumps({
832
942
  "layers": validation,
833
943
  "gate_rule": "gui/hardware 未验证项以 not-tested 明示,不得宣称已验证",
834
- "compile_command": f'UV4 -b "{self._project_rel}" -j0 -o build.log',
944
+ "compile_command": f'UV4 -b "{self._local.project_rel}" -j0 -o build.log',
835
945
  }, ensure_ascii=False, indent=1), encoding="utf-8")
836
946
 
837
- def _readme(self, r: Resolved, stem: str) -> str:
947
+ def _readme(self, r: Resolved, stem: str, pack: dict) -> str:
838
948
  cfg = r.config
839
949
  claims = cfg["retarget"]["uart_pin_claims"]
840
950
  kernel = "m4" if r.series in ("ht32f493x5", "ht32f490x1", "ht32f491x3") else "m0m3"
@@ -850,13 +960,15 @@ class Materializer:
850
960
  f"- Keil MDK-ARM 5.x,编译器 **AC5(armcc)**(本地以 ARMCC V5.06 update 4/7 实测)"
851
961
  f"——**本工程暂不支持 AC6(armclang)**,请在 Options for Target → Target 保持"
852
962
  f"编译器版本为 AC5;切到 AC6 可能在编译/链接阶段报错",
853
- f"- {r.recipe['pack']['device_macro_note']}(manifest 锁定 "
854
- 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']} 时可跳过安装**",
855
967
  "",
856
968
  "## 打开与编译",
857
969
  "",
858
- f"- 打开 `{self._project_rel}`(Target:`{stem}`)",
859
- f"- 命令行基线编译:`UV4 -b \"{self._project_rel}\" -j0 -o build.log`",
970
+ f"- 打开 `{self._local.project_rel}`(Target:`{stem}`)",
971
+ f"- 命令行基线编译:`UV4 -b \"{self._local.project_rel}\" -j0 -o build.log`",
860
972
  ]
861
973
  if r.series == "ht32f5":
862
974
  lines.append("- 调试:工程自带受控 `.uvoptx`(CMSIS-DAP + Reset and Run 配置;"
@@ -889,7 +1001,8 @@ class Materializer:
889
1001
  "| 目录 | 内容 |",
890
1002
  "|---|---|",
891
1003
  "| `library/` | 完整官方库(字节冻结,manifest/files.json 可逐文件核对) |",
892
- "| `utilities/` | 官方板级/公共构建资产(默认不含 gsar/eLink32pro/pack 等 host 工具) |",
1004
+ "| `utilities/` | 官方板级/公共构建资产 + host 工具(`gsar/`、`elink32pro/`)"
1005
+ "+ DFP 安装包(`pack/`,见上「前置条件」) |",
893
1006
  "| `project/MDK_ARMv5/` | 工程文件(由官方逐 IC 工程声明式物化) |",
894
1007
  "| `project/Src_USER/` | 用户源码:自有 `main.c` + 官方配置/中断文件 |",
895
1008
  "| `licenses/` | 官方许可文件 |",
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import os
9
9
  import sys
10
+ import time
10
11
  from importlib.metadata import PackageNotFoundError, version as pkg_version
11
12
  from pathlib import Path
12
13
  from typing import Literal
@@ -43,6 +44,11 @@ BUNDLE_BASE_URL = os.environ.get("BUNDLE_BASE_URL", "").strip()
43
44
  BUNDLE_TTL = _env_int("BUNDLE_TTL_SECONDS", DEFAULT_BUNDLE_TTL)
44
45
  CLOUD_BUNDLE = bool(BUNDLE_DIR and BUNDLE_BASE_URL)
45
46
 
47
+
48
+ def _fmt_time(ts: float) -> str:
49
+ """epoch 秒 → 本地时间字符串(用于云端 zip 下载的有效期提示)。"""
50
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
51
+
46
52
  # 反馈与用量日志:FEEDBACK_ROOT(宿主机挂载的可写卷,与 wiki 同一套约定)
47
53
  # 下按服务名分子目录,本服务写 <FEEDBACK_ROOT>/code/。
48
54
  # 为空则整体关闭——本地开发默认不落盘。注意:日志含 query 原文,勿提交进仓库。
@@ -435,6 +441,9 @@ def get_project_manifest() -> str:
435
441
  "工程文件按工程名重命名为 <project_name>.uvprojx,模板自用文件"
436
442
  "(devices.json / 模板维护文档)不外发,根目录改为生成属于用户工程的 README.md;"
437
443
  "project_name 缺省为型号——**应先向用户确认工程名**,用户同意后才用型号兜底。")
444
+ lines.append("DFP 随包:导出工程 `utilities/pack/` 内即为 recipe 锁定的官方 DFP 安装包"
445
+ "(版本与资产 SHA256 在导出前机器校验,不符即报 PACK_REQUIRED),"
446
+ "用户首次使用在本工程内安装即可;已装同名 DFP 且版本不低于要求版本时可跳过。")
438
447
  lines.append("交付方式:" + (
439
448
  f"云端 zip 下载(随机 token + {BUNDLE_TTL}s TTL,返回下载 URL,out_dir 忽略)"
440
449
  if CLOUD_BUNDLE else "本地目录导出(out_dir 必填)"))
@@ -451,7 +460,7 @@ def export_project_bundle(
451
460
  board: str = "",
452
461
  clock_profile: str = "",
453
462
  retarget_profile: str = "",
454
- include_host_tools: bool = False,
463
+ include_host_tools: bool = True,
455
464
  ) -> str:
456
465
  """导出自包含可编译 Keil 工程(v2 契约:recipe 物化 + manifest + 原子发布)。
457
466
 
@@ -461,6 +470,10 @@ def export_project_bundle(
461
470
  响应含 contract_version/config_id/recipe_revision/device/board/compiler/
462
471
  profiles/工程文件/manifest/树 SHA256/前置依赖/warnings/validation。
463
472
 
473
+ DFP(器件支持包)随包携带:工程 `utilities/pack/` 内即为 recipe 锁定的官方 DFP
474
+ 安装包(资产 SHA256 与版本在物化前机器校验,不符即 PACK_REQUIRED 报错),
475
+ 用户首次使用在本工程内安装即可;本机已装同名 DFP 且版本不低于要求版本时可跳过。
476
+
464
477
  两种交付形态(由服务端环境决定,无需调用方判断):
465
478
  - 云端模式:返回带 TTL 的 zip 下载 URL(含 SHA256 与大小),out_dir 被忽略。
466
479
  - 本地模式:物化到 out_dir(须为空目录或不存在;不会覆盖已有内容)。
@@ -479,8 +492,8 @@ def export_project_bundle(
479
492
  out_dir: 输出目录(本地模式必填;云端模式忽略)。
480
493
  project_name: 工程名(可选)——工程文件名主干、Target 名与输出名;
481
494
  缺省取型号;**调用方应先向用户确认工程名**。
482
- include_host_tools: 是否随包携带 host 工具(gsar/eLink32pro/pack,
483
- 默认不携带;D2 已授权可分发)。
495
+ include_host_tools: 是否随包携带 host 工具(gsar/eLink32pro;默认 True,
496
+ D2 已授权可分发)。DFP 安装包不受此开关影响——它始终随包携带。
484
497
  """
485
498
  try:
486
499
  if CLOUD_BUNDLE:
@@ -0,0 +1,47 @@
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
+ """
8
+ from __future__ import annotations
9
+
10
+ import concurrent.futures
11
+
12
+ from ht32_code_mcp import server as srv
13
+
14
+ CFG_F52352 = "ht32f52352.ht32f52352-sk.ac5.hsi-safe.retarget"
15
+ CFG_F50241 = "ht32f50241.ht32f50241-sk.ac5.hsi-safe.retarget"
16
+ CFG_L52241 = "ht32l52241.ht32l52241-sk.ac5.hsi-safe.retarget"
17
+
18
+
19
+ def test_cloud_zip_response_formats_expiry(tmp_path, monkeypatch):
20
+ """云端模式必须能给出下载 URL 与有效期(不得因未定义的 helper 直接抛 NameError)。"""
21
+ monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
22
+ monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
23
+ monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "http://example.test/ht32-bundles")
24
+ text = srv.export_project_bundle(config_id=CFG_F52352, project_name="CloudMode")
25
+ assert "导出失败" not in text, text
26
+ assert "下载 URL" in text and "http://example.test/ht32-bundles/" in text
27
+ assert "有效期" in text
28
+ assert list(tmp_path.joinpath("bundles").glob("*.zip")), "未生成 zip 产物"
29
+
30
+
31
+ def test_concurrent_exports_do_not_cross_talk(tmp_path):
32
+ """并发导出必须各自落到自己的目录(不得因共享实例状态串台)。"""
33
+ jobs = [(CFG_F50241, "ConcA"), (CFG_L52241, "ConcB")]
34
+
35
+ def run(item):
36
+ idx, (cid, name) = item
37
+ return srv.export_project_bundle(
38
+ config_id=cid, project_name=name, out_dir=str(tmp_path / f"out{idx}"))
39
+
40
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
41
+ texts = list(pool.map(run, enumerate(jobs)))
42
+
43
+ for text, (_, name) in zip(texts, jobs):
44
+ assert "导出失败" not in text, text
45
+ for idx, (_, name) in enumerate(jobs):
46
+ proj = tmp_path / f"out{idx}" / "project" / "MDK_ARMv5" / f"{name}.uvprojx"
47
+ assert proj.is_file(), f"并发导出产物缺失:{proj}"
@@ -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.0"
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