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.
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/PKG-INFO +1 -1
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/pyproject.toml +1 -1
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/exporter.py +8 -4
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/materializer.py +142 -29
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/server.py +16 -3
- ht32_code_mcp-0.4.0/tests/test_export_cloud_mode.py +47 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/tests/test_tools.py +84 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/uv.lock +1 -1
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/.gitignore +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/.gitkeep +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/.pytest_base_final_run.log +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/DEPLOY.md +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/README.md +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/smoke_http.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/release.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/src/ht32_code_mcp/usage.py +0 -0
- {ht32_code_mcp-0.3.1 → ht32_code_mcp-0.4.0}/tests/conftest.py +0 -0
- {ht32_code_mcp-0.3.1 → 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"]
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
533
|
-
"(D2 已授权可分发;
|
|
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.
|
|
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.
|
|
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":
|
|
813
|
-
|
|
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.
|
|
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"- {
|
|
854
|
-
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']} 时可跳过安装**",
|
|
855
967
|
"",
|
|
856
968
|
"## 打开与编译",
|
|
857
969
|
"",
|
|
858
|
-
f"- 打开 `{self.
|
|
859
|
-
f"- 命令行基线编译:`UV4 -b \"{self.
|
|
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/` |
|
|
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 =
|
|
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
|
|
483
|
-
|
|
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))
|
|
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
|