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
+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
@@ -10,6 +26,7 @@ from dataclasses import dataclass
logger = logging.getLogger("sapcli.scanner")
# 对象类型目录名(复数,历史命名)→ 对象类型
DIRECTORY_TYPE_MAP: dict[str, str] = {
"reports": "report",
"classes": "class",
@@ -30,8 +47,38 @@ DIRECTORY_TYPE_MAP: dict[str, str] = {
"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"
# 源码根目录(三层布局的第 1 层父目录)
SRC_DIR = "src"
# ADT 式函数模块文件名分隔符:<fugr>.fugr.<fm>.abap
FUNC_ADT_SEP = ".fugr."
@dataclass
class ScannedObject:
@@ -45,79 +92,146 @@ class ScannedObject:
def scan_project(project_path: str) -> list[ScannedObject]:
"""扫描项目目录,返回识别到的所有对象列表。
遍历已知目录名,每个目录下找 .abap 文件。
functions/ 特殊处理:子目录 = 函数组名
先扫项目根目录下的对象类型目录(旧布局),
再扫 src/<开发包>/ 下的对象类型目录(三层布局)
同名同类型对象只保留第一次扫到的。
"""
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)
if not os.path.isdir(dir_path):
logger.debug("目录不存在,跳过: %s", dir_path)
# 扫描范围:(目录绝对路径, 该目录相对项目根的路径前缀)
scopes: list[tuple[str, str]] = [(project_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
if dir_name == FUNCTIONS_DIR:
_scan_functions(dir_path, results)
else:
_scan_flat_directory(dir_path, dir_name, obj_type, results)
for dir_name in entries:
if dir_name.startswith("."):
continue
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))
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(
dir_path: str,
dir_name: str,
obj_type: str,
rel_prefix: str,
results: list[ScannedObject],
seen: set[tuple[str, str]],
) -> None:
"""扫描普通目录(一对一:文件名 = 对象名)。"""
for filename in sorted(os.listdir(dir_path)):
if not filename.endswith(".abap"):
continue
# 隐藏文件跳过
if filename.startswith("."):
continue
if not os.path.isfile(os.path.join(dir_path, filename)):
continue
obj_name = filename[:-5].upper() # 去掉 .abap,转大写
rel_path = f"{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)
_add(results, seen, obj_name, obj_type, _rel(rel_prefix, dir_name, filename))
def _scan_functions(dir_path: str, results: list[ScannedObject]) -> None:
"""扫描 functions/ 目录(特殊处理:子目录 = 函数组名)。
def _scan_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)):
group_dir = os.path.join(dir_path, group_dir_name)
if not os.path.isdir(group_dir):
for entry in sorted(os.listdir(dir_path)):
if entry.startswith("."):
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
group_name = group_dir_name.upper()
if not entry.endswith(".abap"):
continue
for filename in sorted(os.listdir(group_dir)):
if not filename.endswith(".abap"):
continue
if filename.startswith("."):
continue
func_name = filename[:-5].upper()
obj_name = f"{group_name}/{func_name}"
rel_path = f"{FUNCTIONS_DIR}/{group_dir_name}/{filename}"
results.append(ScannedObject(
name=obj_name,
type="function",
file=rel_path,
))
logger.debug("扫描到: %s (function) → %s", obj_name, rel_path)
# ADT 式:<fugr>.fugr.<fm>.abap
base = entry[:-5]
low = base.lower()
if FUNC_ADT_SEP not in low:
logger.debug("函数模块文件名不符合约定,跳过: %s", entry)
continue
idx = low.index(FUNC_ADT_SEP)
group_name = base[:idx].upper()
func_name = base[idx + len(FUNC_ADT_SEP):].upper()
if not group_name or not func_name:
logger.debug("函数模块文件名解析失败,跳过: %s", entry)
continue
_add(
results, seen,
f"{group_name}/{func_name}", "function",
_rel(rel_prefix, dir_name, entry),
)