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.
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/PKG-INFO +1 -1
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/pyproject.toml +1 -1
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/exporter.py +8 -4
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/materializer.py +128 -20
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/server.py +10 -3
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/test_tools.py +84 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/uv.lock +1 -1
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/.gitignore +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/.gitkeep +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/.pytest_base_final_run.log +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/DEPLOY.md +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/README.md +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/smoke_http.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/usage.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/test_export_cloud_mode.py +0 -0
- {ht32_code_mcp-0.3.2 → ht32_code_mcp-0.4.0}/tests/test_knowledge_grounding.py +0 -0
|
@@ -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 =
|
|
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 =
|
|
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":
|
|
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"-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
538
|
-
"(D2 已授权可分发;
|
|
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":
|
|
818
|
-
|
|
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"- {
|
|
859
|
-
f"
|
|
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/` |
|
|
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 =
|
|
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
|
|
489
|
-
|
|
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))
|
|
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
|