chore: 同步至源码 2.5.1 — SKILL.md 改为构建产物 + 工具代码刷新

SKILL.md(唯一来源 = sap-cli/skill-src/SKILL.md.tmpl):
- 本仓 SKILL.md 自此为构建产物,勿手工编辑;手改后再构建会被漂移护栏拦下
- 并入模板独有内容:调用方式节 + 补齐 parser 真实命令(clone/enhancement/
  history/unit-test/unlock/transport create,原表只 9 个,实际 31 个)
- 版本号改为 {{VERSION}} 注入(原字面量停在 v2.2.1/v2.3.0,与工具实际版本脱节)
- 修 1 处违反铁律 5 的示例(--path ./src → ./src/TMP/class/)

assets/(工具代码,此前严重过时):
- 9 个文件与源码不一致,scanner.py 尤甚:3818B → 6664B
- 修复前从本仓装出的工具**不支持三层 src 结构**,铁律 5 实际跑不通
- transport.py 反向漂移(分发版多 transport create,源码无)已随源码回迁对齐

VERSION 2.5.1;references/ 三份规则与源码一致(无差异)
This commit is contained in:
吴让宇
2026-09-11 00:22:05 +08:00
parent df1533e843
commit e786742bcb
10 changed files with 231 additions and 55 deletions
+25 -4
View File
@@ -1,6 +1,6 @@
--- ---
name: sap-cli name: sap-cli
description: "SAP ABAP ADT CLI for AI agents. Trigger when: operating ABAP objects, syncing code to/from SAP, managing transports, querying DDIC, running ABAP programs, reading SAP tables, or any SAP development task via terminal." description: "Use when you need to operate SAP ABAP development objects syncing code, querying metadata, managing transports, reading tables, or any task involving SAP ADT REST API. Trigger on: ABAP objects, SAP development, transport requests, DDIC types, SE11/SE16N/SE38/SE80 equivalents, 'sync to SAP', 'download from SAP', 'activate ABAP', 'read SAP table'. NOT for developing sap-cli itself."
version: "2.5.1" version: "2.5.1"
author: WuRangyu author: WuRangyu
license: MIT license: MIT
@@ -17,6 +17,21 @@ triggers:
Python CLI wrapping the SAP ADT REST API. Manages the full lifecycle of ABAP development objects from the terminal. Core loop: **download → local edit → sync back to SAP**. Python CLI wrapping the SAP ADT REST API. Manages the full lifecycle of ABAP development objects from the terminal. Core loop: **download → local edit → sync back to SAP**.
## 调用方式
```bash
cd <SKILL_DIR>/assets && python main.py <command> [options]
```
全局参数(置于命令之前):
| 参数 | 说明 |
|------|------|
| `--profile <name>` | 使用指定配置 profile |
| `--verify-ssl` | 启用 SSL 验证(默认关闭) |
安装与配置见同目录 `INSTALL.md`
## Quick Start ## Quick Start
```bash ```bash
@@ -37,7 +52,7 @@ python main.py read-table --name ZMY_TABLE --fields "F1,F2" --where "F1 = 'X'" -
## Command Reference ## Command Reference
Global params (before command): `--profile <name>`, `--verify-ssl`. Optional params in `[ ]`. Optional params in `[ ]`. 各命令参数以 `python main.py <cmd> --help` 为准。
| Category | Command | Key Parameters | | Category | Command | Key Parameters |
|----------|---------|----------------| |----------|---------|----------------|
@@ -49,20 +64,24 @@ Global params (before command): `--profile <name>`, `--verify-ssl`. Optional par
| CRUD | `activate` | `--name --type --corr_nr` (built-in NW 7.40 double-activate) | | CRUD | `activate` | `--name --type --corr_nr` (built-in NW 7.40 double-activate) |
| CRUD | `upload` | `--name --type --path --corr_nr` (lock→write→unlock only; no check/activate) | | CRUD | `upload` | `--name --type --path --corr_nr` (lock→write→unlock only; no check/activate) |
| CRUD | `syntax-check` | `--name --type` (remote object; no upload/activate) | | CRUD | `syntax-check` | `--name --type` (remote object; no upload/activate) |
| CRUD | `unlock` | `--name --type --lock-handle` (释放 delete 后残留的 enqueue 锁) |
| Batch | `init` | `--path` | | Batch | `init` | `--path` |
| Batch | `refresh` | `--path` | | Batch | `refresh` | `--path` |
| Batch | `sync --all` | `--path --corr_nr [--dry-run]` | | Batch | `sync --all` | `--path --corr_nr [--dry-run]` |
| Batch | `analyze` | `--path` (dependency analysis) | | Batch | `analyze` | `--path` (dependency analysis) |
| Batch | `clone` | `--name --type --from <profile> --to <profile> [--corr_nr]` (跨系统克隆对象) |
| Query | `show-table` | `--name` (SE11 structure) | | Query | `show-table` | `--name` (SE11 structure) |
| Query | `read-table` | `--name [--fields --where --max-rows]` (SE16N data) | | Query | `read-table` | `--name [--fields --where --max-rows]` (SE16N data) |
| Query | `list` | `--type [--package --prefix]` | | Query | `list` | `--type [--package --prefix]` |
| Query | `whereused` | `--name --type` | | Query | `whereused` | `--name --type` |
| Query | `search` | `--query` | | Query | `search` | `--query` |
| Query | `diff` | `--name --type --path` (local vs SAP) | | Query | `diff` | `--name --type --path` (local vs SAP) |
| Query | `history` | `--name --type` (对象版本历史) |
| Transport | `transport list` | — | | Transport | `transport list` | — |
| Transport | `transport info` | `--corr_nr` | | Transport | `transport info` | `--corr_nr` |
| Transport | `transport release` | `--corr_nr` | | Transport | `transport release` | `--corr_nr` |
| Transport | `transport objects` | `--corr_nr` | | Transport | `transport objects` | `--corr_nr` |
| Transport | `transport create` | `--description` (新建传输请求) |
| Config | `config show` | — | | Config | `config show` | — |
| Config | `config list-profiles` | — | | Config | `config list-profiles` | — |
| Config | `config set` | `<key> <val>` | | Config | `config set` | `<key> <val>` |
@@ -70,6 +89,8 @@ Global params (before command): `--profile <name>`, `--verify-ssl`. Optional par
| Other | `run-program` | `--name` (SA38) | | Other | `run-program` | `--name` (SA38) |
| Other | `check` | `--name --type` (ATC) | | Other | `check` | `--name --type` (ATC) |
| Other | `format` | `--name --type` (Pretty Printer) | | Other | `format` | `--name --type` (Pretty Printer) |
| Other | `enhancement` | `--name --type` (增强) |
| Other | `unit-test` | `--name --type` (ABAP Unit 测试) |
| Other | `package create` | `--name` | | Other | `package create` | `--name` |
| Other | `cds download` | `--name --path` | | Other | `cds download` | `--name --path` |
| Other | `scaffold` | `--name --template <t>` — alv-report, bapi-wrapper, interface-class, data-model | | Other | `scaffold` | `--name --template <t>` — alv-report, bapi-wrapper, interface-class, data-model |
@@ -80,7 +101,7 @@ Batch sync dependency order: `domain(10) → dataelement(20) → table(30) → t
Step-wise sync for lock-heavy automation — split the atomic `sync` when lock conflicts are frequent; upload now, verify, activate later: Step-wise sync for lock-heavy automation — split the atomic `sync` when lock conflicts are frequent; upload now, verify, activate later:
```bash ```bash
python main.py upload --name ZMY_CLASS --type class --path ./src/zmy_class.abap --corr_nr DEVK901XXX # lock→write→unlock only python main.py upload --name ZMY_CLASS --type class --path ./src/TMP/class/zmy_class.abap --corr_nr DEVK901XXX # lock→write→unlock only
python main.py syntax-check --name ZDEPENDENT_REPORT --type report # verify dependents (no upload) python main.py syntax-check --name ZDEPENDENT_REPORT --type report # verify dependents (no upload)
python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX # activate once clean python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX # activate once clean
``` ```
@@ -297,7 +318,7 @@ Automation fallbacks — exhaust in order before requesting any SAP GUI action:
- **Give the complete change checklist at once** — do the full comparison first (`show-table` vs code field refs); never surface issues incrementally. - **Give the complete change checklist at once** — do the full comparison first (`show-table` vs code field refs); never surface issues incrementally.
- **System-table writes** — 4-step authorization: explain *** → list exact statements → state risks & alternatives → await explicit approval. - **System-table writes** — 4-step authorization: explain *** → list exact statements → state risks & alternatives → await explicit approval.
## NW 7.40 Compatibility (E2E verified — v2.3.0) ## NW 7.40 Compatibility (E2E verified — 2.5.1)
Full matrix: `docs/NW740-COMPATIBILITY.md`. Full matrix: `docs/NW740-COMPATIBILITY.md`.
+1 -1
View File
@@ -2,4 +2,4 @@
sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具 sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具
""" """
__version__ = "2.3.0" __version__ = "2.5.1"
+2
View File
@@ -13,6 +13,7 @@ from sapcli.client import ADTClient
from sapcli.commands import ( from sapcli.commands import (
cmd_create, cmd_create,
cmd_delete, cmd_delete,
cmd_unlock,
cmd_download, cmd_download,
cmd_info, cmd_info,
cmd_init, cmd_init,
@@ -139,6 +140,7 @@ def main() -> None:
"sync": cmd_sync_all if getattr(args, "all", False) else cmd_sync, "sync": cmd_sync_all if getattr(args, "all", False) else cmd_sync,
"info": cmd_info, "info": cmd_info,
"delete": cmd_delete, "delete": cmd_delete,
"unlock": cmd_unlock,
"create": cmd_create, "create": cmd_create,
"init": cmd_init, "init": cmd_init,
"refresh": cmd_refresh, "refresh": cmd_refresh,
+6
View File
@@ -99,6 +99,12 @@ CDS View:
del_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型") del_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型")
del_parser.add_argument("--path", default=None, help="项目根目录 (可选,用于更新清单)") del_parser.add_argument("--path", default=None, help="项目根目录 (可选,用于更新清单)")
# ── unlock ──
unlock_parser = subparsers.add_parser("unlock", help="释放对象残留 enqueue 锁(delete 后锁未释放时用)")
unlock_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')")
unlock_parser.add_argument("--type", required=True, choices=type_choices, help="对象类型")
unlock_parser.add_argument("--lock-handle", required=True, help="LOCK 响应返回的 lockHandle(见 log/adt_tools.log")
# ── info ── # ── info ──
info_parser = subparsers.add_parser("info", help="查询对象元数据信息") info_parser = subparsers.add_parser("info", help="查询对象元数据信息")
info_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')") info_parser.add_argument("--name", required=True, help="对象名称 (function 类型用'组名/模块名')")
+6
View File
@@ -71,6 +71,12 @@ class DdicMixin:
logger.info("DELETE RESPONSE: HTTP %s", resp.status_code) logger.info("DELETE RESPONSE: HTTP %s", resp.status_code)
if resp.status_code in (200, 204): if resp.status_code in (200, 204):
logger.info("删除成功") logger.info("删除成功")
# 修复:delete 成功后必须释放 enqueue 锁,否则残留锁导致后续 create 403
try:
self.unlock(obj_uri, lock_handle)
logger.info("删除后锁已释放")
except Exception as e:
logger.warning("删除后解锁失败(可能锁已自动释放): %s", e)
return True, "" return True, ""
error_text = resp.text[:500] if resp.text else f"HTTP {resp.status_code}" error_text = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
logger.error("删除失败: %s", error_text) logger.error("删除失败: %s", error_text)
+2
View File
@@ -8,6 +8,7 @@ from sapcli.commands.crud import (
cmd_download, cmd_download,
cmd_info, cmd_info,
cmd_sync, cmd_sync,
cmd_unlock,
) )
from sapcli.commands.batch import ( from sapcli.commands.batch import (
cmd_init, cmd_init,
@@ -76,6 +77,7 @@ from sapcli.commands.enhancement import (
__all__ = [ __all__ = [
"cmd_create", "cmd_create",
"cmd_delete", "cmd_delete",
"cmd_unlock",
"cmd_download", "cmd_download",
"cmd_info", "cmd_info",
"cmd_init", "cmd_init",
+3 -2
View File
@@ -37,8 +37,9 @@ def cmd_init(args: argparse.Namespace, client: ADTClient) -> None:
if not scanned: if not scanned:
print(" 未扫描到 .abap 文件") print(" 未扫描到 .abap 文件")
print(" 请确认项目目录结构是否符合约定:") print(" 请确认项目目录结构是否符合约定(两种布局均可):")
print(" reports/, classes/, interfaces/, functions/, domains/, ...") print(" 三层: src/<开发包>/<对象类型>/<文件名>.abap (本地开发包固定 TMP)")
print(" 扁平: <对象类型>/<文件名>.abap (对象类型目录单复数均可)")
return return
print(f" ✓ 扫描到 {len(scanned)} 个本地文件\n") print(f" ✓ 扫描到 {len(scanned)} 个本地文件\n")
+24
View File
@@ -589,6 +589,30 @@ def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None:
raise DeleteError(f"删除失败: {error}") raise DeleteError(f"删除失败: {error}")
def cmd_unlock(args: argparse.Namespace, client: ADTClient) -> None:
"""释放指定对象的 enqueue 锁(清理 delete 后残留锁 / SM12 场景)。"""
obj_type: str = args.type
name: str = args.name
lock_handle: str = args.lock_handle
parsed = parse_object_name(name, obj_type)
type_label = get_type_config(obj_type).label
print("=" * 60)
print(" SAP ADT 释放对象锁")
print("=" * 60)
print(f" 对象类型: {type_label}")
print(f" 对象名称: {parsed.display_name}")
print(f"\n → 正在释放锁 (lockHandle={lock_handle[:12]}...)...")
ok = client.unlock(parsed.obj_uri, lock_handle)
if ok:
print(" ✓ 锁释放成功")
else:
print(" ✗ 锁释放失败(HTTP 非 200,可能锁已不存在或 handle 无效)")
raise SapCliError(f"解锁失败: {parsed.display_name}")
def _update_manifest_after_delete( def _update_manifest_after_delete(
args: argparse.Namespace, args: argparse.Namespace,
name: str, name: str,
+3 -3
View File
@@ -1,4 +1,4 @@
"""传输管理命令:transport (list / info / release / objects)。""" """传输管理命令:transport (list / info / release / objects / create)。"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -119,7 +119,7 @@ def _transport_create(args: argparse.Namespace, client: ADTClient) -> None:
print("=" * 60) print("=" * 60)
print(f" 描述: {description}") print(f" 描述: {description}")
print(f"\n → 正在创建...") print("\n → 正在创建...")
try: try:
new_nr = client.create_transport_request(description) new_nr = client.create_transport_request(description)
except Exception as e: except Exception as e:
@@ -129,7 +129,7 @@ def _transport_create(args: argparse.Namespace, client: ADTClient) -> None:
if new_nr: if new_nr:
print(f"\n ✓ 传输请求已创建: {new_nr}") print(f"\n ✓ 传输请求已创建: {new_nr}")
else: else:
print(f"\n ✗ 创建失败(未返回编号),请检查系统或描述合法性") print("\n ✗ 创建失败(未返回编号),请检查系统或描述合法性")
def _transport_objects(args: argparse.Namespace, client: ADTClient) -> None: def _transport_objects(args: argparse.Namespace, client: ADTClient) -> None:
+159 -45
View File
@@ -1,6 +1,22 @@
"""目录扫描模块。 """目录扫描模块。
扫描项目目录下按约定的子目录结构,识别 ABAP 开发对象。 扫描项目目录,识别 ABAP 开发对象。支持两种布局:
1. **三层布局(推荐,sap-cli-skill 铁律 5**
`src/<开发包>/<对象类型>/<文件名>.abap`
例:`src/TMP/class/zcl_demo.abap`
本地开发包固定写 `TMP`(对应 SAP 端 `$TMP` 本地包,目录名去掉 `$`)。
2. **扁平布局(旧项目,向后兼容)**:
`<对象类型>/<文件名>.abap`(项目根目录下,对象类型目录用复数)
例:`classes/zcl_demo.abap`
对象类型目录名**单复数均接受、大小写不敏感**`class/` 与 `classes/` 等价)。
函数模块(function 类型)两种命名均支持:
- 子目录式:`function/<函数组>/<函数模块>.abap`
- ADT 式:`function/<函数组>.fugr.<函数模块>.abap`abapGit 惯例)
对象名统一为 `函数组/函数模块`(大写)。
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,6 +26,7 @@ from dataclasses import dataclass
logger = logging.getLogger("sapcli.scanner") logger = logging.getLogger("sapcli.scanner")
# 对象类型目录名(复数,历史命名)→ 对象类型
DIRECTORY_TYPE_MAP: dict[str, str] = { DIRECTORY_TYPE_MAP: dict[str, str] = {
"reports": "report", "reports": "report",
"classes": "class", "classes": "class",
@@ -30,8 +47,38 @@ DIRECTORY_TYPE_MAP: dict[str, str] = {
"ddlx": "ddlX", "ddlx": "ddlX",
} }
# 单数形式目录名(sap-cli-skill 结构规范使用单数)→ 对象类型
_SINGULAR_TYPE_MAP: dict[str, str] = {
"report": "report",
"class": "class",
"interface": "interface",
"function": "function",
"domain": "domain",
"dataelement": "dataelement",
"table": "table",
"structure": "structure",
"tabletype": "tabletype",
"include": "include",
"cdsview": "cdsview",
"messageclass": "messageclass",
"view": "view",
"searchhelp": "searchhelp",
"lockobject": "lockobject",
"dcl": "dcl",
"ddlx": "ddlX",
}
# 查找用总表:小写目录名 → 对象类型
TYPE_DIR_LOOKUP: dict[str, str] = {**DIRECTORY_TYPE_MAP, **_SINGULAR_TYPE_MAP}
FUNCTIONS_DIR = "functions" FUNCTIONS_DIR = "functions"
# 源码根目录(三层布局的第 1 层父目录)
SRC_DIR = "src"
# ADT 式函数模块文件名分隔符:<fugr>.fugr.<fm>.abap
FUNC_ADT_SEP = ".fugr."
@dataclass @dataclass
class ScannedObject: class ScannedObject:
@@ -45,79 +92,146 @@ class ScannedObject:
def scan_project(project_path: str) -> list[ScannedObject]: def scan_project(project_path: str) -> list[ScannedObject]:
"""扫描项目目录,返回识别到的所有对象列表。 """扫描项目目录,返回识别到的所有对象列表。
遍历已知目录名,每个目录下找 .abap 文件。 先扫项目根目录下的对象类型目录(旧布局),
functions/ 特殊处理:子目录 = 函数组名 再扫 src/<开发包>/ 下的对象类型目录(三层布局)
同名同类型对象只保留第一次扫到的。
""" """
results: list[ScannedObject] = [] results: list[ScannedObject] = []
seen: set[tuple[str, str]] = set()
for dir_name, obj_type in DIRECTORY_TYPE_MAP.items(): # 扫描范围:(目录绝对路径, 该目录相对项目根的路径前缀)
dir_path = os.path.join(project_path, dir_name) scopes: list[tuple[str, str]] = [(project_path, "")]
if not os.path.isdir(dir_path):
logger.debug("目录不存在,跳过: %s", dir_path) src_path = os.path.join(project_path, SRC_DIR)
if os.path.isdir(src_path):
for pkg_name in sorted(os.listdir(src_path)):
if pkg_name.startswith("."):
continue
pkg_path = os.path.join(src_path, pkg_name)
if os.path.isdir(pkg_path):
scopes.append((pkg_path, f"{SRC_DIR}/{pkg_name}"))
for root_path, rel_prefix in scopes:
try:
entries = sorted(os.listdir(root_path))
except OSError as exc: # 权限/竞态
logger.debug("目录不可读,跳过: %s (%s)", root_path, exc)
continue continue
if dir_name == FUNCTIONS_DIR: for dir_name in entries:
_scan_functions(dir_path, results) if dir_name.startswith("."):
else: continue
_scan_flat_directory(dir_path, dir_name, obj_type, results) obj_type = TYPE_DIR_LOOKUP.get(dir_name.lower())
if obj_type is None:
continue
dir_path = os.path.join(root_path, dir_name)
if not os.path.isdir(dir_path):
continue
if obj_type == "function":
_scan_functions(dir_path, dir_name, rel_prefix, results, seen)
else:
_scan_flat_directory(dir_path, dir_name, obj_type, rel_prefix, results, seen)
logger.info("扫描完成: %d 个对象", len(results)) logger.info("扫描完成: %d 个对象", len(results))
return results return results
def _rel(rel_prefix: str, *parts: str) -> str:
"""拼相对项目根的路径(统一用 / 分隔,去掉空段)。"""
segs = [rel_prefix, *parts]
return "/".join(s for s in segs if s)
def _add(
results: list[ScannedObject],
seen: set[tuple[str, str]],
name: str,
obj_type: str,
rel_path: str,
) -> None:
key = (name, obj_type)
if key in seen:
logger.debug("重复对象,跳过: %s (%s) ← %s", name, obj_type, rel_path)
return
seen.add(key)
results.append(ScannedObject(name=name, type=obj_type, file=rel_path))
logger.debug("扫描到: %s (%s) → %s", name, obj_type, rel_path)
def _scan_flat_directory( def _scan_flat_directory(
dir_path: str, dir_path: str,
dir_name: str, dir_name: str,
obj_type: str, obj_type: str,
rel_prefix: str,
results: list[ScannedObject], results: list[ScannedObject],
seen: set[tuple[str, str]],
) -> None: ) -> None:
"""扫描普通目录(一对一:文件名 = 对象名)。""" """扫描普通目录(一对一:文件名 = 对象名)。"""
for filename in sorted(os.listdir(dir_path)): for filename in sorted(os.listdir(dir_path)):
if not filename.endswith(".abap"): if not filename.endswith(".abap"):
continue continue
# 隐藏文件跳过
if filename.startswith("."): if filename.startswith("."):
continue continue
if not os.path.isfile(os.path.join(dir_path, filename)):
continue
obj_name = filename[:-5].upper() # 去掉 .abap,转大写 obj_name = filename[:-5].upper() # 去掉 .abap,转大写
rel_path = f"{dir_name}/{filename}" _add(results, seen, obj_name, obj_type, _rel(rel_prefix, dir_name, filename))
results.append(ScannedObject(
name=obj_name,
type=obj_type,
file=rel_path,
))
logger.debug("扫描到: %s (%s) → %s", obj_name, obj_type, rel_path)
def _scan_functions(dir_path: str, results: list[ScannedObject]) -> None: def _scan_functions(
"""扫描 functions/ 目录(特殊处理:子目录 = 函数组名)。 dir_path: str,
dir_name: str,
rel_prefix: str,
results: list[ScannedObject],
seen: set[tuple[str, str]],
) -> None:
"""扫描函数模块目录。
目录结构: functions/{组名小写}/{模块名小写}.abap 支持两种命名:
对象名: {组名大写}/{模块名大写} - 子目录式: <函数组>/<函数模块>.abap
- ADT 式: <函数组>.fugr.<函数模块>.abap
对象名: <函数组大写>/<函数模块大写>
""" """
for group_dir_name in sorted(os.listdir(dir_path)): for entry in sorted(os.listdir(dir_path)):
group_dir = os.path.join(dir_path, group_dir_name) if entry.startswith("."):
if not os.path.isdir(group_dir):
continue continue
if group_dir_name.startswith("."): entry_path = os.path.join(dir_path, entry)
if os.path.isdir(entry_path):
# 子目录式:子目录名 = 函数组名
group_name = entry.upper()
for filename in sorted(os.listdir(entry_path)):
if not filename.endswith(".abap") or filename.startswith("."):
continue
if not os.path.isfile(os.path.join(entry_path, filename)):
continue
func_name = filename[:-5].upper()
_add(
results, seen,
f"{group_name}/{func_name}", "function",
_rel(rel_prefix, dir_name, entry, filename),
)
continue continue
group_name = group_dir_name.upper() if not entry.endswith(".abap"):
continue
for filename in sorted(os.listdir(group_dir)): # ADT 式:<fugr>.fugr.<fm>.abap
if not filename.endswith(".abap"): base = entry[:-5]
continue low = base.lower()
if filename.startswith("."): if FUNC_ADT_SEP not in low:
continue logger.debug("函数模块文件名不符合约定,跳过: %s", entry)
continue
func_name = filename[:-5].upper() idx = low.index(FUNC_ADT_SEP)
obj_name = f"{group_name}/{func_name}" group_name = base[:idx].upper()
rel_path = f"{FUNCTIONS_DIR}/{group_dir_name}/{filename}" func_name = base[idx + len(FUNC_ADT_SEP):].upper()
if not group_name or not func_name:
results.append(ScannedObject( logger.debug("函数模块文件名解析失败,跳过: %s", entry)
name=obj_name, continue
type="function", _add(
file=rel_path, results, seen,
)) f"{group_name}/{func_name}", "function",
logger.debug("扫描到: %s (function) → %s", obj_name, rel_path) _rel(rel_prefix, dir_name, entry),
)