Files
吴让宇 c5905a5b1e refactor: 本仓升为唯一源(原 sap-cli 源码仓归档)
方向反转:此前 SKILL.md 是「模板渲染产物」、sap-cli 是源;现 sap-cli 归档,
sap-cli-skill 承接开发与分发,SKILL.md 回归手工维护的正本。

迁移(来自 sap-cli,共 104 文件):
- tests/           692 例测试(15 个文件的内联 sys.path 改指 assets/)
- openspec/        SDD 规格与归档变更(42 文件)
- docs/            开发文档与 ADT 原理(含 dev/CLAUDE.md、AGENTS.md)
- .claude/         rules 副本 + settings.json(供 Claude Code)
- .github/ .hermes/ .pre-commit-config.yaml .editorconfig CLAUDE.md
- scripts/ 保持仅 setup.py(pack_skill.py 已随旧仓归档,不迁)

修复(迁移暴露的真实缺陷):
- assets/pyproject.toml 的 build-backend 写作 `setuptools.backends._legacy:_Backend`,
  该模块在 setuptools 中不存在 → `pip install -e` 从来装不上。改为 build_meta。
  实测:临时 venv 安装成功,sap-cli --help 正常列出 31 个命令
- pyproject readme 指向不存在的 assets/README.md(editable 安装会失败)→ 改内联文本
- pyproject urls 改指 sap-cli-skill

机制调整:
- .github/workflows/ci.yml 适配 assets/ 布局;顶部注明该工作流仅 GitHub 执行,
  本仓在 Gitee 不会自动跑
- pre-commit 增本地测试门禁(Gitee 上真正生效的那道)
- .gitignore 合并旧仓完整规则(保留 log/ 下 md 知识库入库,只忽略运行日志)
- 大文件上限 100KB→1MB(架构图 512KB)

守卫测试 tests/unit/test_repo_guards.py(10 → 18 例):
- SKILL.md 须记录 parser 全部 CLI 命令 / 铁律 1-5 须为真实小节标题 / 示例不得违反铁律 5
- references/ 规则齐备;.claude/rules 与 references 必须一致(实测抓到一次真实漂移)
- VERSION == sapcli.__version__ == README 版本
- 仓内不得再出现 pack_skill.py / skill-src(防废弃流程回潮)

698 tests OK;editable 安装与 CLI 入口经临时 venv 实测通过。
docs/RELEASING.md 重写为单源开发流程。
2026-09-11 00:40:15 +08:00

23 KiB
Raw Permalink Blame History

title, created, updated, tags, parent, related
title created updated tags parent related
批量同步设计 — 项目清单驱动 2026-05-25 2026-05-25
SAP
ADT
design
sap-cli/doc/sap-cli使用指南
sap-cli/doc/技术说明
sap-cli/doc/sap-cli使用指南#4.3 源代码同步激活 (`sync`)

批量同步设计 — 项目清单驱动

[!abstract] 概述 为 sap-cli 工具增加项目清单机制,支持批量源代码同步。核心思路:在源代码目录下维护 manifest.json 清单文件,记录每个开发对象在 SAP 系统中的状态,后续操作基于清单驱动,避免重复查询。

[!info] 设计动机 现有 sync 命令只支持单对象操作,缺少以下能力:

  • 批量扫描本地文件并识别对象
  • 预查询 SAP 系统状态,制定执行计划
  • 按依赖关系排序,批量执行同步
  • 缓存对象状态,避免每次重复查询

1. 目录约定

1.1 项目目录结构

项目根目录下按对象类型组织子目录,manifest.json 位于根目录:

project/                        ← --path 指向这里(项目根目录)
├── manifest.json               ← 状态清单(init 自动生成)
├── reports/
│   └── {对象名小写}.abap
├── classes/
│   └── {对象名小写}.abap
├── interfaces/
│   └── {对象名小写}.abap
├── functions/
│   └── {函数组名小写}/
│       └── {函数模块名小写}.abap
├── domains/
│   └── {对象名小写}.abap
├── dataelements/
│   └── {对象名小写}.abap
├── tables/
│   └── {对象名小写}.abap
└── tabletypes/
    └── {对象名小写}.abap

1.2 目录名 → 类型映射

init 扫描时按以下映射表从目录名推断对象类型:

目录名 对象类型 文件 → 对象名规则
reports/ report zmy_report.abapZMY_REPORT
classes/ class zcl_my_class.abapZCL_MY_CLASS
interfaces/ interface zif_my_interface.abapZIF_MY_INTERFACE
functions/ function zgroup/z_my_func.abapZGROUP/Z_MY_FUNC
domains/ domain z_status.abapZ_STATUS
dataelements/ dataelement z_status.abapZ_STATUS
tables/ table zmy_table.abapZMY_TABLE
tabletypes/ tabletype zty_table.abapZTY_TABLE

[!note] 文件名 → 对象名规则

  • 文件名去掉 .abap 后缀后转为大写即为对象名
  • functions/ 下的子目录名作为函数组名,子目录内的文件名作为函数模块名
  • 对象名为 函数组名大写/函数模块名大写 格式

2. 清单文件 (manifest.json)

2.1 结构定义

{
  "version": 1,
  "last_init": "2026-05-25T10:00:00",
  "last_refresh": "2026-05-25T14:00:00",
  "objects": {
    "<对象名>": {
      "type": "<对象类型>",
      "file": "<相对文件路径>",
      "system_status": "<状态>",
      "corr_nr": "<传输请求号或null>",
      "depends_on": ["<依赖对象名>"],
      "last_sync": "<ISO时间戳或null>",
      "last_sync_result": "<结果>"
    }
  }
}

2.2 字段说明

字段 类型 说明
version int 清单格式版本号,当前为 1
last_init string 最近一次 init 的时间戳
last_refresh string 最近一次 refresh 的时间戳
objects object 以对象名为 key 的字典
objects.<name>.type string 对象类型(report/class/interface/...
objects.<name>.file string 相对于项目根目录的文件路径
objects.<name>.system_status string active / inactive / not_exists
objects.<name>.corr_nr string|null 传输请求号,null 表示本地对象 $TMP
objects.<name>.depends_on string[] 依赖的对象名列表
objects.<name>.last_sync string|null 最近一次成功同步的时间
objects.<name>.last_sync_result string success / failed / pending

2.3 示例

{
  "version": 1,
  "last_init": "2026-05-25T10:00:00",
  "last_refresh": null,
  "objects": {
    "Z_STATUS": {
      "type": "domain",
      "file": "domains/z_status.abap",
      "system_status": "active",
      "corr_nr": "DEVK901362",
      "depends_on": [],
      "last_sync": "2026-05-25T10:30:00",
      "last_sync_result": "success"
    },
    "ZIF_MY_INTERFACE": {
      "type": "interface",
      "file": "interfaces/zif_my_interface.abap",
      "system_status": "active",
      "corr_nr": "DEVK901362",
      "depends_on": [],
      "last_sync": "2026-05-25T10:30:00",
      "last_sync_result": "success"
    },
    "ZCL_MY_CLASS": {
      "type": "class",
      "file": "classes/zcl_my_class.abap",
      "system_status": "active",
      "corr_nr": "DEVK901362",
      "depends_on": ["ZIF_MY_INTERFACE"],
      "last_sync": "2026-05-25T11:00:00",
      "last_sync_result": "success"
    },
    "ZMY_REPORT": {
      "type": "report",
      "file": "reports/zmy_report.abap",
      "system_status": "inactive",
      "corr_nr": null,
      "depends_on": ["ZCL_MY_CLASS"],
      "last_sync": null,
      "last_sync_result": "pending"
    },
    "ZGROUP/Z_MY_FUNC": {
      "type": "function",
      "file": "functions/zgroup/z_my_func.abap",
      "system_status": "active",
      "corr_nr": "DEVK901362",
      "depends_on": [],
      "last_sync": "2026-05-25T10:00:00",
      "last_sync_result": "success"
    }
  }
}

3. 命令变更一览

命令 变更类型 说明
init 新增 扫描项目目录 → 查询 SAP → 生成 manifest.json
create 变更 新增 --path 参数;创建成功后自动写入清单
sync 变更 新增 --all 批量模式;单对象模式操作后更新清单
delete 变更 新增 --path 参数;删除成功后从清单移除
refresh 新增 重新查询清单中所有对象的 SAP 状态

4. 命令详细设计

4.1 init — 项目初始化(新增)

命令格式

python main.py init --path <项目根目录>

执行流程

扫描项目目录
  ├─ 按目录映射表识别文件 → 生成 {对象名, 类型, 文件路径} 列表
  ├─ 输出: "扫描到 N 个本地文件"
  └─ 忽略非 .abap 文件和隐藏文件

逐个查询 SAP 系统
  ├─ 对象存在 → 查询 info → 获取 system_status + corr_nr
  ├─ 对象不存在 → 标记 system_status: "not_exists"
  └─ 查询失败 → 标记 system_status: "unknown"

应用类型默认优先级生成初始 depends_on
  (init 时只设置类型层面的默认依赖,用户可后续手动编辑)
  domain/dataelement/table → 无依赖
  tabletype → 依赖同项目中的 table
  interface → 无依赖
  class → 依赖同项目中被引用的 interface
  function → 无显式依赖
  report → 无显式依赖

写入 manifest.json
输出汇总报告

输出示例

============================================================
  sap-cli 项目初始化
============================================================
  项目路径: ./project
  扫描到 5 个本地文件

  → 正在查询 SAP 系统状态...
  ✓ Z_STATUS           (domain)     active   corr: DEVK901362
  ✓ ZIF_MY_INTERFACE   (interface)  active   corr: DEVK901362
  ✓ ZCL_MY_CLASS       (class)      active   corr: DEVK901362
  ⚠ ZMY_REPORT         (report)     inactive corr: 本地对象
  ✗ ZCL_NEW_CLASS      (class)      不存在

  ✓ 清单已生成: ./project/manifest.json
     5 个对象: 3 已激活, 1 未激活, 1 不存在

[!tip] 清单可手动编辑 manifest.json 生成后,用户可以手动编辑 depends_on 字段来补充对象间的显式依赖关系。 init 只设置类型层面的默认依赖,具体的引用关系(如某个 class 实现了哪个 interface)需要用户自行标注。


4.2 sync --all — 批量同步(新增)

命令格式

python main.py sync --all --path <项目根目录> [--corr_nr <传输请求号>] [--fail-fast] [--dry-run]
参数 必需 说明
--all 启用批量模式
--path 项目根目录(包含 manifest.json
--corr_nr 统一传输请求号(跳过交互选择,所有对象共用)
--fail-fast 遇到失败立即停止(默认:跳过并继续)
--dry-run 仅输出执行计划,不实际操作

执行流程

读取 manifest.json
  ├─ 不存在 → 报错: "请先执行 init"
  └─ 存在 → 继续

扫描本地文件 vs 清单差异
  ├─ 本地有但清单没有的文件 → 提示: "N 个未纳入清单的文件,建议先 create"
  └─ 清单有但本地被删的文件 → 警告,跳过

过滤待处理对象
  ├─ system_status == "active" && last_sync_result == "success" → 跳过(已同步)
  ├─ system_status == "not_exists" → 标记为 [create + sync]
  └─ 其他 → 标记为 [sync]

拓扑排序
  ├─ 按 depends_on 建有向图
  ├─ 无显式依赖的按类型默认优先级排序
  └─ 检测循环依赖 → 报错退出

--dry-run 模式:
  输出执行计划,不实际操作

传输请求预选择(一次性交互):
  ├─ 扫描待处理对象,统计需要传输请求号的数量
  ├─ 指定了 --corr_nr → 直接使用,无交互
  ├─ 全部对象已绑定请求号或为本地对象 → 无交互
  └─ 有对象需要请求号 → 弹出一次选择,选定后全局复用:
       ├─ 选择已有请求号 → 后续所有需要请求号的对象都使用此请求号
       ├─ 新建请求号 → 同上
       └─ 不使用请求号 → 所需对象都作为本地对象处理

执行阶段(按排序顺序逐个处理,使用预选的传输请求号):
  对每个对象:
    ├─ 本地文件不存在 → 跳过,警告
    ├─ system_status == "not_exists" → 先执行 create(使用预选请求号)
    ├─ 执行 sync 逻辑(使用预选请求号,跳过逐对象交互):
    │     ├─ 对象已绑定请求号 → 使用对象自身绑定的请求号
    │     ├─ 本地对象($TMP)→ 无需请求号
    │     └─ 未绑定 → 使用预选的统一请求号(不再弹出交互)
    ├─ 成功 → 更新 manifest:
    │     system_status: "active"
    │     corr_nr: 实际使用的请求号
    │     last_sync: 当前时间
    │     last_sync_result: "success"
    └─ 失败 →
         ├─ --fail-fast → 停止,输出已处理和未处理的对象
         └─ 默认 → 标记 last_sync_result: "failed",跳过,继续下一个
                     后续依赖此对象的对象也自动跳过(标记 skipped)

输出汇总报告

dry-run 输出示例

============================================================
  sap-cli 批量同步 (dry-run)
============================================================
  项目路径: ./project
  清单对象: 5

  执行计划(按依赖排序):
    1. Z_STATUS           (domain)     → sync [inactive → active]
    2. ZIF_MY_INTERFACE   (interface)  → 跳过 [已是最新]
    3. ZCL_MY_CLASS       (class)      → sync [依赖: ZIF_MY_INTERFACE ✓]
    4. ZMY_REPORT         (report)     → sync [依赖: ZCL_MY_CLASS]
    5. ZGROUP/Z_MY_FUNC   (function)   → 跳过 [已是最新]

  待处理: 3 个 | 跳过: 2 个

执行输出示例

============================================================
  sap-cli 批量同步
============================================================
  项目路径: ./project
  待处理: 3 个对象

  → 预检传输请求需求...
     2 个对象已绑定请求号: Z_STATUS(DEVK901362), ZCL_MY_CLASS(DEVK901362)
     1 个对象需要传输请求: ZMY_REPORT

  → 查询可用的传输请求...
  找到 2 个可修改的传输请求:

    1. DEVK901362  Implement sales validation  (所有者: ADMIN2)
    2. DEVK901365  Bugfix for invoice printing  (所有者: ADMIN2)
    3. 新建传输请求
    0. 不使用传输请求(本地对象)

  请选择 [0-3]: 1
  ✓ 统一传输请求: DEVK901362(后续所有对象共用此请求号)

  ── [1/3] Z_STATUS (domain) ──
  ✓ 锁定成功(对象已绑定传输请求: DEVK901362)
  ✓ 源代码写入成功
  ✓ 解锁成功
  ✓ 语法检查通过
  ✓ 激活成功

  ── [2/3] ZCL_MY_CLASS (class) ──
  ✓ 锁定成功(对象已绑定传输请求: DEVK901362)
  ✓ 源代码写入成功
  ✓ 解锁成功
  ✓ 语法检查通过
  ✓ 激活成功

  ── [3/3] ZMY_REPORT (report) ──
  ✓ 锁定成功(使用统一传输请求: DEVK901362)
  ✓ 源代码写入成功
  ✓ 解锁成功
  ✗ 语法检查未通过! 1 个错误
     [错误] 行 10: Field "XXX" is unknown
  ⊘ 跳过(标记为 failed

  ============================================================
  批量同步完成
  ============================================================
    ✓ 成功:  Z_STATUS, ZCL_MY_CLASS
    ✗ 失败:  ZMY_REPORT (语法错误,行 10)
    ⊘ 跳过:  (无)
    清单已更新: ./project/manifest.json

[!tip] 指定 --corr_nr 跳过交互 如果已知传输请求号,使用 --corr_nr 参数可以直接跳过交互选择:

python main.py sync --all --path ./project --corr_nr DEVK901362

此时所有未绑定请求号的对象都会使用指定的请求号,全程零交互


4.3 refresh — 刷新清单状态(新增)

命令格式

python main.py refresh --path <项目根目录>

执行流程

读取 manifest.json
逐个重新查询 SAP info
更新每个对象的 system_status 和 corr_nr
(不修改 last_sync 和 last_sync_result
输出变更摘要

使用场景

  • 其他人修改了 SAP 端的对象
  • 传输请求状态发生变化(释放、导入等)
  • 需要确认当前系统状态但不想执行同步

输出示例

============================================================
  sap-cli 清单刷新
============================================================
  项目路径: ./project
  清单对象: 5

  → 正在查询 SAP 系统状态...
  ✓ Z_STATUS           (domain)     active   corr: DEVK901362 (未变化)
  ✓ ZIF_MY_INTERFACE   (interface)  active   corr: DEVK901362 (未变化)
  ~ ZCL_MY_CLASS       (class)      inactive corr: DEVK901362 (状态变更: active → inactive)
  ✓ ZMY_REPORT         (report)     inactive corr: null       (未变化)
  ✓ ZGROUP/Z_MY_FUNC   (function)   active   corr: DEVK901362 (未变化)

  ✓ 刷新完成
     变更: 1 个 | 未变化: 4 个

4.4 create — 变更:自动写入清单

变更内容

  • 新增 --path 参数:指向项目根目录
  • 创建成功后:自动在 manifest.json 中新增一条记录

新增的清单记录

{
  "ZCL_NEW_CLASS": {
    "type": "class",
    "file": "classes/zcl_new_class.abap",
    "system_status": "active",
    "corr_nr": "DEVK901362",
    "depends_on": [],
    "last_sync": "2026-05-25T14:00:00",
    "last_sync_result": "success"
  }
}

向后兼容

  • 如果未指定 --path,或指定目录下没有 manifest.json:仅创建对象,不写清单
  • 现有不含 --path 的用法不受影响

4.5 delete — 变更:从清单移除

变更内容

  • 新增 --path 参数:指向项目根目录
  • 删除成功后:从 manifest.json 中移除该对象记录

向后兼容

  • 如果未指定 --path,或指定目录下没有 manifest.json:仅删除对象,不更新清单

4.6 sync(单对象) — 变更:操作后更新清单

变更内容

  • 新增 --path 参数:指向项目根目录(可选)
  • sync 成功或失败后:更新 manifest.json 中对应对象的记录

向后兼容

  • 如果未指定 --path,或指定目录下没有 manifest.json:行为与现有完全一致

5. 依赖排序策略

5.1 类型默认优先级

无显式 depends_on 时,按类型优先级排序:

优先级 数值 类型 说明
1 10 domain 基础域定义
2 20 dataelement 依赖 domain
3 30 table 依赖 dataelement
4 40 tabletype 依赖 table
5 50 interface 接口定义
6 60 class 可能实现 interface
7 70 function 函数模块
8 80 report 程序,可能引用以上所有

5.2 排序算法

采用拓扑排序(Kahn 算法):

1. 以 manifest 中所有对象为节点
2. 建立有向边:
   - 显式依赖: depends_on 中列出的对象 → 当前对象
   - 类型默认优先级: 仅当两个对象之间没有显式依赖时作为 tie-breaker
3. 执行 Kahn 拓扑排序
4. 检测循环依赖 → 若存在未入队的节点则报错,输出循环链

5.3 依赖失败传播

批量 sync 时,若对象 A 依赖对象 B,而 B 同步失败:

A.depends_on = ["B"]
B.last_sync_result = "failed"

→ A 自动跳过,标记 last_sync_result: "skipped"
→ 依赖 A 的对象同样跳过(级联跳过)

6. 边界情况处理

场景 处理方式
本地新增了 .abap 文件但清单中没有 sync --all 时提示: "N 个未纳入清单的文件",建议先执行 create
清单中有对象但本地文件被删了 sync --all 时警告并跳过该对象
manifest.json 不存在 sync --all 报错,提示先执行 init
依赖对象 sync 失败 跳过所有依赖它的对象(级联标记 skipped)
循环依赖 检测到后报错,输出循环链中的对象名
非项目目录(无子目录结构) init 时只扫描当前目录下的 .abap 文件
SAP 连接中断 标记已处理的对象,输出中断位置,下次可从断点继续
manifest.json 版本不匹配 提示版本不兼容,建议重新 init
部分对象已绑定请求号,部分未绑定 已绑定的使用自身请求号,未绑定的使用预选的统一请求号
全部对象都是本地对象($TMP 无需传输请求,直接执行,零交互
指定了 --corr_nr 但部分对象已绑定其他请求号 已绑定的使用自身请求号(不受 --corr_nr 覆盖)

7. 项目生命周期

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  init ──────────────────────────────────────────────    │
│  │ 扫描本地文件 → 查询 SAP → 生成 manifest.json         │
│  │                                                      │
│  ▼                                                      │
│  create(新增对象时) ────────────────────────────────   │
│  │ 在 SAP 创建 → 自动写入 manifest                      │
│  │                                                      │
│  ▼                                                      │
│  sync --all(日常同步)─────────────────────────────    │
│  │ 读 manifest → 拓扑排序 → 逐个 sync → 更新 manifest  │
│  │                                                      │
│  ▼                                                      │
│  refresh(SAP 端有变化时)────────────────────────────  │
│    重新查询 SAP → 更新 manifest 中的状态字段             │
│                                                         │
└─────────────────────────────────────────────────────────┘

[!tip] 典型工作流

# 1. 首次使用:初始化项目清单
python main.py init --path ./my_project

# 2. 日常开发:新增对象
python main.py create --name ZCL_NEW --type class --path ./my_project --description "新类"

# 3. 日常开发:修改本地 .abap 文件后,批量同步
python main.py sync --all --path ./my_project

# 4. 如需查看执行计划(不实际操作)
python main.py sync --all --path ./my_project --dry-run

# 5. 其他人修改了 SAP 端对象后,刷新清单
python main.py refresh --path ./my_project

8. 模块设计(实现层面)

8.1 新增模块

模块 职责
sapcli/manifest.py 清单文件的读取、写入、查询、更新
sapcli/scanner.py 扫描本地目录,识别对象名和类型
sapcli/sorter.py 拓扑排序,依赖检测

8.2 清单模块 (manifest.py) 接口

@dataclass
class ManifestEntry:
    name: str
    type: str
    file: str
    system_status: str          # "active" / "inactive" / "not_exists"
    corr_nr: str | None
    depends_on: list[str]
    last_sync: str | None       # ISO timestamp
    last_sync_result: str       # "success" / "failed" / "pending"

class Manifest:
    version: int
    last_init: str | None
    last_refresh: str | None
    objects: dict[str, ManifestEntry]

    @classmethod
    def load(cls, project_path: str) -> "Manifest": ...
    def save(self) -> None: ...
    def upsert(self, entry: ManifestEntry) -> None: ...
    def remove(self, name: str) -> None: ...
    def get(self, name: str) -> ManifestEntry | None: ...
    def pending_objects(self) -> list[ManifestEntry]: ...

8.3 扫描模块 (scanner.py) 接口

DIRECTORY_TYPE_MAP = {
    "reports": "report",
    "classes": "class",
    "interfaces": "interface",
    "functions": "function",
    "domains": "domain",
    "dataelements": "dataelement",
    "tables": "table",
    "tabletypes": "tabletype",
}

@dataclass
class ScannedObject:
    name: str           # 大写对象名,function 类型含 /
    type: str           # 对象类型
    file: str           # 相对于项目根目录的路径

def scan_project(project_path: str) -> list[ScannedObject]: ...

8.4 排序模块 (sorter.py) 接口

TYPE_PRIORITY = {
    "domain": 10,
    "dataelement": 20,
    "table": 30,
    "tabletype": 40,
    "interface": 50,
    "class": 60,
    "function": 70,
    "report": 80,
}

class CyclicDependencyError(SapCliError): ...

def topological_sort(
    objects: list[ManifestEntry],
) -> list[ManifestEntry]: ...
# → 按 depends_on + TYPE_PRIORITY 排序
# → 检测循环依赖,抛出 CyclicDependencyError

9. 相关笔记