From 0e7eb1eb9377279fb75e72b28cd54fd75d864325 Mon Sep 17 00:00:00 2001 From: "zhiye.sun" Date: Tue, 25 Aug 2026 10:46:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(migration):=20=E5=BB=BA=E7=AB=8B=20Skill?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E5=B7=A5=E4=BD=9C=E6=B5=81=E4=B8=8E?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + AGENTS.md | 154 +++++++++++ README.md | 9 +- migration/MIGRATION_PLAN.md | 215 ++++++++++++++ migration/README.md | 12 +- migration/scripts/check_skill.py | 79 ++++++ migration/scripts/scan_sources.py | 111 ++++++++ migration/scripts/update_lock.py | 65 +++++ migration/source-lock.json | 323 +++++++++++++++++++++- migration/tests/test_migration_scripts.py | 95 +++++++ 10 files changed, 1063 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100644 migration/MIGRATION_PLAN.md create mode 100644 migration/scripts/check_skill.py create mode 100644 migration/scripts/scan_sources.py create mode 100644 migration/scripts/update_lock.py create mode 100644 migration/tests/test_migration_scripts.py diff --git a/.gitignore b/.gitignore index ba817e4..5805831 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ bin/ # CraftKit 本地迁移来源配置,仅用于开发机,不进入公开仓库 migration/local-sources.json +migration/source-scan.json +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c242ad --- /dev/null +++ b/AGENTS.md @@ -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//skills// +├─ 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 私有状态。 +- 不使用破坏性历史改写,也不推送,除非用户明确授权。 diff --git a/README.md b/README.md index bb09205..ad082d8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通用问题出发独立实现能力,不携带任何公司的品牌、内部框架、业务规则、源码、模板或环境信息。 +## 文档导航 + +- [协作约定](AGENTS.md):项目范围、Skill 设计、迁移边界、验证和 Git 规则。 +- [迁移计划](migration/MIGRATION_PLAN.md):迁移批次、依赖顺序、合并原则和验收门禁。 +- [迁移追踪说明](migration/README.md):来源指纹和状态维护方式。 + ## 插件组成 | 插件 | 用途 | 当前状态 | @@ -17,7 +23,8 @@ CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通 ```text CraftKit/ ├─ .agents/plugins/marketplace.json # 仓库级 Codex 市场清单 -├─ migration/ # 来源指纹与迁移追踪 +├─ AGENTS.md # Codex 与贡献者协作约定 +├─ migration/ # 迁移计划、来源指纹与状态追踪 ├─ plugins/ # 可独立安装的插件 │ ├─ dev/ │ ├─ doc/ diff --git a/migration/MIGRATION_PLAN.md b/migration/MIGRATION_PLAN.md new file mode 100644 index 0000000..d6ce2ed --- /dev/null +++ b/migration/MIGRATION_PLAN.md @@ -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。 diff --git a/migration/README.md b/migration/README.md index 0d699e9..78508a3 100644 --- a/migration/README.md +++ b/migration/README.md @@ -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 文件哈希。 @@ -10,4 +20,4 @@ 4. 在 CraftKit 中独立重写并完成验证。 5. 更新来源指纹、目标路径、状态和复核日期。 -允许的状态:`pending`、`rewriting`、`migrated`、`excluded`、`review`。 +允许的状态:`pending`、`specified`、`rewriting`、`review`、`migrated`、`excluded`、`superseded`。 diff --git a/migration/scripts/check_skill.py b/migration/scripts/check_skill.py new file mode 100644 index 0000000..fe368d3 --- /dev/null +++ b/migration/scripts/check_skill.py @@ -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() diff --git a/migration/scripts/scan_sources.py b/migration/scripts/scan_sources.py new file mode 100644 index 0000000..789933b --- /dev/null +++ b/migration/scripts/scan_sources.py @@ -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() diff --git a/migration/scripts/update_lock.py b/migration/scripts/update_lock.py new file mode 100644 index 0000000..0cb3574 --- /dev/null +++ b/migration/scripts/update_lock.py @@ -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() diff --git a/migration/source-lock.json b/migration/source-lock.json index 0a5fff5..819417d 100644 --- a/migration/source-lock.json +++ b/migration/source-lock.json @@ -11,5 +11,326 @@ "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" + } + } } diff --git a/migration/tests/test_migration_scripts.py b/migration/tests/test_migration_scripts.py new file mode 100644 index 0000000..e95cc3c --- /dev/null +++ b/migration/tests/test_migration_scripts.py @@ -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()