feat(migration): 建立 Skill 迁移工作流与追踪工具

This commit is contained in:
zhiye.sun
2026-08-25 10:46:09 +08:00
parent 795c331706
commit 0e7eb1eb93
10 changed files with 1063 additions and 3 deletions
+3
View File
@@ -31,3 +31,6 @@ bin/
# CraftKit 本地迁移来源配置,仅用于开发机,不进入公开仓库 # CraftKit 本地迁移来源配置,仅用于开发机,不进入公开仓库
migration/local-sources.json migration/local-sources.json
migration/source-scan.json
__pycache__/
*.pyc
+154
View File
@@ -0,0 +1,154 @@
# CraftKit 协作约定
本文件适用于 CraftKit 仓库及其全部子目录。更深层目录若存在自己的 `AGENTS.md`,以更具体的约定为准。
## 项目目标
CraftKit 面向 Codex 插件市场,提供简洁、中性、可独立安装的 Skill。所有能力必须能够脱离来源项目独立理解、验证和维护。
## 沟通与改动
- 所有回答、文档和代码注释使用中文;公开 API、标准名称和代码标识可以保留英文。
- 修改前先说明方案。简单改动可在说明后直接执行;复杂或高风险改动先明确范围、影响和验证方式。
- 阅读、分析和评审任务默认只读,不因发现问题自动扩大为修改任务。
- 未经明确要求,不提交、不推送、不安装插件,也不修改用户级 Codex 配置。
- 保留工作区已有改动;提交时只暂存本次确认范围内的文件。
## 目录职责
| 路径 | 职责 |
| --- | --- |
| `.agents/plugins/marketplace.json` | CraftKit 市场及插件入口 |
| `plugins/dev/` | 软件设计、编码、审查与测试 Skill |
| `plugins/doc/` | 文档转换、整理与写作 Skill |
| `plugins/git/` | Git 操作与交付 Skill |
| `plugins/knowledge/` | 交接、复盘与知识维护 Skill |
| `plugins/skill/` | Skill 创建、迁移、检查与同步工具 |
| `migration/` | 来源指纹、迁移计划和状态追踪,不进入插件发布内容 |
## Skill 设计规则
- Skill 名称使用小写字母、数字和连字符,简短且以动作或明确领域为中心。
- Skill 文件夹名必须与 `SKILL.md` frontmatter 的 `name` 完全一致。
- `description` 只说明能力和触发场景,避免大而全、宽泛兜底或堆砌关键词。
- `SKILL.md` 只保留共同目标、关键流程和必要边界;条件性细节放入 `references/`。
- 只有重复执行且确定性逻辑明显时才新增 `scripts/`;新增脚本必须有可运行验证。
- 只有产物确实需要模板、图片或样例文件时才新增 `assets/`。
- 不创建没有实际用途的空目录、占位文档或重复 README。
- 自动发现保持默认开启;只有用户明确要求时才设置为仅显式调用。
推荐结构:
```text
plugins/<plugin>/skills/<skill>/
├─ SKILL.md
├─ agents/openai.yaml # 有 UI 元数据时使用
├─ scripts/ # 有确定性自动化时使用
├─ references/ # 有条件加载的详细资料时使用
└─ assets/ # 生成产物需要的素材
```
## 迁移与知识产权边界
- 迁移采用“分析能力 → 编写中性规格 → 脱离原文独立实现”的方式,不做目录复制、批量替词或近义改写。
- 不复制来源 Skill 的正文、脚本、提示词、模板、示例、规则库、注释或独特文档结构。
- 不写入公司名称、产品名称、内部域名、内部包名、项目名、人员信息、业务规则或环境路径。
- 公司框架 API、组件契约、审批流程、版本矩阵和内部消息格式默认排除。
- 通用技术规范必须根据公开标准或官方资料重新设计;需要引用时记录来源和许可证。
- 来源文件只用于人工分析和哈希追踪,不得进入 `plugins/` 发布目录。
- 上游变化只触发复核,不自动覆盖已迁移 Skill。
详细顺序和门禁见 [迁移计划](migration/MIGRATION_PLAN.md)。
## Skill 迁移工作流
所有新增迁移、来源更新和已迁移 Skill 的重新评估,都必须按以下顺序处理。用户未明确确认前,只能进行只读分析,不得创建、复制、改写或删除目标 Skill 文件。
### 1. 阅读迁移计划和现状
- 完整阅读 `migration/MIGRATION_PLAN.md`、`migration/README.md` 和 `migration/source-lock.json`。
- 检查 Git 状态、目标插件目录和现有 Skill,避免覆盖未提交改动或重复实现。
- 只读检查相关来源 Skill 及其辅助资源,确认来源提交、文件哈希、依赖关系和变化范围。
- 区分首次迁移、上游更新、目标重构和排除项复核,不把不同类型混为一次迁移。
### 2. 列出来源 Skill 和作用
在任何写入前,向用户列出本批计划处理的来源 Skill:
| 项目 | 内容 |
| --- | --- |
| 来源标识 | 使用本地迁移标识,不在发布目录写入公司品牌 |
| 来源 Skill | 原始名称和相对路径 |
| 当前作用 | 根据源码确认的能力、输入、输出和关键边界 |
| 迁移类型 | 新增、更新、合并、公开资料重建或排除 |
| 风险 | 公司专属知识、内部依赖、重复能力和兼容性问题 |
来源作用必须有文件依据;无法从静态内容确认的运行行为应明确标记为未验证。
### 3. 给出目标名称、作用和组织结构
针对每个来源 Skill,先给出建议方案:
| 项目 | 内容 |
| --- | --- |
| 目标插件 | `dev`、`doc`、`git`、`knowledge` 或 `skill` |
| 目标名称 | 简短、中性、动作或明确领域导向的名称 |
| 目标作用 | 迁移后保留的通用能力和明确排除的内容 |
| 组织结构 | `SKILL.md` 及确有需要的 `agents/`、`scripts/`、`references/`、`assets/` |
| 合并关系 | 独立 Skill、合并到已有 Skill、替代旧 Skill 或排除 |
| 验证方案 | 静态校验、脚本测试、真实请求和敏感内容扫描 |
此阶段只输出设计,不创建目标目录。若多个来源能力可以合并,应优先给出合并方案,避免一一照搬来源结构。
### 4. 与用户确认迁移方案
- 展示本批范围、目标映射、组织结构、排除内容和验证方式后,明确请求用户确认。
- 用户可以确认全部方案,也可以调整名称、拆分或合并方式、迁移顺序和排除项。
- 只有“确认执行”“按此方案迁移”等明确授权才允许进入写入阶段。
- 用户仅要求评估、查看方案或继续讨论时,不视为迁移授权。
- 确认后的授权只覆盖已展示范围;新增 Skill、扩大来源范围或改变组织方式必须重新确认。
### 5. 执行迁移并更新文档
获得确认后:
1. 按中性能力规格独立实现目标 Skill,不复制来源目录或正文。
2. 运行 `AGENTS.md` 中规定的全部验证,并记录真实结果和未验证边界。
3. 更新 `migration/source-lock.json` 的来源哈希、目标路径、状态和复核日期。
4. 更新 `migration/MIGRATION_PLAN.md` 的执行清单和批次进度。
5. 更新根 `README.md`、所属插件说明或其他确实受影响的文档;不得创建重复状态文档。
6. 向用户报告新增、更新、合并和排除结果,以及测试、扫描和剩余风险。
7. 展示本批待提交文件清单和建议的 Conventional Commit 提交信息,进入第二次确认。
### 6. 与用户确认并提交到本地
- 迁移前确认只授权执行迁移,不自动授权 Git 提交。
- 迁移和文档同步完成后,必须再次等待用户明确确认;“确认提交”“提交到本地”等表述才视为提交授权。
- 用户要求调整、补测或继续检查时,先完成相应工作并重新展示结果,不得沿用此前的提交确认。
- 获得确认后,只暂存本批迁移方案中已展示且验证通过的文件,不使用会混入其他改动的宽泛暂存命令。
- 暂存后执行 `git diff --cached --check`,并复核暂存文件清单;发现额外文件或敏感内容时停止提交并报告。
- 使用中文 Conventional Commit 信息创建本地提交,然后检查提交内容和工作区状态。
- 本地提交成功后向用户报告提交哈希、提交信息、文件范围和剩余未提交改动。
- 第二次确认只授权本地提交,不包含推送、创建标签、发布插件或修改远端历史;这些操作必须另行获得明确授权。
## 验证要求
每个新增或修改的 Skill 至少完成:
1. 检查目录名、frontmatter 和引用路径。
2. 运行 Skill 校验器。
3. 运行所属插件 manifest 校验器。
4. 执行公司标识、内部路径、凭据和占位符扫描。
5. 有脚本时运行核心行为测试;有生成物时检查实际产物。
6. 用一个真实请求验证触发边界和输出结果。
7. 更新 `migration/source-lock.json` 中的状态、目标路径和复核日期。
静态检查通过不代表真实行为已经验证,交付时应分别说明静态校验、脚本测试和实际场景验证结果。
## Git 约定
- 使用 Conventional Commits,提交说明使用中文。
- 推荐类型:`feat`、`fix`、`docs`、`refactor`、`test`、`chore`。
- 提交前执行 `git status --short`、`git diff --cached --check` 并检查暂存文件清单。
- 不提交本地来源配置、凭据、缓存、运行产物和 IDE 私有状态。
- 不使用破坏性历史改写,也不推送,除非用户明确授权。
+8 -1
View File
@@ -2,6 +2,12 @@
CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通用问题出发独立实现能力,不携带任何公司的品牌、内部框架、业务规则、源码、模板或环境信息。 CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通用问题出发独立实现能力,不携带任何公司的品牌、内部框架、业务规则、源码、模板或环境信息。
## 文档导航
- [协作约定](AGENTS.md):项目范围、Skill 设计、迁移边界、验证和 Git 规则。
- [迁移计划](migration/MIGRATION_PLAN.md):迁移批次、依赖顺序、合并原则和验收门禁。
- [迁移追踪说明](migration/README.md):来源指纹和状态维护方式。
## 插件组成 ## 插件组成
| 插件 | 用途 | 当前状态 | | 插件 | 用途 | 当前状态 |
@@ -17,7 +23,8 @@ CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通
```text ```text
CraftKit/ CraftKit/
├─ .agents/plugins/marketplace.json # 仓库级 Codex 市场清单 ├─ .agents/plugins/marketplace.json # 仓库级 Codex 市场清单
├─ migration/ # 来源指纹与迁移追踪 ├─ AGENTS.md # Codex 与贡献者协作约定
├─ migration/ # 迁移计划、来源指纹与状态追踪
├─ plugins/ # 可独立安装的插件 ├─ plugins/ # 可独立安装的插件
│ ├─ dev/ │ ├─ dev/
│ ├─ doc/ │ ├─ doc/
+215
View File
@@ -0,0 +1,215 @@
# Skill 迁移计划
## 1. 目标
将两个本地来源中的通用能力整理为 CraftKit Skill。迁移结果必须适用于 Codex 插件市场,并与来源项目的品牌、内部框架、业务知识和运行环境解耦。
迁移不追求来源 Skill 与目标 Skill 一一对应。重复能力应合并,公司专属能力应排除,目标数量以职责清晰和实际复用价值为准。
## 2. 迁移路径
```text
来源发现
→ 风险分类
→ 重复能力合并
→ 中性能力规格
→ 独立实现
→ 行为验证
→ 敏感内容扫描
→ Codex 与插件校验
→ 更新迁移状态
```
禁止采用“完整复制来源目录后批量替换名称”的方式。中性能力规格形成后,目标实现应基于该规格、当前 Codex 能力和公开资料完成。
## 3. 状态模型
| 状态 | 含义 | 进入条件 |
| --- | --- | --- |
| `pending` | 待评估 | 已发现来源 Skill |
| `specified` | 已完成中性规格 | 已明确目标、边界、依赖和排除内容 |
| `rewriting` | 独立实现中 | 已创建目标 Skill |
| `review` | 待复核 | 功能完成,但版权、依赖或行为仍需确认 |
| `migrated` | 已迁移 | 全部门禁通过 |
| `excluded` | 不迁移 | 公司专属或没有独立价值 |
| `superseded` | 已合并 | 能力已由另一个目标 Skill 覆盖 |
## 4. 迁移批次
### 第 0 批:迁移基础设施
第 0 批属于仓库内部维护工具,不发布为市场 Skill:
1. `migration/scripts/scan_sources.py`:只读发现来源 Skill、辅助资源和文件哈希。
2. `AGENTS.md` 迁移工作流:完成分类、目标设计和用户确认。
3. `migration/scripts/check_skill.py`:检查结构、引用、占位符、敏感内容和凭据风险。
4. `migration/scripts/update_lock.py`:预览或更新来源指纹和迁移状态。
完成门槛:能够对全部来源 Skill 生成稳定清单,并在不复制来源内容的情况下更新状态。
### 第 1 批:低风险样板
用于验证完整迁移闭环:
1. `doc/format-markdown`
2. `git/summarize-commit`
3. `knowledge/prepare-handoff`
4. `doc/write-report`
5. `doc/tech-to-requirements`
完成门槛:固化目录模板、验证命令、行为用例和迁移记录格式。
### 第 2 批:文档转换
所属插件:`doc`
1. `docx-to-markdown`
2. `markdown-to-docx`
3. `excel-to-markdown`
4. `archive-docs`
重点验证图片、表格、合并单元格、编码、覆盖策略和路径安全。脚本及测试数据必须独立创建。
### 第 3 批:Git 工作流
所属插件:`git`
1. `create-branch`
2. `configure-git`
3. `export-changes`
4. `integrate-branch`
`summarize-commit` 已在样板批次完成。涉及提交、合并和远端操作的 Skill 必须保留明确授权边界,并保护脏工作区。
### 第 4 批:知识管理
所属插件:`knowledge`
1. `analyze-agent-failure`
2. `distill-task`
3. `maintain-lessons`
4. `summarize-worklog`
5. `init-agents`
来源中与经验初始化、提升和回扫相关的多个能力统一合并为 `maintain-lessons`,通过模式区分具体工作。
### 第 5 批:开发主流程
所属插件:`dev`
```text
plan-change
├─ design-backend → implement-backend → test-backend
└─ design-frontend → implement-frontend → test-ui
↓
review-code
```
建议顺序:
1. `plan-change`
2. `design-backend`
3. `design-frontend`
4. `prepare-api`
5. `implement-backend`
6. `implement-frontend`
7. `review-code`
8. `test-backend`
9. `test-ui`
10. `analyze-bugs`
多个来源中的代码检查、代码审查能力合并为 `review-code`,通过工作区、提交和分支三种模式覆盖。
### 第 6 批:项目规范与前端辅助
1. `dev/select-component`
2. `dev/design-style`
3. `dev/design-form`
4. `skill/retrieve-guidance`
5. `skill/maintain-guidance`
这里只实现读取和维护“当前项目自身规范”的机制,不随 CraftKit 提供任何来源项目规范。
### 第 7 批:基于公开资料重建
以下能力不从来源文本改写,而是根据官方资料重新设计:
1. `dev/design-api`
2. `dev/design-db`
3. `dev/review-java`
4. `dev/review-frontend`
5. `dev/design-frontend-data`
6. `dev/review-mybatis`
中性规格必须记录所采用的公开标准、官方文档和许可证信息。
### 第 8 批:暂缓或排除
默认排除:
- 公司框架知识查询与专属代码规范。
- 内部组件契约、审批流程和消息格式。
- 内部仓库发布流程和框架版本升级矩阵。
暂缓评估:
- 浏览器代理配置。
- 通用大版本升级工具。
- Skill 路由器。
- 工作量评估。
- 非 Codex 平台的迁移工具。
## 5. 合并原则
来源数量不等于目标数量。优先执行以下合并:
| 来源能力类型 | 目标 Skill |
| --- | --- |
| 代码检查、提交审查、分支审查 | `review-code` |
| 多套开发计划 | `plan-change` |
| 多套后端设计 | `design-backend` |
| 多套前端设计 | `design-frontend` |
| 多套后端实现 | `implement-backend` |
| 多套前端实现 | `implement-frontend` |
| 多套 Word 转 Markdown | `docx-to-markdown` |
| 经验初始化、提升、回扫 | `maintain-lessons` |
| 规范检索和索引维护 | `retrieve-guidance`、`maintain-guidance` |
目标 Skill 总量不设硬指标,预期控制在约 30 个,避免细碎能力和重复触发。
## 6. 单个 Skill 的迁移步骤
1. 记录来源相对路径、提交和 SHA-256。
2. 判断该能力是迁移、合并、重建还是排除。
3. 只提炼目标、输入、输出、关键边界和真实用例,形成中性规格。
4. 确定目标插件和简短 Skill 名称。
5. 基于中性规格和公开资料独立实现。
6. 对脚本执行单元或行为测试,对文档型 Skill 执行真实请求测试。
7. 扫描敏感内容、来源残留、无效工具名和宿主绑定表达。
8. 运行 Skill 与插件校验。
9. 将状态更新为 `migrated`,记录目标路径、目标版本和复核日期。
10. 展示迁移结果、验证证据、待提交文件和建议提交信息,等待用户再次确认。
11. 用户确认后精确暂存本批文件并创建本地提交;推送和发布仍需独立授权。
## 7. 每批验收门禁
- Skill 名称简洁,目录名与 frontmatter 一致。
- `description` 能准确触发,不是大而全的能力描述。
- 不存在来源正文、脚本、模板、示例或独特结构的直接复制。
- 公司标识、内部域名、包名、路径和人员信息扫描为零。
- 所有引用文件存在,脚本和生成物经过实际验证。
- Skill 校验和所属插件校验通过。
- 至少一个真实请求用例通过。
- `source-lock.json` 已更新。
任一门禁未通过时,不进入下一批的大规模迁移。
## 8. 当前执行顺序
- [x] 扩展 `source-lock.json` 的 Skill 级记录结构。
- [x] 实现来源只读扫描脚本。
- [x] 在 `AGENTS.md` 中固化分类、设计和确认流程。
- [x] 实现 Skill 与敏感内容检查脚本。
- [x] 实现迁移台账预览和更新脚本。
- [ ] 迁移并验证首个样板 `doc/format-markdown`。
- [ ] 样板通过后推进第 1 批其余 Skill。
+11 -1
View File
@@ -2,6 +2,16 @@
本目录只记录来源指纹、迁移状态和人工复核结果,不存放或打包公司插件内容。 本目录只记录来源指纹、迁移状态和人工复核结果,不存放或打包公司插件内容。
- [迁移计划](MIGRATION_PLAN.md):批次顺序、依赖、合并策略与验收门禁。
- `source-lock.json`:可提交的来源指纹和迁移状态。
- `local-sources.json`:本地来源路径,不提交;结构参考 `local-sources.example.json`。
- `scripts/scan_sources.py`:只读生成来源 Skill 指纹清单。
- `scripts/check_skill.py`:检查迁移后 Skill 的结构、引用和敏感内容。
- `scripts/update_lock.py`:根据扫描报告预览或更新迁移台账。
- `tests/`:迁移脚本的隔离行为测试。
扫描报告可能包含来源 Skill 名称和相对路径,应写入本地临时目录或忽略文件,不进入插件发布内容。
同步流程: 同步流程:
1. 读取来源仓库当前提交与 Skill 文件哈希。 1. 读取来源仓库当前提交与 Skill 文件哈希。
@@ -10,4 +20,4 @@
4. 在 CraftKit 中独立重写并完成验证。 4. 在 CraftKit 中独立重写并完成验证。
5. 更新来源指纹、目标路径、状态和复核日期。 5. 更新来源指纹、目标路径、状态和复核日期。
允许的状态:`pending`、`rewriting`、`migrated`、`excluded`、`review`。 允许的状态:`pending`、`specified`、`rewriting`、`review`、`migrated`、`excluded`、`superseded`。
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""检查 CraftKit Skill 的基础结构、引用和敏感内容。"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
LINK_RE = re.compile(r"\[[^]]+]\((?!https?://|#)([^)]+)\)")
SECRET_RE = re.compile(
r"(?i)(api[_-]?key|password|private[_-]?key|access[_-]?token)\s*[:=]\s*[^\s]+"
)
def frontmatter(text: str) -> dict[str, str]:
"""解析当前校验所需的简单顶层 frontmatter 字段。"""
if not text.startswith("---\n"):
return {}
end = text.find("\n---", 4)
if end < 0:
return {}
values: dict[str, str] = {}
for line in text[4:end].splitlines():
if ":" in line and not line.startswith((" ", "\t")):
key, value = line.split(":", 1)
values[key.strip()] = value.strip().strip("'\"")
return values
def check_skill(root: Path, forbidden: list[str]) -> dict:
"""返回结构化问题列表,不修改被检查目录。"""
issues: list[dict[str, str]] = []
skill_md = root / "SKILL.md"
if not skill_md.is_file():
return {"skill": root.name, "issues": [{"code": "missing-skill-md", "path": "SKILL.md"}]}
text = skill_md.read_text(encoding="utf-8-sig")
metadata = frontmatter(text)
name = metadata.get("name", "")
if not NAME_RE.fullmatch(name):
issues.append({"code": "invalid-name", "path": "SKILL.md"})
if name != root.name:
issues.append({"code": "name-path-mismatch", "path": "SKILL.md"})
if not metadata.get("description"):
issues.append({"code": "missing-description", "path": "SKILL.md"})
for path in sorted(item for item in root.rglob("*") if item.is_file()):
relative = path.relative_to(root).as_posix()
content = path.read_text(encoding="utf-8-sig", errors="replace")
if "[TODO:" in content:
issues.append({"code": "todo-placeholder", "path": relative})
if SECRET_RE.search(content):
issues.append({"code": "possible-secret", "path": relative})
lowered = content.casefold()
for term in forbidden:
if term.casefold() in lowered:
issues.append({"code": f"forbidden:{term}", "path": relative})
if path.suffix.lower() == ".md":
for link in LINK_RE.findall(content):
target = link.split("#", 1)[0]
if target and not (path.parent / target).resolve().is_file():
issues.append({"code": "broken-link", "path": f"{relative}:{link}"})
return {"skill": root.name, "issues": issues}
def main() -> None:
parser = argparse.ArgumentParser(description="检查 CraftKit Skill")
parser.add_argument("skill", nargs="+", type=Path)
parser.add_argument("--forbid", action="append", default=[])
args = parser.parse_args()
results = [check_skill(path.resolve(), args.forbid) for path in args.skill]
print(json.dumps({"results": results}, ensure_ascii=False, indent=2))
raise SystemExit(1 if any(item["issues"] for item in results) else 0)
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""只读扫描来源目录中的 Skill,并输出稳定的 JSON 清单。"""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
from datetime import datetime, timezone
from pathlib import Path
def file_hash(path: Path) -> str:
"""按二进制内容计算单文件 SHA-256。"""
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(65536), b""):
digest.update(block)
return digest.hexdigest()
def directory_hash(root: Path) -> tuple[str, int]:
"""把相对路径和文件内容共同纳入哈希,确保结构变化也可被识别。"""
digest = hashlib.sha256()
files = sorted(path for path in root.rglob("*") if path.is_file())
for path in files:
relative = path.relative_to(root).as_posix()
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(bytes.fromhex(file_hash(path)))
return digest.hexdigest(), len(files)
def read_skill_name(skill_md: Path) -> str:
"""从 YAML frontmatter 中读取 name;解析失败时回退为目录名。"""
for line in skill_md.read_text(encoding="utf-8-sig").splitlines():
if line.startswith("name:"):
return line.split(":", 1)[1].strip().strip("'\"") or skill_md.parent.name
return skill_md.parent.name
def git_commit(root: Path) -> str | None:
"""尽力读取来源提交;非 Git 目录时返回空值,不阻塞文件扫描。"""
result = subprocess.run(
["git", "-c", f"safe.directory={root.as_posix()}", "-C", str(root), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else None
def scan_source(source_id: str, root: Path) -> dict:
"""扫描单个来源,结果仅包含相对路径和指纹,不包含本地绝对路径。"""
if not root.is_dir():
raise ValueError(f"来源目录不存在:{root}")
skills = []
for skill_md in sorted(root.rglob("SKILL.md")):
skill_root = skill_md.parent
digest, file_count = directory_hash(skill_root)
children = sorted(
child.name for child in skill_root.iterdir() if child.is_dir()
)
skills.append(
{
"name": read_skill_name(skill_md),
"relativePath": skill_root.relative_to(root).as_posix(),
"sha256": digest,
"fileCount": file_count,
"resources": children,
}
)
return {
"id": source_id,
"commit": git_commit(root),
"skillCount": len(skills),
"skills": skills,
}
def parse_source(value: str) -> tuple[str, Path]:
"""解析 id=path 参数,避免把本地路径写入输出文件。"""
if "=" not in value:
raise argparse.ArgumentTypeError("来源参数必须使用 id=path 格式")
source_id, raw_path = value.split("=", 1)
if not source_id.strip() or not raw_path.strip():
raise argparse.ArgumentTypeError("来源标识和路径均不能为空")
return source_id.strip(), Path(raw_path).expanduser().resolve()
def main() -> None:
parser = argparse.ArgumentParser(description="只读扫描 Skill 来源目录")
parser.add_argument("--source", action="append", required=True, type=parse_source)
parser.add_argument("--output", type=Path, help="可选 JSON 输出路径;省略时输出到终端")
args = parser.parse_args()
payload = {
"schemaVersion": 1,
"generatedAt": datetime.now(timezone.utc).isoformat(),
"sources": [scan_source(source_id, root) for source_id, root in args.source],
}
content = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(content, encoding="utf-8")
else:
print(content, end="")
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""根据扫描报告更新迁移台账;默认预览,显式 --apply 才写入。"""
from __future__ import annotations
import argparse
import hashlib
import json
from datetime import date
from pathlib import Path
VALID_STATUS = {
"pending", "specified", "rewriting", "review",
"migrated", "excluded", "superseded",
}
def merge(lock: dict, report: dict) -> dict:
"""保留人工维护字段,仅同步来源提交、数量和 Skill 指纹。"""
lock.setdefault("schemaVersion", 1)
lock["updatedAt"] = date.today().isoformat()
lock.setdefault("sources", {})
lock.setdefault("skills", {})
for source in report.get("sources", []):
source_id = source["id"]
lock["sources"][source_id] = {
"commit": source.get("commit"),
"skillCount": source["skillCount"],
}
for skill in source["skills"]:
# 发布台账不保存来源名称和路径;本地扫描报告负责提供可读映射。
path_hash = hashlib.sha256(skill["relativePath"].encode("utf-8")).hexdigest()
key = f"{source_id}:{path_hash[:16]}"
current = lock["skills"].get(key, {})
status = current.get("status", "pending")
if status not in VALID_STATUS:
raise ValueError(f"非法迁移状态:{key}={status}")
lock["skills"][key] = {
**current,
"sourcePathHash": path_hash,
"sourceSha256": skill["sha256"],
"status": status,
}
return lock
def main() -> None:
parser = argparse.ArgumentParser(description="更新 Skill 迁移台账")
parser.add_argument("--lock", required=True, type=Path)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--apply", action="store_true", help="确认写入台账")
args = parser.parse_args()
lock = json.loads(args.lock.read_text(encoding="utf-8"))
report = json.loads(args.report.read_text(encoding="utf-8"))
content = json.dumps(merge(lock, report), ensure_ascii=False, indent=2) + "\n"
if args.apply:
temporary = args.lock.with_suffix(args.lock.suffix + ".tmp")
temporary.write_text(content, encoding="utf-8")
temporary.replace(args.lock)
else:
print(content, end="")
if __name__ == "__main__":
main()
+322 -1
View File
@@ -11,5 +11,326 @@
"skillCount": 52 "skillCount": 52
} }
}, },
"skills": {} "skills": {
"source-a:2c317f4ba8859f5e": {
"sourcePathHash": "2c317f4ba8859f5e210c18e69e99e8e472d38d97e193c5f213508d4afb672c11",
"sourceSha256": "ed08a64bf0f5004d43a9c09a40087aebec8f52323c5a47d8fc73f9f9aca9be0e",
"status": "pending"
},
"source-a:1b7843684955ce5a": {
"sourcePathHash": "1b7843684955ce5a0beb3ef16c6e751c5e9eb5a17409084fed806b74ec0c522f",
"sourceSha256": "eabf9fabba7b751a0b85547b141d92821f461e8d904f4fb15d5746eca1965769",
"status": "pending"
},
"source-a:5d76364c580248a5": {
"sourcePathHash": "5d76364c580248a54449e5ec7ede0079f19df21fef95922d3538fb82c095b181",
"sourceSha256": "9a664d17f276b01311f661aa6e8fd40063708550c97cc3340246922194d8390b",
"status": "pending"
},
"source-a:a0b791b6dac96b81": {
"sourcePathHash": "a0b791b6dac96b8180df558278b5c7ef643b4806cc4e534ffe6395b9236666d3",
"sourceSha256": "603b4fd6e4b52c9b5d368c5ba6bdff333d32f3e03e9b5393686b0729eb4ffc7c",
"status": "pending"
},
"source-a:0124493991019160": {
"sourcePathHash": "0124493991019160a06c65d6200db0a50abb212a79bcefab4be4631be85c07c8",
"sourceSha256": "eca051fb929c5bd74c34564631872c2d6755b4427a7c4fbb4d6240eeb6331ee3",
"status": "pending"
},
"source-a:b80c5913e146321c": {
"sourcePathHash": "b80c5913e146321ca5efef5e45f11172e79c542855c2ae65478fe253414e66ea",
"sourceSha256": "5e33d979bb08fdcc47b2dee2e0d5cb39c24825b391f8bbf5606e57cbd4c2ef2c",
"status": "pending"
},
"source-a:cd36505dd718e889": {
"sourcePathHash": "cd36505dd718e88966ad74421c65e6c7cccd71a1436f2843eb8cfa7a4f1534c5",
"sourceSha256": "a0137bd615988c49a8967b4e0129618858268b90391ab2ed912cc72f338f41f8",
"status": "pending"
},
"source-a:e14c1a3cd3b041f6": {
"sourcePathHash": "e14c1a3cd3b041f650d71eb5701c71c0de76cb7a909e9a419f1d277159cd763a",
"sourceSha256": "7780de4d979a6bafd1f5f503f08261858907f110acddb630dfab36d32e54c6a8",
"status": "pending"
},
"source-a:fdde0da43c1ec4c7": {
"sourcePathHash": "fdde0da43c1ec4c7fddad96b9172435af6be96ceafa2cc824f70e04a3cf11085",
"sourceSha256": "46a02dbff9c3fff57a0802853e2424983927f53c7f1cc3d8a42542403e62a161",
"status": "pending"
},
"source-a:4efb6f9dd0f39dff": {
"sourcePathHash": "4efb6f9dd0f39dff13339e62ff24b672552efcbcedb007e9ea86fe41950adbb0",
"sourceSha256": "a914823bdf50d9e5c8ee70d6363c5c0e7aea341ed390587a9285c70a7047558e",
"status": "pending"
},
"source-a:32e7cfc21d252985": {
"sourcePathHash": "32e7cfc21d25298559bffbe7f0918f0f6e7d2aac29e3eca39d1f43d8226cf934",
"sourceSha256": "0f0486e3f25279a1a1cb8cecfef4dc01830471b5d31b52a9d17a425919997bc2",
"status": "pending"
},
"source-a:bc91b57fa2a50049": {
"sourcePathHash": "bc91b57fa2a500498b31b0f1a87dfa8ab83c32e4b361b4ea1614ea6721234b7c",
"sourceSha256": "1436c7e25325c927d55d1dfc2a3117ec8e30845d66e06d2e6d7cc75abe4e0792",
"status": "pending"
},
"source-b:3fb4282b0b034d52": {
"sourcePathHash": "3fb4282b0b034d5286bccd9924c37bedbe857c4673798aa844f540fe0ee5186a",
"sourceSha256": "196cd701c67d06fce4ee3b9894cbed95e6bc9101cc2089f83ae8f35b61a11332",
"status": "pending"
},
"source-b:0dedfcd01cd89e28": {
"sourcePathHash": "0dedfcd01cd89e28e83d841f78c270b1210399cc867fd3e1865c14acf1ea865a",
"sourceSha256": "be6a694d4de9b8a792e19e1ed116e2fdd54d91731d9efcadfca1b0055a4a3145",
"status": "pending"
},
"source-b:3f05784a0a86ba5f": {
"sourcePathHash": "3f05784a0a86ba5fb8f268797ca1ceabe8ca237b3c9d3c4396e622c1ef421cc6",
"sourceSha256": "9a39cd7af138519fe3de3f7a3cb031ea5da1243ff5779504a13215fa03a5a3c2",
"status": "pending"
},
"source-b:41934e94a1922a1b": {
"sourcePathHash": "41934e94a1922a1bbccec4377246e67bd84f1ffbd2b9d1bf4027c1ed15d12260",
"sourceSha256": "a0db48d88dab0537e061146edf6dbdeb4b7f8a935febd1556f798ef234b29e5b",
"status": "pending"
},
"source-b:0cfb12fd60357a34": {
"sourcePathHash": "0cfb12fd60357a346c8e0079536540fba81e406d58205098f1a5162c6ce7e9d1",
"sourceSha256": "aa722e0dbcb613748b75bc2f5c81b8a97cbff07905b1fcee6d58cb176135fc08",
"status": "pending"
},
"source-b:f171b05e239c2e2c": {
"sourcePathHash": "f171b05e239c2e2c5f2a0c636dfac0c2456b9d3b5215fdaa7fb86a33be1ee188",
"sourceSha256": "cd16d17204009509e6798badab786b9bd4d3969117b3ad4b9005337b76a4791b",
"status": "pending"
},
"source-b:3b29e4441f642e65": {
"sourcePathHash": "3b29e4441f642e65d442fd284f37a45364cebb7a6c53446c354d15c70e532410",
"sourceSha256": "d2689d3ac45070389473cb8cc414df44e82ad267915d8cd7c53c023f5b8f3696",
"status": "pending"
},
"source-b:f6ec7bd5501e6ea5": {
"sourcePathHash": "f6ec7bd5501e6ea5f6e907a586a8d7cbe5fbbeebd3e5c8efead9b250742a148e",
"sourceSha256": "25905b3b294b0be2e245099acb1a557793215f157b96b9e964e78170838652f0",
"status": "pending"
},
"source-b:c49d1db42acd7220": {
"sourcePathHash": "c49d1db42acd722071d61420be58ba8ccbc54f5370bdf951abfb5981914271cf",
"sourceSha256": "20875df87fb84c7d5830d6eaaff544c9e32e3f9a2359ed099de5f3a4a36eaa7d",
"status": "pending"
},
"source-b:9a3f3d3c43d9017a": {
"sourcePathHash": "9a3f3d3c43d9017a080a9832df5b975e6ddd3f8c39947ee768aeb973fc23974e",
"sourceSha256": "47c072e792d1cf80ddcc69536236ccb525d21e7d5408ed41cca55483556e8acb",
"status": "pending"
},
"source-b:eb730c56bc7a4a15": {
"sourcePathHash": "eb730c56bc7a4a158ddec74c7497a10fdea2d0b628783b968e3ee9ee4572b15c",
"sourceSha256": "89aa46526fa7531958df46be4b162a9f7b9ea597e3e9121991cf25e9f25158f5",
"status": "pending"
},
"source-b:422174b0c00bbd65": {
"sourcePathHash": "422174b0c00bbd657f4c08415d4a722584556cfa5f3edc997b11e8ab0ef8656a",
"sourceSha256": "017a4ca0b7648ed91003deeb97a3757e8f17acd6963c6019140389a907f1345a",
"status": "pending"
},
"source-b:742ba3611bee4e34": {
"sourcePathHash": "742ba3611bee4e34dfee6dadbe192dd8be677c4b9f49108e2f04d9e22dc8801f",
"sourceSha256": "7582e333966b45ee2d145601831449d75c4f3159670c31df25e5d18e6a357bbe",
"status": "pending"
},
"source-b:d6d4166800584a61": {
"sourcePathHash": "d6d4166800584a61ad6cbf96af57bb8ee269a16babba753f267b48efd66f6d2d",
"sourceSha256": "60f8708db24f368ecec55bd3f326683fac60bd6776ac69e0fafaf4c77333e703",
"status": "pending"
},
"source-b:023767d9c7be6295": {
"sourcePathHash": "023767d9c7be62956d12fd01c94ac676b9fe6719741acd6f412c8b446efa2760",
"sourceSha256": "ba579f8d6a62b4745d8d3922b33e3c20974d8081ae4d1c87727d1a9e4b609945",
"status": "pending"
},
"source-b:b127c47139270a7f": {
"sourcePathHash": "b127c47139270a7f9157e18ad6a0c975b99caf07b9a0874daeac86d9a99ce48c",
"sourceSha256": "0214b80071f07beaff4f5837b983546ff71dbaa65bc29e34a5a23c9d946b683b",
"status": "pending"
},
"source-b:34ab291ce3cfed2e": {
"sourcePathHash": "34ab291ce3cfed2e69cc6f08309b1a41142be91235b9a573daf98639b4a001cc",
"sourceSha256": "239d7efb1f9a53fc1b40f367954a6f6eaaf9d7a56cdf712128dcf393dc5a5067",
"status": "pending"
},
"source-b:3c8596a5d95de214": {
"sourcePathHash": "3c8596a5d95de214d381b2f04898dd7042a266aa7889569981a97a1cf50045d1",
"sourceSha256": "cc19258fcf63018ce2dac4d7253e23fbfb71596ee27235871ad78aae743b57e6",
"status": "pending"
},
"source-b:a388287911bc39cb": {
"sourcePathHash": "a388287911bc39cb3f88824044426782df3c3e6450c4db9e4948c28a47dbf9f1",
"sourceSha256": "20e13c3458f6af8d0b131f650f573eb824978438b52475a2f480a67c2f846cd6",
"status": "pending"
},
"source-b:ca08ee8839bccc15": {
"sourcePathHash": "ca08ee8839bccc1504c253cdb9bfc09803ddf77f948605f8563d04e0466a2ce7",
"sourceSha256": "0086c8da048d94303e9d5765b91b7eff9a10a798ffa73de9f245be5422cf3255",
"status": "pending"
},
"source-b:1b4cee54060ef399": {
"sourcePathHash": "1b4cee54060ef3995d6c98e99dee3c3200ab2e90dd62136cef07b9b09e31364d",
"sourceSha256": "24df169fbb18f39be5ec4046cc8d33535b1b86d2114673ee35d1a2a0324c1b25",
"status": "pending"
},
"source-b:7b7552c8c6e1b5f5": {
"sourcePathHash": "7b7552c8c6e1b5f56a283db304272da8cd63210aaf7327231a7b26f3cf903082",
"sourceSha256": "9c373fe6bb955d4431e92bba66239cb707a6179082a624b74bc77fa36663fc61",
"status": "pending"
},
"source-b:5f525e6aeda55dd8": {
"sourcePathHash": "5f525e6aeda55dd81c26d38cf59097cb52abbcfc5b05e35fe7e8101a1a3353b5",
"sourceSha256": "8068a6137951e94a01bc68cf9011db69f3d759304a3167291afa4c238531e34a",
"status": "pending"
},
"source-b:f2079362cf82e719": {
"sourcePathHash": "f2079362cf82e719e2dde6098c55ce984709a97446a7f66113f8b5f317f1f55b",
"sourceSha256": "f57f3c0d5175c3c1700b74e8489d820eed4ef36d10e79ab14e096cb2a20a0cec",
"status": "pending"
},
"source-b:b78122ad4813e9ba": {
"sourcePathHash": "b78122ad4813e9baef0ee5281bddb910b01fd89bdbbd2753011ac3deb52a8ce5",
"sourceSha256": "c06d7eb2674dcbb4bad01a8c65c6d372dffd95c585a16ab2c3f1bba19df199e0",
"status": "pending"
},
"source-b:7fd143975b0071c3": {
"sourcePathHash": "7fd143975b0071c3efa271a9ad19c3d7e422f64d3e23aa08d7a091c805cb9467",
"sourceSha256": "33582dcda068247a5c56238134dad308d1f66b03abbfdc7bc282a9ac9ff918a2",
"status": "pending"
},
"source-b:affd0805467646c1": {
"sourcePathHash": "affd0805467646c1fcb4c196776e9cb610fcec721e1a44df3bc02f5c08b84aeb",
"sourceSha256": "c7f565da3a1f412d3f15642fc9ad44733adee73c4d13cdebffaff1de4ca5652c",
"status": "pending"
},
"source-b:2a64acb63e8b2030": {
"sourcePathHash": "2a64acb63e8b203031c4ef292f957c0fe0d3722e079dc9a299730015b9e3359c",
"sourceSha256": "580da97b2fb16543db9aef62359f153fe5aca382d88987f10f233289bc02790d",
"status": "pending"
},
"source-b:3a5d583c511959b3": {
"sourcePathHash": "3a5d583c511959b3d1934d15eaa9b0cd0590c558535b6a8307f870d7ce0c8dd0",
"sourceSha256": "6b69964b6e65ddc6eefa857fa1016587d3b2d2fac22d2bda4196762233fb025b",
"status": "pending"
},
"source-b:13bb77c66fee0964": {
"sourcePathHash": "13bb77c66fee09641fe61781ffbba4c51ed47b14ce225972ea35219e489405a3",
"sourceSha256": "abe529dcc85b1ac88791c95cf7be12cf5b1f545988b515f10edb55f0a8c94fb1",
"status": "pending"
},
"source-b:fd03e996181699d9": {
"sourcePathHash": "fd03e996181699d95a09c1940f685ac366daeeda1aa901175d26dcf153f755fb",
"sourceSha256": "b4ef1f7847dd9081fdacaecae8853b2946e22c899b08bbe48ef280d21546e044",
"status": "pending"
},
"source-b:4ca0940d73b4b626": {
"sourcePathHash": "4ca0940d73b4b6264a37e1a344d669367dc313364a0077abf8bcaf97cfed42a1",
"sourceSha256": "92b3cea563f544806493245bdf099e2357112ccbce15007593c94d62c26c258c",
"status": "pending"
},
"source-b:0b917cc63232dff3": {
"sourcePathHash": "0b917cc63232dff38c375d3bec3fabe57390201c34c3ce96f7885eb23bca91d7",
"sourceSha256": "5ecfa8040f5d71f347cfcfb89040a5cbfdb96f71867fb11b750b78d4aa17d66a",
"status": "pending"
},
"source-b:905e74ea51281da1": {
"sourcePathHash": "905e74ea51281da179c1eb31677af71569a95139a74fb139d39dadd52db2ce62",
"sourceSha256": "cfc1eb3fafb15dc51b730dd36d3ad507b5ae415d5f1a5da5138471170afdde38",
"status": "pending"
},
"source-b:274c4aac900a23c3": {
"sourcePathHash": "274c4aac900a23c3ca0506807c4df8e09c95e21b84fd633951bae146f6c41028",
"sourceSha256": "b4beacdc745cd06ee4be20694d60bae0c9c6122fb61b917b12b66451497651e9",
"status": "pending"
},
"source-b:6e1a4d182b2e41fd": {
"sourcePathHash": "6e1a4d182b2e41fddd28051c20d7170821a87b518e49684cc4f12b731789831d",
"sourceSha256": "cf106df97100aabf260db7c8d45c2d6d499c643b4898d7e46c98852a90b7134d",
"status": "pending"
},
"source-b:cd4c15d66d5cfba0": {
"sourcePathHash": "cd4c15d66d5cfba0c568b6f3ac4ceac9a7cea797eca8c6bf5eab81187e70f7d7",
"sourceSha256": "a9b109dc659e8e65645b6507ab2a39a04598d0c0ed63feaef0985765d3ff9f3f",
"status": "pending"
},
"source-b:a6062c4036828615": {
"sourcePathHash": "a6062c4036828615945db311cf68c2ac886feef91c79b53d882bbe41fd71143f",
"sourceSha256": "94b7e58d4c4903498cf29365fe466aaba8bfdf4675754f65cd30bad37c99e02f",
"status": "pending"
},
"source-b:f58c6cd52bc983e8": {
"sourcePathHash": "f58c6cd52bc983e87d265308ade0da8d89bd74d5d4f1e96fc463ef299a9fe94a",
"sourceSha256": "98392eb0fe65dab6877495e6863a6722cd2777f5767dfd95b3a829ed7798a5e7",
"status": "pending"
},
"source-b:258ee10299925494": {
"sourcePathHash": "258ee1029992549420c8465c8de2d63c1bfaf9334b812d6b79a07a9823b49733",
"sourceSha256": "b4958d4aee3c06b778273d87a07dd423cf15fc8fbfdde27e00552ff60138795e",
"status": "pending"
},
"source-b:52592d4a59314c74": {
"sourcePathHash": "52592d4a59314c74de9b58cfb4cda6e9f5d9514bb05c9b586044c12754982d52",
"sourceSha256": "e8be1b2c17ab3aaccbb5559a07b64da8a7c718293e83cb8dd685c87708c7cbdc",
"status": "pending"
},
"source-b:f478193705d1d02d": {
"sourcePathHash": "f478193705d1d02d6bb6375728dc10d34487e3f67ab8d164a80104f6a71fccdc",
"sourceSha256": "218eb591d1944b2280853bbf39cb028f9a41e55dee01218838b14f87755fad72",
"status": "pending"
},
"source-b:e4b14f4b2719e28b": {
"sourcePathHash": "e4b14f4b2719e28b409bc66980b3698588684dde81a0c627e862262e76c34b2f",
"sourceSha256": "de185022a0cf8bfce340709f47052578ec761664c96723172c48bdd4f10e71f1",
"status": "pending"
},
"source-b:aa42f7c1a045bb49": {
"sourcePathHash": "aa42f7c1a045bb496bbc9e62beb7a7b74544153ed9551492f5b801ca4c9df098",
"sourceSha256": "aff6fad01afa2a15756a36d210c67b229a1eb2155981292450f0487c3005125d",
"status": "pending"
},
"source-b:3763e17cff825df6": {
"sourcePathHash": "3763e17cff825df6920f57d84736093082014f2a0316d9e84ee18ac8078ff6e5",
"sourceSha256": "bd2e4ae7f8a9bded1f7a983289fb49556f30cd5d6c6382500dd8c464fdd66e02",
"status": "pending"
},
"source-b:9e3e75840a46af96": {
"sourcePathHash": "9e3e75840a46af96c33a1885f192813239b511eb972a4962b30b76de7392c88c",
"sourceSha256": "379c41e41d833880653ce065a63380e9cf29d2b7663f7ee91a3d77594202d042",
"status": "pending"
},
"source-b:766516f82e8e9be3": {
"sourcePathHash": "766516f82e8e9be32e1ff3f29123202b6bad85bf9a88e36f87f256446ea31eaf",
"sourceSha256": "c66ca1651923b20cd62395ba2cc6b8532093d5df10f420c1673a103a88dbf5aa",
"status": "pending"
},
"source-b:31dec2fa162b06d3": {
"sourcePathHash": "31dec2fa162b06d379aa676d426d2de01c3b082061ff15adb7a8c721cd5635af",
"sourceSha256": "a146d79ccfec7b1f9275cda8e6cf3ab1190430d0c3bc11886a722ce1d6fabc90",
"status": "pending"
},
"source-b:988b200efd0b8be0": {
"sourcePathHash": "988b200efd0b8be024060e0785da2f864bf7ee30d7c7b434fde7ab1e4cdcc9d8",
"sourceSha256": "e7fa073d592167c07b06698d664192baafb126afa1a6bfdc50a056e1ea13e56f",
"status": "pending"
},
"source-b:8a7cdf719d8f8a3e": {
"sourcePathHash": "8a7cdf719d8f8a3e0200781a82062cc5a839e4d2c2fd400982771968245d62dd",
"sourceSha256": "3461485ba35dceffc5fc0e9266793003a0355d412aa8a609c93709745226d00a",
"status": "pending"
},
"source-b:6e5eb2b9ab9273cf": {
"sourcePathHash": "6e5eb2b9ab9273cf889bd908ff446eda59cc3e121ee7a1861fb2664d7fa049e9",
"sourceSha256": "31eef261e6e3e930cd88b18c938176c3ef1d3e8d039986712ee6c3191481872d",
"status": "pending"
},
"source-b:a985b2c0fb927117": {
"sourcePathHash": "a985b2c0fb9271174d9d46425eb6792d2483ebcd6da89ecc1cbfb61407259499",
"sourceSha256": "e2f92df8dc489e79bfdd74de8817abf742f425422765c76c4a46263d1fbd7a9e",
"status": "pending"
}
}
} }
+95
View File
@@ -0,0 +1,95 @@
"""迁移内部脚本的最小行为测试。"""
from __future__ import annotations
import importlib.util
import hashlib
import json
import tempfile
import unittest
from pathlib import Path
SCRIPT_ROOT = Path(__file__).parents[1] / "scripts"
def load_module(name: str):
"""从脚本路径加载模块,避免要求项目安装为 Python 包。"""
spec = importlib.util.spec_from_file_location(name, SCRIPT_ROOT / f"{name}.py")
module = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(module)
return module
scan_sources = load_module("scan_sources")
check_skill = load_module("check_skill")
update_lock = load_module("update_lock")
class MigrationScriptTests(unittest.TestCase):
def test_scan_uses_relative_paths_and_stable_hash(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
skill = root / "group" / "demo"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
"---\nname: demo\ndescription: 示例\n---\n", encoding="utf-8"
)
first = scan_sources.scan_source("source-a", root)
second = scan_sources.scan_source("source-a", root)
self.assertEqual("group/demo", first["skills"][0]["relativePath"])
self.assertEqual(first["skills"][0]["sha256"], second["skills"][0]["sha256"])
self.assertNotIn(str(root), json.dumps(first, ensure_ascii=False))
def test_check_reports_name_mismatch_and_forbidden_term(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "expected"
root.mkdir()
(root / "SKILL.md").write_text(
"---\nname: other\ndescription: 示例\n---\n内部标识",
encoding="utf-8",
)
result = check_skill.check_skill(root, ["内部标识"])
codes = {issue["code"] for issue in result["issues"]}
self.assertIn("name-path-mismatch", codes)
self.assertIn("forbidden:内部标识", codes)
def test_update_lock_preserves_manual_fields(self):
path_hash = hashlib.sha256("group/demo".encode("utf-8")).hexdigest()
key = f"source-a:{path_hash[:16]}"
lock = {
"schemaVersion": 1,
"sources": {},
"skills": {
key: {
"status": "specified",
"target": "plugins/skill/skills/demo",
}
},
}
report = {
"sources": [
{
"id": "source-a",
"commit": "abc",
"skillCount": 1,
"skills": [
{
"name": "demo",
"relativePath": "group/demo",
"sha256": "123",
}
],
}
]
}
merged = update_lock.merge(lock, report)
entry = merged["skills"][key]
self.assertEqual("specified", entry["status"])
self.assertEqual("plugins/skill/skills/demo", entry["target"])
self.assertEqual("123", entry["sourceSha256"])
self.assertNotIn("name", entry)
if __name__ == "__main__":
unittest.main()