ht32-code-mcp 0.4.5__tar.gz → 0.4.6__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 (35) hide show
  1. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/DEPLOY.md +58 -5
  2. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/PKG-INFO +1 -1
  3. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/components.example.json +7 -0
  4. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/pyproject.toml +1 -1
  5. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/component_exporter.py +1 -0
  6. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/component_source.py +66 -14
  7. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/components.py +11 -2
  8. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/server.py +3 -0
  9. ht32_code_mcp-0.4.6/tests/test_component_redact.py +125 -0
  10. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/uv.lock +1 -1
  11. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/.gitignore +0 -0
  12. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/.gitkeep +0 -0
  13. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/.pytest_base_final_run.log +0 -0
  14. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/README.md +0 -0
  15. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/e2e_scenario_a.py +0 -0
  16. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/e2e_scenario_b.py +0 -0
  17. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/smoke_http.py +0 -0
  18. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/smoke_stdio.py +0 -0
  19. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/__init__.py +0 -0
  20. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/__main__.py +0 -0
  21. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/exporter.py +0 -0
  22. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/index.py +0 -0
  23. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/knowledge.py +0 -0
  24. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/materializer.py +0 -0
  25. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/release.py +0 -0
  26. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/src/ht32_code_mcp/usage.py +0 -0
  27. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/conftest.py +0 -0
  28. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_component_export.py +0 -0
  29. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_component_source.py +0 -0
  30. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_component_tools.py +0 -0
  31. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_components.py +0 -0
  32. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_export_cloud_mode.py +0 -0
  33. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_knowledge_grounding.py +0 -0
  34. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_recipe_claims.py +0 -0
  35. {ht32_code_mcp-0.4.5 → ht32_code_mcp-0.4.6}/tests/test_tools.py +0 -0
@@ -350,10 +350,63 @@ MCP 服务端 → 仓库内登记表(默认 .work/components/components.json
350
350
  | `COMPONENTS_STATE_FILE` | `<临时目录>/ht32-component-cache/tag-lock.json` | tag→commit 台账(检测 tag 被强制移动);生产建议放持久卷(compose 已指向 `/app/feedback/component-tag-lock.json`) |
351
351
  | `COMPONENT_GIT_TIMEOUT` | `60` | 单次 Git 操作超时(秒) |
352
352
 
353
- **运行时依赖 `git`**:组件工具通过服务端 `git` 读取远端。部署前先确认容器内可用
354
- (`docker exec ht32-code-mcp git --version`);不可用时组件工具返回
355
- `[COMPONENT_REMOTE_UNAVAILABLE] 服务端未安装 git`,需自建带 git 的镜像或改用带 git 的运行时。
356
- 纯检索/导出模板不依赖 git,不受影响。
353
+ **运行时依赖 `git` + 私有仓库凭据**(两者缺一,组件工具都会返回
354
+ `[COMPONENT_REMOTE_UNAVAILABLE]`):
355
+
356
+ 1. **镜像**:官方 `supercorp/supergateway:next-uvx` 默认不带 git。同目录
357
+ [`.work/docker/Dockerfile`](../docker/Dockerfile) 在其上补装 git(自动适配
358
+ apt/apk),compose 已改为 `image: ht32-code-mcp:0.4.5-git` + `build: .`:
359
+
360
+ ```bash
361
+ cd /vol1/1000/SSD/docker/ht32-code/.work/docker
362
+ docker compose build ht32-code-mcp # 首次;之后同步脚本带 --build 会自动复用缓存
363
+ docker compose up -d --force-recreate ht32-code-mcp
364
+ docker exec ht32-code-mcp git --version # 必须打印版本
365
+ ```
366
+
367
+ 2. **凭据**:内网 Gitea 仓库非公开,匿名 `git ls-remote` 返回 **403**(可用
368
+ `curl -i http://100.66.1.6:3000/api/v1/repos/S621/HT32_BSP/tags` 复核)。在部署机
369
+ `.work/docker/.env`(已被 `.gitignore` 忽略)写入 Basic 凭据:
370
+
371
+ ```bash
372
+ # Gitea → 设置 → 应用 → 生成令牌,权限只需 read:repository
373
+ printf 'COMPONENTS_GIT_AUTH=%s\n' \
374
+ "$(printf '%s:%s' '<Gitea 用户名>' '<只读 token>' | base64 -w0)" > .env
375
+ chmod 600 .env
376
+ ```
377
+
378
+ compose 以 `GIT_CONFIG_COUNT/KEY_0/VALUE_0` 注入 `http.extraheader`,只存在于
379
+ 容器进程环境;不写入仓库、日志或 MCP 响应。轮换 token 后改 `.env` 并重建容器。
380
+
381
+ 3. **验证**:容器内直接执行(无需再带 `-c`,凭据由环境注入):
382
+
383
+ ```bash
384
+ docker exec ht32-code-mcp git ls-remote http://100.66.1.6:3000/S621/LwFSM.git | tail -2
385
+ ```
386
+
387
+ 能列出 `refs/tags/V2.0` 即表示 git 与凭据都就位。纯检索/导出模板不依赖 git,不受影响。
388
+
389
+ ### 源码裁剪(`export.redact`,0.4.6+)
390
+
391
+ 面向“只交付 `.lib`、不给源码”的组件。**不执行仓库里的 `script/submodule.bat`**
392
+ (Linux 无 cmd,且执行仓库脚本等于远程执行),改为快照层的等价内置规则:
393
+
394
+ | 模式 | 行为 |
395
+ |---|---|
396
+ | `none`(默认) | 不裁剪 |
397
+ | `auto` | 仅当仓库含 `script/submodule.bat` 时,按下面的规则裁剪 |
398
+ | `lib-only` | 无条件裁剪 |
399
+
400
+ `lib-only` 规则(路径相对导出根,大小写不敏感):
401
+
402
+ 1. 移除根目录 `project/`;
403
+ 2. `lib/<name>.lib` 存在 → 移除 `src/<name>.c`;
404
+ 3. `tools/InfoProcess.exe` 存在 → 移除 `tools/InfoProcess.c`;
405
+ 4. `script/` 保留。
406
+
407
+ 裁剪发生在**快照构建阶段**,因此 `read_component_file`、`search_component_code`
408
+ 与导出都看不到被移除的源文件;`manifest/components-lock.json` 会记录
409
+ `redaction.mode` 与逐条 `removed`(路径 + 原因),便于审计。
357
410
 
358
411
  ### 组件登记表放在哪里
359
412
 
@@ -491,4 +544,4 @@ uv lock && uv build && uv publish
491
544
  | 3.7 | 云端 zip 下载通道(P1-3) | 服务器侧已上线:下载 1608157 字节 / SHA256 一致 / 解压 455 文件 / UV4 0E0W | ✅ |
492
545
  | 3.8 | 定时同步脚本含 bundle 清理(P1-2) | `ht32-code-mcp-update.md` 第 6 步 `find … -mmin +60 -delete` | ✅ |
493
546
  | 3.9 | 反馈与用量日志(P1-5,与 wiki 统一) | 0.2.1;云端已启用,但 `FEEDBACK_ROOT` 漏写前导 `/`(显示 `app/feedback/code`),需改绝对路径 | ⚠️ |
494
- | 3.10 | 私有组件目录(0.4.5) | 登记表随仓库版本化(`.work/components/components.json`,无额外挂载);容器内需有 `git` | ⏳ 待用户部署 |
547
+ | 3.10 | 私有组件(0.4.5) | 登记表随仓库版本化;镜像 `ht32-code-mcp:0.4.5-git`(内置 git)+ 只读凭据注入;2026-11-02 线上验收:2 组件真实 tag、读/搜、导出 601 条目 / 组件 94 文件 / 无 `.git` | ✅ |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.4.5
3
+ Version: 0.4.6
4
4
  Summary: HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出
5
5
  License-Expression: MIT
6
6
  Keywords: firmware,holtek,ht32,mcp,mcu
@@ -54,6 +54,13 @@
54
54
  "exclude": ["docs/", "tests/"],
55
55
  // 非空时只允许这些后缀,如 [".c", ".h"];空 = 不限
56
56
  "allowed_suffixes": [],
57
+ // 源码裁剪(不执行仓库脚本):none / auto / lib-only
58
+ // none = 不裁剪(默认)
59
+ // auto = 仅当仓库含 script/submodule.bat 时按下面的内置规则裁剪
60
+ // lib-only= 无条件裁剪:移除 project/、删除有同名 lib/*.lib 的 src/*.c、
61
+ // 删除与 tools/InfoProcess.exe 同名的 .c;script/ 保留
62
+ // 裁剪在快照层生效,read/search/export 都看不到被移除的源码
63
+ "redact": "none",
57
64
  // 容量上限:文件数 / 单文件字节 / 总字节
58
65
  "max_files": 2000,
59
66
  "max_file_bytes": 2097152,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ht32-code-mcp"
7
- version = "0.4.5"
7
+ version = "0.4.6"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -64,6 +64,7 @@ class PreparedComponents:
64
64
  "files": [{"path": f.rel, "bytes": f.size, "sha256": f.sha256}
65
65
  for f in snap.files],
66
66
  "excluded": snap.excluded,
67
+ "redaction": {"mode": snap.redact_mode, "removed": snap.redacted},
67
68
  "license": item.entry.license,
68
69
  "maintainer": item.entry.maintainer,
69
70
  })
@@ -72,7 +72,8 @@ class Snapshot:
72
72
 
73
73
  def __init__(self, entry: ComponentEntry, tag: str, commit: str,
74
74
  workdir: Path, archive: Path, files: list[SnapshotFile],
75
- excluded: list[dict]) -> None:
75
+ excluded: list[dict], redact_mode: str = "none",
76
+ redacted: list[dict] | None = None) -> None:
76
77
  self.entry = entry
77
78
  self.tag = tag
78
79
  self.commit = commit
@@ -80,6 +81,8 @@ class Snapshot:
80
81
  self.archive = archive
81
82
  self.files = files
82
83
  self.excluded = excluded
84
+ self.redact_mode = redact_mode
85
+ self.redacted = redacted or []
83
86
 
84
87
  @property
85
88
  def total_bytes(self) -> int:
@@ -285,19 +288,20 @@ class ComponentSource:
285
288
  "只接受发布 tag 指向的提交")
286
289
  archive = workdir / "tree.zip"
287
290
  self._git(["archive", "--format=zip", f"--output={archive}", head], cwd=repo)
288
- files, excluded = self._plan(archive, info.tag)
289
- return Snapshot(self.entry, info.tag, info.commit, workdir, archive, files, excluded)
291
+ files, excluded, redact_mode, redacted = self._plan(archive, info.tag)
292
+ return Snapshot(self.entry, info.tag, info.commit, workdir, archive,
293
+ files, excluded, redact_mode, redacted)
290
294
  except BaseException:
291
295
  _rmtree(workdir)
292
296
  raise
293
297
 
294
298
  # ---------------------------------------------------------- 文件计划
295
- def _plan(self, archive: Path, tag: str) -> tuple[list[SnapshotFile], list[dict]]:
299
+ def _plan(self, archive: Path, tag: str
300
+ ) -> tuple[list[SnapshotFile], list[dict], str, list[dict]]:
296
301
  export = self.entry.export
297
302
  root_prefix = f"{export.root}/" if export.root else ""
298
- files: list[SnapshotFile] = []
303
+ candidates: list[SnapshotFile] = []
299
304
  excluded: list[dict] = []
300
- total = 0
301
305
  with zipfile.ZipFile(archive) as zf:
302
306
  for info in zf.infolist():
303
307
  if info.is_dir():
@@ -323,20 +327,39 @@ class ComponentSource:
323
327
  if export.allowed_suffixes and not name.lower().endswith(export.allowed_suffixes):
324
328
  raise _path_rejected(
325
329
  f"组件文件类型不在登记允许列表({list(export.allowed_suffixes)}):{name}")
326
- if info.file_size > export.max_file_bytes:
327
- raise ComponentError(
328
- COMPONENT_LIMIT_EXCEEDED,
329
- f"组件 '{self.entry.id}' 的 {name} 为 {info.file_size} 字节,"
330
- f"超过单文件上限 {export.max_file_bytes}")
331
- total += info.file_size
332
- files.append(SnapshotFile(
330
+ candidates.append(SnapshotFile(
333
331
  rel=name, size=info.file_size,
334
332
  sha256=hashlib.sha256(zf.read(info)).hexdigest(), zip_name=info.filename))
333
+ if not candidates:
334
+ raise ComponentError(
335
+ COMPONENT_TAG_NOT_FOUND,
336
+ f"组件 '{self.entry.id}' 在 tag '{tag}' 的导出根 "
337
+ f"'{export.root or '.'}' 下没有可导出文件")
338
+
339
+ redact_mode, removed = _redaction_plan(export.redact, [f.rel for f in candidates])
340
+ files: list[SnapshotFile] = []
341
+ redacted: list[dict] = []
342
+ for item in candidates:
343
+ reason = removed.get(item.rel.lower())
344
+ if reason:
345
+ redacted.append({"path": item.rel, "reason": reason})
346
+ excluded.append({"path": item.rel, "reason": reason})
347
+ else:
348
+ files.append(item)
335
349
  if not files:
336
350
  raise ComponentError(
337
351
  COMPONENT_TAG_NOT_FOUND,
338
352
  f"组件 '{self.entry.id}' 在 tag '{tag}' 的导出根 "
339
353
  f"'{export.root or '.'}' 下没有可导出文件")
354
+
355
+ total = 0
356
+ for item in files:
357
+ if item.size > export.max_file_bytes:
358
+ raise ComponentError(
359
+ COMPONENT_LIMIT_EXCEEDED,
360
+ f"组件 '{self.entry.id}' 的 {item.rel} 为 {item.size} 字节,"
361
+ f"超过单文件上限 {export.max_file_bytes}")
362
+ total += item.size
340
363
  if len(files) > export.max_files:
341
364
  raise ComponentError(
342
365
  COMPONENT_LIMIT_EXCEEDED,
@@ -345,7 +368,36 @@ class ComponentSource:
345
368
  raise ComponentError(
346
369
  COMPONENT_LIMIT_EXCEEDED,
347
370
  f"组件 '{self.entry.id}' 总字节 {total} 超过上限 {export.max_total_bytes}")
348
- return sorted(files, key=lambda f: f.rel), excluded
371
+ return sorted(files, key=lambda f: f.rel), excluded, redact_mode, redacted
372
+
373
+
374
+ def _redaction_plan(mode: str, names: list[str]) -> tuple[str, dict[str, str]]:
375
+ """源码裁剪:等价于组件仓库的 `script/submodule.bat`,但**不执行**任何脚本。
376
+
377
+ 规则(路径相对组件导出根,大小写不敏感):
378
+ - 移除根目录 `project/`(示例/测试工程);
379
+ - `lib/<name>.lib` 存在时,移除 `src/<name>.c`;
380
+ - `tools/InfoProcess.exe` 存在时,移除 `tools/InfoProcess.c`;
381
+ - `script/` 保留。
382
+ `auto` 仅当检测到 `script/submodule.bat` 时生效;`lib-only` 无条件生效。
383
+ 返回 (生效模式, {小写路径: 原因})。
384
+ """
385
+ if mode == "none":
386
+ return "none", {}
387
+ lowered = [n.lower() for n in names]
388
+ if mode == "auto" and "script/submodule.bat" not in lowered:
389
+ return "none", {}
390
+ libs = {Path(n).stem for n in lowered if re.fullmatch(r"lib/[^/]+\.lib", n)}
391
+ has_tool_exe = "tools/infoprocess.exe" in lowered
392
+ removed: dict[str, str] = {}
393
+ for name in lowered:
394
+ if name.startswith("project/"):
395
+ removed[name] = "redact:project-dir"
396
+ elif re.fullmatch(r"src/[^/]+\.c", name) and Path(name).stem in libs:
397
+ removed[name] = "redact:source-replaced-by-lib"
398
+ elif has_tool_exe and name == "tools/infoprocess.c":
399
+ removed[name] = "redact:tool-source"
400
+ return "lib-only", removed
349
401
 
350
402
 
351
403
  def _unsafe_member(name: str) -> bool:
@@ -53,9 +53,11 @@ _COMPONENT_KEYS = {
53
53
  _TAGS_KEYS = {"include", "exclude"}
54
54
  _EXPORT_KEYS = {
55
55
  "root", "target", "exclude", "allowed_suffixes",
56
- "max_files", "max_file_bytes", "max_total_bytes",
56
+ "max_files", "max_file_bytes", "max_total_bytes", "redact",
57
57
  }
58
58
 
59
+ REDACT_MODES = ("none", "auto", "lib-only")
60
+
59
61
  DEFAULT_MAX_FILES = 2000
60
62
  DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024
61
63
  DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024
@@ -82,6 +84,8 @@ class ExportPolicy:
82
84
  max_files: int = DEFAULT_MAX_FILES
83
85
  max_file_bytes: int = DEFAULT_MAX_FILE_BYTES
84
86
  max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES
87
+ # 源码裁剪:none / auto(检测到 script/submodule.bat 才裁剪)/ lib-only
88
+ redact: str = "none"
85
89
 
86
90
 
87
91
  @dataclass(frozen=True)
@@ -308,6 +312,10 @@ def _parse_export(data: dict, where: str, component_id: str) -> ExportPolicy:
308
312
  else:
309
313
  target = f"components/{component_id}"
310
314
  excludes = _str_list(block, "exclude", f"{where}.export")
315
+ redact = (_optional_str(block, "redact", f"{where}.export") or "none").strip().lower()
316
+ if redact not in REDACT_MODES:
317
+ raise _fail(f"{where}.export.redact",
318
+ f"只支持 {' / '.join(REDACT_MODES)},当前为 '{redact}'")
311
319
  suffixes = tuple(s if s.startswith(".") else "." + s
312
320
  for s in _str_list(block, "allowed_suffixes", f"{where}.export"))
313
321
  max_files = _int_field(block, "max_files", f"{where}.export", DEFAULT_MAX_FILES)
@@ -319,7 +327,8 @@ def _parse_export(data: dict, where: str, component_id: str) -> ExportPolicy:
319
327
  raise _fail(f"{where}.export", "max_files 超出合理上限(100000)")
320
328
  return ExportPolicy(root=root, target=target, exclude=excludes,
321
329
  allowed_suffixes=suffixes, max_files=max_files,
322
- max_file_bytes=max_file, max_total_bytes=max_total)
330
+ max_file_bytes=max_file, max_total_bytes=max_total,
331
+ redact=redact)
323
332
 
324
333
 
325
334
  def _parse_component(raw: object, index: int, remotes: dict[str, str],
@@ -702,6 +702,9 @@ def get_component_info(component_id: str) -> str:
702
702
  lines.append(f"- tag 规则:include={list(entry.tag_include)},"
703
703
  f"exclude={list(entry.tag_exclude)}")
704
704
  lines.extend(describe_tags(tags, err, limit=50))
705
+ if entry.export.redact != "none":
706
+ lines.append(f"- 源码裁剪:{entry.export.redact}"
707
+ "(移除 project/、有同名 .lib 的 src/*.c、InfoProcess.c;读/搜/导出统一生效)")
705
708
  export = entry.export
706
709
  lines += [
707
710
  f"- 导出落点:{entry.target}/(仓库导出根:{export.root or '.'})",
@@ -0,0 +1,125 @@
1
+ """组件源码裁剪:等价 `script/submodule.bat`,但不在服务端执行任何脚本。"""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from conftest import catalog_document, commit_all, git, write
7
+ from ht32_code_mcp.component_exporter import ComponentService
8
+ from ht32_code_mcp.component_source import (ComponentSource, TagLockStore,
9
+ _redaction_plan)
10
+ from ht32_code_mcp.components import ComponentError, parse_catalog
11
+
12
+
13
+ @pytest.fixture
14
+ def redact_repo(tmp_path):
15
+ if not __import__("shutil").which("git"):
16
+ pytest.skip("环境无 git,跳过源码裁剪测试")
17
+ repo = tmp_path / "iap-repo"
18
+ repo.mkdir()
19
+ git(repo, "init", "-q")
20
+ write(repo, "README.md", "# HT32 IAP\n")
21
+ write(repo, "inc/ht32_iap.h", "void iap_init(void);\n")
22
+ write(repo, "src/ht32_iap.c", "void iap_init(void) {}\n")
23
+ write(repo, "src/ht32_iap_cache.c", "void cache_init(void) {}\n")
24
+ write(repo, "src/extra.c", "void extra(void) {}\n")
25
+ write(repo, "project/test/main.c", "int main(void) { return 0; }\n")
26
+ write(repo, "tools/InfoProcess.c", "int main(void) { return 0; }\n")
27
+ (repo / "tools" / "InfoProcess.exe").write_bytes(b"MZ")
28
+ (repo / "lib").mkdir()
29
+ (repo / "lib" / "ht32_iap.lib").write_bytes(b"LIB-1")
30
+ (repo / "lib" / "ht32_iap_cache.lib").write_bytes(b"LIB-2")
31
+ write(repo, "script/submodule.bat", "@echo off\r\n")
32
+ commit = commit_all(repo, "iap v1")
33
+ git(repo, "tag", "v1.0.0")
34
+ return {"path": repo, "commit": commit}
35
+
36
+
37
+ def _catalog(redact: str, remote: str = "https://git.example.invalid/org/iap.git"):
38
+ doc = catalog_document(remote)
39
+ doc["components"][0]["export"] = {"target": "", "redact": redact}
40
+ return parse_catalog(doc)
41
+
42
+
43
+ def _source(repo, tmp_path, redact: str) -> ComponentSource:
44
+ catalog = _catalog(redact)
45
+ return ComponentSource(catalog.get("ht32_bsp"), str(repo),
46
+ work_root=tmp_path / "work", timeout=120,
47
+ tag_locks=TagLockStore(None))
48
+
49
+
50
+ def test_redaction_plan_auto_requires_script():
51
+ names = ["src/a.c", "lib/a.lib", "project/main.c"]
52
+ assert _redaction_plan("auto", names) == ("none", {})
53
+
54
+
55
+ def test_redaction_plan_lib_only_rules():
56
+ names = ["src/a.c", "src/b.c", "lib/a.lib", "project/x/main.c",
57
+ "tools/InfoProcess.c", "tools/InfoProcess.exe",
58
+ "script/submodule.bat"]
59
+ mode, removed = _redaction_plan("lib-only", names)
60
+ assert mode == "lib-only"
61
+ assert set(removed) == {"src/a.c", "project/x/main.c", "tools/infoprocess.c"}
62
+ assert removed["src/a.c"] == "redact:source-replaced-by-lib"
63
+
64
+
65
+ def test_snapshot_redacts_sources_but_keeps_lib_header_and_script(redact_repo, tmp_path):
66
+ src = _source(redact_repo["path"], tmp_path, "auto")
67
+ snap = src.snapshot("v1.0.0")
68
+ try:
69
+ rels = {f.rel for f in snap.files}
70
+ assert snap.redact_mode == "lib-only"
71
+ assert "project/test/main.c" not in rels
72
+ assert "src/ht32_iap.c" not in rels
73
+ assert "src/ht32_iap_cache.c" not in rels
74
+ assert "tools/InfoProcess.c" not in rels
75
+ assert "src/extra.c" in rels
76
+ assert "lib/ht32_iap.lib" in rels
77
+ assert "inc/ht32_iap.h" in rels
78
+ assert "tools/InfoProcess.exe" in rels
79
+ assert "script/submodule.bat" in rels
80
+ reasons = {r["path"]: r["reason"] for r in snap.redacted}
81
+ assert reasons["src/ht32_iap.c"] == "redact:source-replaced-by-lib"
82
+ assert reasons["project/test/main.c"] == "redact:project-dir"
83
+ assert reasons["tools/InfoProcess.c"] == "redact:tool-source"
84
+ finally:
85
+ snap.close()
86
+
87
+
88
+ def test_snapshot_redact_none_keeps_everything(redact_repo, tmp_path):
89
+ src = _source(redact_repo["path"], tmp_path, "none")
90
+ snap = src.snapshot("v1.0.0")
91
+ try:
92
+ rels = {f.rel for f in snap.files}
93
+ assert "project/test/main.c" in rels
94
+ assert "src/ht32_iap.c" in rels
95
+ assert snap.redact_mode == "none"
96
+ assert snap.redacted == []
97
+ finally:
98
+ snap.close()
99
+
100
+
101
+ def test_export_lock_records_redaction(redact_repo, tmp_path):
102
+ catalog = _catalog("auto", str(redact_repo["path"]))
103
+ svc = ComponentService(lambda: catalog, work_root=tmp_path / "work", timeout=120)
104
+ prepared = svc.prepare(["ht32_bsp@v1.0.0"])
105
+ try:
106
+ lock = prepared.lock_document()
107
+ row = lock["components"][0]
108
+ assert row["redaction"]["mode"] == "lib-only"
109
+ removed = {r["path"] for r in row["redaction"]["removed"]}
110
+ assert removed == {"project/test/main.c", "src/ht32_iap.c",
111
+ "src/ht32_iap_cache.c", "tools/InfoProcess.c"}
112
+ exported = {f["path"] for f in row["files"]}
113
+ assert "src/ht32_iap.c" not in exported
114
+ assert "lib/ht32_iap.lib" in exported
115
+ finally:
116
+ prepared.close()
117
+
118
+
119
+ def test_registry_rejects_unknown_redact_mode():
120
+ doc = catalog_document("https://git.example.invalid/org/repo.git")
121
+ doc["components"][0]["export"]["redact"] = "all"
122
+ with pytest.raises(ComponentError) as err:
123
+ parse_catalog(doc)
124
+ assert err.value.code == "COMPONENTS_CONFIG_INVALID"
125
+ assert "redact" in err.value.message
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.4.5"
443
+ version = "0.4.6"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
File without changes
File without changes
File without changes