kcode-pi 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/cli/kcode.d.ts +1 -0
- package/dist/cli/kcode.js +27 -4
- package/package.json +1 -1
- package/src/cli/kcode.ts +29 -4
- package/src/official/kingdee-skills.ts +60 -13
- package/src/rules/checker.ts +143 -0
- package/vendor/kingdee-skills/kingdee-cosmic-reviewer/SKILL.md +2 -2
- package/vendor/kingdee-skills/ok-cosmic/SKILL.md +52 -101
- package/vendor/kingdee-skills/ok-cosmic/agents/openai.yaml +4 -4
- package/vendor/kingdee-skills/ok-cosmic/manifest.json +21 -20
- package/vendor/kingdee-skills/ok-cosmic/ok-cosmic-intro.html +1 -1
- package/vendor/kingdee-skills/ok-cosmic/rules/a-layer-rules.json +1 -1
- package/vendor/kingdee-skills/ok-cosmic/rules/anti-patterns.md +2 -2
- package/vendor/kingdee-skills/ok-cosmic/rules/coding-preferences.md +4 -4
- package/vendor/kingdee-skills/ok-cosmic/rules/constraints.md +3 -3
- package/vendor/kingdee-skills/ok-cosmic/rules/decision-matrix.md +8 -8
- package/vendor/kingdee-skills/ok-cosmic/rules/intent-routing.md +1 -1
- package/vendor/kingdee-skills/ok-cosmic/rules/post-check.md +19 -18
- package/vendor/kingdee-skills/ok-ksql/SKILL.md +9 -9
- package/vendor/kingdee-skills/ok-ksql/manifest.json +2 -1
- package/vendor/kingdee-skills/ok-ksql/references/ksql-datafix.md +2 -2
- package/vendor/kingdee-skills/kingdee-cosmic-reviewer/scripts/pattern-matcher.py +0 -336
- package/vendor/kingdee-skills/kingdee-cosmic-reviewer/scripts/review-score-calculator.py +0 -121
- package/vendor/kingdee-skills/ok-cosmic/CHANGELOG.md +0 -295
- package/vendor/kingdee-skills/ok-cosmic/README.md +0 -460
- package/vendor/kingdee-skills/ok-cosmic/requirements.txt +0 -2
- package/vendor/kingdee-skills/ok-cosmic/scripts/config_loader.py +0 -204
- package/vendor/kingdee-skills/ok-cosmic/scripts/cosmic-api-knowledge.py +0 -910
- package/vendor/kingdee-skills/ok-cosmic/scripts/cosmic-basedata-query.py +0 -359
- package/vendor/kingdee-skills/ok-cosmic/scripts/cosmic-config-check.py +0 -181
- package/vendor/kingdee-skills/ok-cosmic/scripts/cosmic-extpoints-query.py +0 -389
- package/vendor/kingdee-skills/ok-cosmic/scripts/cosmic-form-metadata.py +0 -856
- package/vendor/kingdee-skills/ok-cosmic/scripts/cosmic-post-check.py +0 -262
- package/vendor/kingdee-skills/ok-cosmic/scripts/cosmic-post-lint.py +0 -293
- package/vendor/kingdee-skills/ok-cosmic/scripts/lint/__init__.py +0 -2
- package/vendor/kingdee-skills/ok-cosmic/scripts/lint/base.py +0 -393
- package/vendor/kingdee-skills/ok-cosmic/scripts/lint/resource_check.py +0 -176
- package/vendor/kingdee-skills/ok-cosmic/scripts/lint/scene_check.py +0 -375
- package/vendor/kingdee-skills/ok-cosmic/scripts/lint/style_check.py +0 -434
- package/vendor/kingdee-skills/ok-cosmic/scripts/lint/verify_check.py +0 -36
- package/vendor/kingdee-skills/ok-cosmic/scripts/route_client.py +0 -186
- package/vendor/kingdee-skills/ok-cosmic/scripts/script_utils.py +0 -40
- package/vendor/kingdee-skills/ok-cosmic/scripts/sqlite_cache.py +0 -142
- package/vendor/kingdee-skills/ok-cosmic/setup/cuslib/kd-cd-cosmic-commons.jar +0 -0
- package/vendor/kingdee-skills/ok-cosmic/setup/cuslib/kd-cd-cosmic-features.jar +0 -0
- package/vendor/kingdee-skills/ok-cosmic/setup/setup-mac.sh +0 -18
- package/vendor/kingdee-skills/ok-cosmic/setup/setup-windows.bat +0 -53
- package/vendor/kingdee-skills/ok-cosmic/setup/setup.jar +0 -0
- package/vendor/kingdee-skills/ok-ksql/scripts/ksql_lint.py +0 -363
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# SPDX-License-Identifier: NOASSERTION
|
|
3
|
-
"""Small CLI helpers shared by ok-cosmic scripts."""
|
|
4
|
-
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
|
|
7
|
-
import argparse
|
|
8
|
-
import sys
|
|
9
|
-
from typing import Callable, Optional
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class FriendlyArgumentParser(argparse.ArgumentParser):
|
|
13
|
-
"""ArgumentParser that reports concise failures instead of dumping usage."""
|
|
14
|
-
|
|
15
|
-
def error(self, message: str) -> None:
|
|
16
|
-
self.exit(1, f"✖️ 参数错误: {message}\n提示: 使用 --help 查看参数。\n")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def run_cli(main_func: Callable[[], Optional[int]]) -> int:
|
|
20
|
-
"""Run a CLI entrypoint and convert uncaught exceptions to failure text."""
|
|
21
|
-
|
|
22
|
-
try:
|
|
23
|
-
result = main_func()
|
|
24
|
-
if isinstance(result, int):
|
|
25
|
-
return result
|
|
26
|
-
return 0
|
|
27
|
-
except SystemExit as e:
|
|
28
|
-
code = e.code
|
|
29
|
-
if code is None:
|
|
30
|
-
return 0
|
|
31
|
-
if isinstance(code, int):
|
|
32
|
-
return code
|
|
33
|
-
print(f"✖️ 执行失败: {code}", file=sys.stderr)
|
|
34
|
-
return 1
|
|
35
|
-
except KeyboardInterrupt:
|
|
36
|
-
print("✖️ 执行已取消", file=sys.stderr)
|
|
37
|
-
return 130
|
|
38
|
-
except Exception as e:
|
|
39
|
-
print(f"✖️ 执行失败: {e}", file=sys.stderr)
|
|
40
|
-
return 1
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# SPDX-License-Identifier: NOASSERTION
|
|
3
|
-
"""Shared SQLite JSON cache helpers for ok-cosmic scripts."""
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import sqlite3
|
|
9
|
-
import sys
|
|
10
|
-
import time
|
|
11
|
-
from typing import Any, Dict, Iterable, Optional, Sequence
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def resolve_graph_db_path(config: Dict[str, Any], missing_message: str) -> str:
|
|
18
|
-
"""Resolve graph.dbPath relative to ok-cosmic.json's directory."""
|
|
19
|
-
graph_config = config.get("graph", {})
|
|
20
|
-
if not isinstance(graph_config, dict):
|
|
21
|
-
graph_config = {}
|
|
22
|
-
|
|
23
|
-
db_path = str(graph_config.get("dbPath", "")).strip()
|
|
24
|
-
if not db_path:
|
|
25
|
-
raise ValueError(missing_message)
|
|
26
|
-
|
|
27
|
-
raw_db_path = os.path.expanduser(db_path)
|
|
28
|
-
if os.path.isabs(raw_db_path):
|
|
29
|
-
return os.path.normpath(raw_db_path)
|
|
30
|
-
|
|
31
|
-
base_dir = str(config.get("__config_dir__", "")).strip() or os.getcwd()
|
|
32
|
-
return os.path.normpath(os.path.abspath(os.path.join(base_dir, raw_db_path)))
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _validate_identifier(value: str, label: str) -> str:
|
|
36
|
-
if not IDENTIFIER_RE.match(value):
|
|
37
|
-
raise ValueError(f"{label} 不是安全的 SQLite 标识符: {value}")
|
|
38
|
-
return value
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class JsonSqliteCache:
|
|
42
|
-
"""Small SQLite cache storing JSON payload + updated_at with TTL."""
|
|
43
|
-
|
|
44
|
-
def __init__(
|
|
45
|
-
self,
|
|
46
|
-
db_path: str,
|
|
47
|
-
*,
|
|
48
|
-
table_name: str,
|
|
49
|
-
key_column: str,
|
|
50
|
-
create_sql: str,
|
|
51
|
-
ttl: int = 600,
|
|
52
|
-
check_same_thread: bool = True,
|
|
53
|
-
init_error_message: str = "初始化缓存表失败",
|
|
54
|
-
write_error_message: str = "写入缓存失败",
|
|
55
|
-
):
|
|
56
|
-
self.db_path = db_path
|
|
57
|
-
self.table_name = _validate_identifier(table_name, "table_name")
|
|
58
|
-
self.key_column = _validate_identifier(key_column, "key_column")
|
|
59
|
-
self.ttl = ttl
|
|
60
|
-
self.init_error_message = init_error_message
|
|
61
|
-
self.write_error_message = write_error_message
|
|
62
|
-
self._conn = sqlite3.connect(self.db_path, check_same_thread=check_same_thread)
|
|
63
|
-
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
64
|
-
self._conn.execute("PRAGMA busy_timeout=5000")
|
|
65
|
-
self._conn.row_factory = sqlite3.Row
|
|
66
|
-
self._init_db(create_sql)
|
|
67
|
-
|
|
68
|
-
def _init_db(self, create_sql: str) -> None:
|
|
69
|
-
try:
|
|
70
|
-
self._conn.execute(create_sql)
|
|
71
|
-
self._conn.commit()
|
|
72
|
-
except Exception as e:
|
|
73
|
-
print(f" (DEBUG) {self.init_error_message}: {e}", file=sys.stderr)
|
|
74
|
-
|
|
75
|
-
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
|
76
|
-
try:
|
|
77
|
-
row = self._conn.execute(
|
|
78
|
-
f"SELECT payload, updated_at FROM {self.table_name} WHERE {self.key_column} = ?",
|
|
79
|
-
(key,),
|
|
80
|
-
).fetchone()
|
|
81
|
-
if not row or (time.time() - row["updated_at"] > self.ttl):
|
|
82
|
-
return None
|
|
83
|
-
return json.loads(row["payload"])
|
|
84
|
-
except Exception:
|
|
85
|
-
return None
|
|
86
|
-
|
|
87
|
-
def set_payload(
|
|
88
|
-
self,
|
|
89
|
-
key: str,
|
|
90
|
-
payload: Dict[str, Any],
|
|
91
|
-
*,
|
|
92
|
-
extra_columns: Optional[Sequence[str]] = None,
|
|
93
|
-
extra_values: Optional[Sequence[Any]] = None,
|
|
94
|
-
) -> None:
|
|
95
|
-
extra_columns = tuple(extra_columns or ())
|
|
96
|
-
extra_values = tuple(extra_values or ())
|
|
97
|
-
if len(extra_columns) != len(extra_values):
|
|
98
|
-
raise ValueError("extra_columns 和 extra_values 数量不一致")
|
|
99
|
-
for column in extra_columns:
|
|
100
|
-
_validate_identifier(column, "extra_column")
|
|
101
|
-
|
|
102
|
-
columns = (self.key_column, *extra_columns, "payload", "updated_at")
|
|
103
|
-
placeholders = ", ".join("?" for _ in columns)
|
|
104
|
-
column_sql = ", ".join(columns)
|
|
105
|
-
values = (
|
|
106
|
-
key,
|
|
107
|
-
*extra_values,
|
|
108
|
-
json.dumps(payload, ensure_ascii=False),
|
|
109
|
-
int(time.time()),
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
try:
|
|
113
|
-
self._conn.execute(
|
|
114
|
-
f"""
|
|
115
|
-
INSERT OR REPLACE INTO {self.table_name}
|
|
116
|
-
({column_sql})
|
|
117
|
-
VALUES ({placeholders})
|
|
118
|
-
""",
|
|
119
|
-
values,
|
|
120
|
-
)
|
|
121
|
-
self._conn.commit()
|
|
122
|
-
except Exception as e:
|
|
123
|
-
print(f" (DEBUG) {self.write_error_message}: {e}", file=sys.stderr)
|
|
124
|
-
|
|
125
|
-
def remove(self, key: str) -> None:
|
|
126
|
-
try:
|
|
127
|
-
self._conn.execute(
|
|
128
|
-
f"DELETE FROM {self.table_name} WHERE {self.key_column} = ?",
|
|
129
|
-
(key,),
|
|
130
|
-
)
|
|
131
|
-
self._conn.commit()
|
|
132
|
-
except Exception:
|
|
133
|
-
pass
|
|
134
|
-
|
|
135
|
-
def close(self) -> None:
|
|
136
|
-
try:
|
|
137
|
-
self._conn.close()
|
|
138
|
-
except Exception:
|
|
139
|
-
pass
|
|
140
|
-
|
|
141
|
-
def __del__(self):
|
|
142
|
-
self.close()
|
|
Binary file
|
|
Binary file
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# ok-cosmic-knowledge 离线 API 知识图谱构建工具
|
|
3
|
-
# 用法: ./setup-mac.sh [参数]
|
|
4
|
-
|
|
5
|
-
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
-
JAR_FILE="$SCRIPT_DIR/setup.jar"
|
|
7
|
-
|
|
8
|
-
if ! command -v java &> /dev/null; then
|
|
9
|
-
echo "错误: 未找到 java 命令,请安装 JDK 8+"
|
|
10
|
-
exit 1
|
|
11
|
-
fi
|
|
12
|
-
|
|
13
|
-
if [ ! -f "$JAR_FILE" ]; then
|
|
14
|
-
echo "请确认发布包完整,或重新获取包含 setup.jar 的安装包"
|
|
15
|
-
exit 1
|
|
16
|
-
fi
|
|
17
|
-
|
|
18
|
-
java -jar "$JAR_FILE" "$@"
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
@echo off
|
|
2
|
-
setlocal
|
|
3
|
-
REM ok-cosmic-knowledge 离线 API 知识图谱构建工具
|
|
4
|
-
REM 用法: setup-windows.bat [参数]
|
|
5
|
-
|
|
6
|
-
chcp 65001 >nul 2>nul
|
|
7
|
-
|
|
8
|
-
set "SCRIPT_DIR=%~dp0"
|
|
9
|
-
set "JAR_FILE=%SCRIPT_DIR%setup.jar"
|
|
10
|
-
set "EXIT_CODE=0"
|
|
11
|
-
set "PAUSE_ON_EXIT="
|
|
12
|
-
|
|
13
|
-
REM Explorer 双击通常通过 cmd.exe /c 启动,执行结束后窗口会立即关闭。
|
|
14
|
-
echo %CMDCMDLINE% | findstr /I /C:" /c " >nul && set "PAUSE_ON_EXIT=1"
|
|
15
|
-
|
|
16
|
-
if not exist "%JAR_FILE%" (
|
|
17
|
-
echo 错误: setup.jar 文件不存在: "%JAR_FILE%"
|
|
18
|
-
echo 请确认发布包完整,或重新获取包含 setup.jar 的安装包
|
|
19
|
-
set "EXIT_CODE=1"
|
|
20
|
-
goto end
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
where java >nul 2>nul
|
|
24
|
-
if errorlevel 1 (
|
|
25
|
-
echo 错误: 未找到 java 命令,请先安装 JRE/JDK 并配置 PATH。
|
|
26
|
-
set "EXIT_CODE=1"
|
|
27
|
-
goto end
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
pushd "%SCRIPT_DIR%" >nul 2>nul
|
|
31
|
-
if errorlevel 1 (
|
|
32
|
-
echo 错误: 无法进入脚本目录: "%SCRIPT_DIR%"
|
|
33
|
-
set "EXIT_CODE=1"
|
|
34
|
-
goto end
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
java -jar "%JAR_FILE%" %*
|
|
38
|
-
set "EXIT_CODE=%ERRORLEVEL%"
|
|
39
|
-
popd >nul
|
|
40
|
-
|
|
41
|
-
:end
|
|
42
|
-
if defined PAUSE_ON_EXIT (
|
|
43
|
-
echo.
|
|
44
|
-
if "%EXIT_CODE%"=="0" (
|
|
45
|
-
echo 执行完成,按任意键关闭窗口...
|
|
46
|
-
) else (
|
|
47
|
-
echo 执行失败,退出码: %EXIT_CODE%
|
|
48
|
-
echo 按任意键关闭窗口...
|
|
49
|
-
)
|
|
50
|
-
pause >nul
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
exit /b %EXIT_CODE%
|
|
Binary file
|
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Static checks for ok-ksql generated PostgreSQL data-fix SQL files.
|
|
3
|
-
|
|
4
|
-
The linter is intentionally dependency-free and conservative: it catches the
|
|
5
|
-
high-risk patterns required by the ok-ksql skill, but it is not a full SQL
|
|
6
|
-
parser. Fix reported ERROR items before delivery. WARN items document style
|
|
7
|
-
preferences that may be acceptable only with an explicit reason.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import argparse
|
|
13
|
-
import re
|
|
14
|
-
import sys
|
|
15
|
-
from dataclasses import dataclass
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
from typing import Iterable, Iterator, Sequence
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
TIMESTAMP_RE = re.compile(r"\b\d{12}\b")
|
|
21
|
-
BACKUP_TABLE_RE = re.compile(r"\bbak_[a-zA-Z0-9_]+_(\d{12})\b", re.IGNORECASE)
|
|
22
|
-
FILE_TS_RE = re.compile(r"ksql_[^/\\]*_(\d{12})\.txt$", re.IGNORECASE)
|
|
23
|
-
HEADER_TS_RE = re.compile(r"备份表时间戳[::]\s*(\d{12})")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@dataclass(frozen=True)
|
|
27
|
-
class Finding:
|
|
28
|
-
severity: str
|
|
29
|
-
line: int
|
|
30
|
-
message: str
|
|
31
|
-
|
|
32
|
-
def format(self, path: Path) -> str:
|
|
33
|
-
return f"{path}:{self.line}: {self.severity}: {self.message}"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@dataclass(frozen=True)
|
|
37
|
-
class Statement:
|
|
38
|
-
text: str
|
|
39
|
-
line: int
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def strip_comments_and_literals(sql: str) -> str:
|
|
43
|
-
"""Return SQL with comments and quoted literals replaced by spaces.
|
|
44
|
-
|
|
45
|
-
This keeps line numbers stable while avoiding false positives from words
|
|
46
|
-
appearing inside comments or strings.
|
|
47
|
-
"""
|
|
48
|
-
|
|
49
|
-
out: list[str] = []
|
|
50
|
-
i = 0
|
|
51
|
-
n = len(sql)
|
|
52
|
-
state = "normal"
|
|
53
|
-
dollar_tag = ""
|
|
54
|
-
|
|
55
|
-
while i < n:
|
|
56
|
-
ch = sql[i]
|
|
57
|
-
nxt = sql[i + 1] if i + 1 < n else ""
|
|
58
|
-
|
|
59
|
-
if state == "normal":
|
|
60
|
-
if ch == "-" and nxt == "-":
|
|
61
|
-
out.extend([" ", " "])
|
|
62
|
-
i += 2
|
|
63
|
-
state = "line_comment"
|
|
64
|
-
continue
|
|
65
|
-
if ch == "/" and nxt == "*":
|
|
66
|
-
out.extend([" ", " "])
|
|
67
|
-
i += 2
|
|
68
|
-
state = "block_comment"
|
|
69
|
-
continue
|
|
70
|
-
if ch == "'":
|
|
71
|
-
out.append(" ")
|
|
72
|
-
i += 1
|
|
73
|
-
state = "single_quote"
|
|
74
|
-
continue
|
|
75
|
-
if ch == '"':
|
|
76
|
-
out.append(" ")
|
|
77
|
-
i += 1
|
|
78
|
-
state = "double_quote"
|
|
79
|
-
continue
|
|
80
|
-
if ch == "$":
|
|
81
|
-
match = re.match(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$", sql[i:])
|
|
82
|
-
if match:
|
|
83
|
-
dollar_tag = match.group(0)
|
|
84
|
-
out.extend(" " * len(dollar_tag))
|
|
85
|
-
i += len(dollar_tag)
|
|
86
|
-
state = "dollar_quote"
|
|
87
|
-
continue
|
|
88
|
-
out.append(ch)
|
|
89
|
-
i += 1
|
|
90
|
-
continue
|
|
91
|
-
|
|
92
|
-
if state == "line_comment":
|
|
93
|
-
out.append("\n" if ch == "\n" else " ")
|
|
94
|
-
i += 1
|
|
95
|
-
if ch == "\n":
|
|
96
|
-
state = "normal"
|
|
97
|
-
continue
|
|
98
|
-
|
|
99
|
-
if state == "block_comment":
|
|
100
|
-
if ch == "*" and nxt == "/":
|
|
101
|
-
out.extend([" ", " "])
|
|
102
|
-
i += 2
|
|
103
|
-
state = "normal"
|
|
104
|
-
else:
|
|
105
|
-
out.append("\n" if ch == "\n" else " ")
|
|
106
|
-
i += 1
|
|
107
|
-
continue
|
|
108
|
-
|
|
109
|
-
if state == "single_quote":
|
|
110
|
-
if ch == "'" and nxt == "'":
|
|
111
|
-
out.extend([" ", " "])
|
|
112
|
-
i += 2
|
|
113
|
-
else:
|
|
114
|
-
out.append("\n" if ch == "\n" else " ")
|
|
115
|
-
i += 1
|
|
116
|
-
if ch == "'":
|
|
117
|
-
state = "normal"
|
|
118
|
-
continue
|
|
119
|
-
|
|
120
|
-
if state == "double_quote":
|
|
121
|
-
if ch == '"' and nxt == '"':
|
|
122
|
-
out.extend([" ", " "])
|
|
123
|
-
i += 2
|
|
124
|
-
else:
|
|
125
|
-
out.append("\n" if ch == "\n" else " ")
|
|
126
|
-
i += 1
|
|
127
|
-
if ch == '"':
|
|
128
|
-
state = "normal"
|
|
129
|
-
continue
|
|
130
|
-
|
|
131
|
-
if state == "dollar_quote":
|
|
132
|
-
if sql.startswith(dollar_tag, i):
|
|
133
|
-
out.extend(" " * len(dollar_tag))
|
|
134
|
-
i += len(dollar_tag)
|
|
135
|
-
state = "normal"
|
|
136
|
-
dollar_tag = ""
|
|
137
|
-
else:
|
|
138
|
-
out.append("\n" if ch == "\n" else " ")
|
|
139
|
-
i += 1
|
|
140
|
-
continue
|
|
141
|
-
|
|
142
|
-
return "".join(out)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def iter_statements(masked_sql: str) -> Iterator[Statement]:
|
|
146
|
-
start = 0
|
|
147
|
-
start_line = 1
|
|
148
|
-
line = 1
|
|
149
|
-
|
|
150
|
-
for idx, ch in enumerate(masked_sql):
|
|
151
|
-
if ch == ";":
|
|
152
|
-
text = masked_sql[start : idx + 1]
|
|
153
|
-
if text.strip():
|
|
154
|
-
yield Statement(text=text, line=start_line)
|
|
155
|
-
start = idx + 1
|
|
156
|
-
start_line = line
|
|
157
|
-
if ch == "\n":
|
|
158
|
-
line += 1
|
|
159
|
-
if not masked_sql[start:idx].strip():
|
|
160
|
-
start_line = line
|
|
161
|
-
|
|
162
|
-
tail = masked_sql[start:]
|
|
163
|
-
if tail.strip():
|
|
164
|
-
yield Statement(text=tail, line=start_line)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def line_of_offset(text: str, base_line: int, offset: int) -> int:
|
|
168
|
-
return base_line + text[:offset].count("\n")
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def has_token(statement: str, token: str) -> bool:
|
|
172
|
-
return re.search(rf"\b{re.escape(token)}\b", statement, re.IGNORECASE) is not None
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def lint_statement(stmt: Statement) -> list[Finding]:
|
|
176
|
-
findings: list[Finding] = []
|
|
177
|
-
compact = " ".join(stmt.text.split())
|
|
178
|
-
if not compact:
|
|
179
|
-
return findings
|
|
180
|
-
|
|
181
|
-
first = re.match(r"^\s*(\w+)", compact)
|
|
182
|
-
first_word = first.group(1).upper() if first else ""
|
|
183
|
-
|
|
184
|
-
if first_word in {"UPDATE", "DELETE"} and not has_token(stmt.text, "WHERE"):
|
|
185
|
-
findings.append(
|
|
186
|
-
Finding(
|
|
187
|
-
"ERROR",
|
|
188
|
-
stmt.line,
|
|
189
|
-
f"{first_word} 语句缺少 WHERE,禁止生成无范围更新/删除。",
|
|
190
|
-
)
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
select_star_matches = list(re.finditer(r"\bSELECT\s+\*", stmt.text, re.IGNORECASE))
|
|
194
|
-
if select_star_matches:
|
|
195
|
-
is_backup = re.search(
|
|
196
|
-
r"\bSELECT\s+\*\s+INTO\s+bak_[a-zA-Z0-9_]+_\d{12}\s+FROM\b",
|
|
197
|
-
stmt.text,
|
|
198
|
-
re.IGNORECASE,
|
|
199
|
-
)
|
|
200
|
-
if is_backup:
|
|
201
|
-
if has_token(stmt.text, "WHERE"):
|
|
202
|
-
findings.append(
|
|
203
|
-
Finding(
|
|
204
|
-
"ERROR",
|
|
205
|
-
stmt.line,
|
|
206
|
-
"备份语句必须整表备份,SELECT * INTO bak_... 不允许带 WHERE。",
|
|
207
|
-
)
|
|
208
|
-
)
|
|
209
|
-
else:
|
|
210
|
-
for match in select_star_matches:
|
|
211
|
-
findings.append(
|
|
212
|
-
Finding(
|
|
213
|
-
"ERROR",
|
|
214
|
-
line_of_offset(stmt.text, stmt.line, match.start()),
|
|
215
|
-
"查询/验证语句禁止 SELECT *;只有整表备份 SELECT * INTO bak_... 例外。",
|
|
216
|
-
)
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
if re.search(r"\bSELECT\s+\*\s+INTO\b", stmt.text, re.IGNORECASE) and not re.search(
|
|
220
|
-
r"\bSELECT\s+\*\s+INTO\s+bak_[a-zA-Z0-9_]+_\d{12}\s+FROM\b",
|
|
221
|
-
stmt.text,
|
|
222
|
-
re.IGNORECASE,
|
|
223
|
-
):
|
|
224
|
-
findings.append(
|
|
225
|
-
Finding(
|
|
226
|
-
"ERROR",
|
|
227
|
-
stmt.line,
|
|
228
|
-
"备份表名必须形如 bak_<原表或业务缩写>_<yyyyMMddHHmm>。",
|
|
229
|
-
)
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
for match in re.finditer(r"\bEXISTS\b", stmt.text, re.IGNORECASE):
|
|
233
|
-
findings.append(
|
|
234
|
-
Finding(
|
|
235
|
-
"WARN",
|
|
236
|
-
line_of_offset(stmt.text, stmt.line, match.start()),
|
|
237
|
-
"SQL 可读性偏好:成员关系/半连接默认使用 IN,只有 IN 改变语义时才保留 EXISTS 并说明原因。",
|
|
238
|
-
)
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
if re.search(r"\bUPDATE\b.+\bJOIN\b", compact, re.IGNORECASE):
|
|
242
|
-
findings.append(
|
|
243
|
-
Finding(
|
|
244
|
-
"WARN",
|
|
245
|
-
stmt.line,
|
|
246
|
-
"PostgreSQL 多表更新优先使用 UPDATE ... FROM ... WHERE ...,不要使用 MySQL 风格 UPDATE ... JOIN。",
|
|
247
|
-
)
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
if re.search(r"=\s*NULL\b|\bNULL\s*=", stmt.text, re.IGNORECASE):
|
|
251
|
-
findings.append(
|
|
252
|
-
Finding(
|
|
253
|
-
"ERROR",
|
|
254
|
-
stmt.line,
|
|
255
|
-
"NULL 判断必须使用 IS NULL / IS NOT NULL,不能使用 = NULL。",
|
|
256
|
-
)
|
|
257
|
-
)
|
|
258
|
-
|
|
259
|
-
if re.search(r"<>|!=", stmt.text) and has_token(stmt.text, "NULL"):
|
|
260
|
-
findings.append(
|
|
261
|
-
Finding(
|
|
262
|
-
"WARN",
|
|
263
|
-
stmt.line,
|
|
264
|
-
"涉及 NULL 的不等比较需确认语义;PostgreSQL 可优先使用 IS DISTINCT FROM。",
|
|
265
|
-
)
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
return findings
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def lint_timestamps(path: Path, raw_sql: str) -> list[Finding]:
|
|
272
|
-
findings: list[Finding] = []
|
|
273
|
-
backup_timestamps = set(BACKUP_TABLE_RE.findall(raw_sql))
|
|
274
|
-
|
|
275
|
-
if len(backup_timestamps) > 1:
|
|
276
|
-
findings.append(
|
|
277
|
-
Finding(
|
|
278
|
-
"ERROR",
|
|
279
|
-
1,
|
|
280
|
-
"同一 SQL 文件中出现多个备份表时间戳;桌面文件、备份表和文件头时间戳必须一致。",
|
|
281
|
-
)
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
file_match = FILE_TS_RE.search(path.name)
|
|
285
|
-
if file_match and backup_timestamps and file_match.group(1) not in backup_timestamps:
|
|
286
|
-
findings.append(
|
|
287
|
-
Finding(
|
|
288
|
-
"ERROR",
|
|
289
|
-
1,
|
|
290
|
-
f"文件名时间戳 {file_match.group(1)} 与备份表时间戳 {', '.join(sorted(backup_timestamps))} 不一致。",
|
|
291
|
-
)
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
header_matches = list(HEADER_TS_RE.finditer(raw_sql))
|
|
295
|
-
header_timestamps = {m.group(1) for m in header_matches}
|
|
296
|
-
if len(header_timestamps) > 1:
|
|
297
|
-
findings.append(Finding("ERROR", 1, "文件头出现多个不同的备份表时间戳。"))
|
|
298
|
-
if header_timestamps and backup_timestamps and header_timestamps != backup_timestamps:
|
|
299
|
-
findings.append(
|
|
300
|
-
Finding(
|
|
301
|
-
"ERROR",
|
|
302
|
-
1,
|
|
303
|
-
f"文件头时间戳 {', '.join(sorted(header_timestamps))} 与备份表时间戳 {', '.join(sorted(backup_timestamps))} 不一致。",
|
|
304
|
-
)
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
return findings
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def lint_file(path: Path) -> list[Finding]:
|
|
311
|
-
raw_sql = path.read_text(encoding="utf-8")
|
|
312
|
-
masked_sql = strip_comments_and_literals(raw_sql)
|
|
313
|
-
|
|
314
|
-
findings: list[Finding] = []
|
|
315
|
-
findings.extend(lint_timestamps(path, raw_sql))
|
|
316
|
-
for stmt in iter_statements(masked_sql):
|
|
317
|
-
findings.extend(lint_statement(stmt))
|
|
318
|
-
return sorted(findings, key=lambda f: (f.line, f.severity, f.message))
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
|
|
322
|
-
parser = argparse.ArgumentParser(
|
|
323
|
-
description="Lint ok-ksql generated PostgreSQL data-fix SQL files."
|
|
324
|
-
)
|
|
325
|
-
parser.add_argument("paths", nargs="+", type=Path, help="SQL/.txt files to lint")
|
|
326
|
-
parser.add_argument(
|
|
327
|
-
"--strict",
|
|
328
|
-
action="store_true",
|
|
329
|
-
help="treat WARN findings as failures",
|
|
330
|
-
)
|
|
331
|
-
return parser.parse_args(argv)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def main(argv: Sequence[str] | None = None) -> int:
|
|
335
|
-
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
336
|
-
all_findings: list[tuple[Path, Finding]] = []
|
|
337
|
-
|
|
338
|
-
for path in args.paths:
|
|
339
|
-
if not path.exists():
|
|
340
|
-
print(f"{path}:1: ERROR: 文件不存在。", file=sys.stderr)
|
|
341
|
-
return 2
|
|
342
|
-
if path.is_dir():
|
|
343
|
-
print(f"{path}:1: ERROR: 请输入 SQL/.txt 文件,不能是目录。", file=sys.stderr)
|
|
344
|
-
return 2
|
|
345
|
-
for finding in lint_file(path):
|
|
346
|
-
all_findings.append((path, finding))
|
|
347
|
-
|
|
348
|
-
for path, finding in all_findings:
|
|
349
|
-
print(finding.format(path))
|
|
350
|
-
|
|
351
|
-
error_count = sum(1 for _, f in all_findings if f.severity == "ERROR")
|
|
352
|
-
warn_count = sum(1 for _, f in all_findings if f.severity == "WARN")
|
|
353
|
-
print(f"SUMMARY: {error_count} error(s), {warn_count} warning(s)")
|
|
354
|
-
|
|
355
|
-
if error_count:
|
|
356
|
-
return 1
|
|
357
|
-
if args.strict and warn_count:
|
|
358
|
-
return 1
|
|
359
|
-
return 0
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if __name__ == "__main__":
|
|
363
|
-
raise SystemExit(main())
|