activo 0.2.2 → 0.3.0
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 +79 -3
- package/dist/core/llm/ollama.d.ts +2 -0
- package/dist/core/llm/ollama.d.ts.map +1 -1
- package/dist/core/llm/ollama.js +26 -0
- package/dist/core/llm/ollama.js.map +1 -1
- package/dist/core/tools/ast.d.ts +81 -0
- package/dist/core/tools/ast.d.ts.map +1 -0
- package/dist/core/tools/ast.js +700 -0
- package/dist/core/tools/ast.js.map +1 -0
- package/dist/core/tools/cache.d.ts +19 -0
- package/dist/core/tools/cache.d.ts.map +1 -0
- package/dist/core/tools/cache.js +497 -0
- package/dist/core/tools/cache.js.map +1 -0
- package/dist/core/tools/cssAnalysis.d.ts +3 -0
- package/dist/core/tools/cssAnalysis.d.ts.map +1 -0
- package/dist/core/tools/cssAnalysis.js +270 -0
- package/dist/core/tools/cssAnalysis.js.map +1 -0
- package/dist/core/tools/embeddings.d.ts +8 -0
- package/dist/core/tools/embeddings.d.ts.map +1 -0
- package/dist/core/tools/embeddings.js +631 -0
- package/dist/core/tools/embeddings.js.map +1 -0
- package/dist/core/tools/frontendAst.d.ts +6 -0
- package/dist/core/tools/frontendAst.d.ts.map +1 -0
- package/dist/core/tools/frontendAst.js +680 -0
- package/dist/core/tools/frontendAst.js.map +1 -0
- package/dist/core/tools/htmlAnalysis.d.ts +3 -0
- package/dist/core/tools/htmlAnalysis.d.ts.map +1 -0
- package/dist/core/tools/htmlAnalysis.js +398 -0
- package/dist/core/tools/htmlAnalysis.js.map +1 -0
- package/dist/core/tools/index.d.ts +10 -0
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +21 -1
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/javaAst.d.ts +6 -0
- package/dist/core/tools/javaAst.d.ts.map +1 -0
- package/dist/core/tools/javaAst.js +678 -0
- package/dist/core/tools/javaAst.js.map +1 -0
- package/dist/core/tools/memory.d.ts +11 -0
- package/dist/core/tools/memory.d.ts.map +1 -0
- package/dist/core/tools/memory.js +551 -0
- package/dist/core/tools/memory.js.map +1 -0
- package/dist/core/tools/mybatisAnalysis.d.ts +3 -0
- package/dist/core/tools/mybatisAnalysis.d.ts.map +1 -0
- package/dist/core/tools/mybatisAnalysis.js +251 -0
- package/dist/core/tools/mybatisAnalysis.js.map +1 -0
- package/dist/core/tools/sqlAnalysis.d.ts +3 -0
- package/dist/core/tools/sqlAnalysis.d.ts.map +1 -0
- package/dist/core/tools/sqlAnalysis.js +250 -0
- package/dist/core/tools/sqlAnalysis.js.map +1 -0
- package/package.json +2 -1
- package/src/core/llm/ollama.ts +30 -0
- package/src/core/tools/ast.ts +826 -0
- package/src/core/tools/cache.ts +570 -0
- package/src/core/tools/cssAnalysis.ts +324 -0
- package/src/core/tools/embeddings.ts +746 -0
- package/src/core/tools/frontendAst.ts +802 -0
- package/src/core/tools/htmlAnalysis.ts +466 -0
- package/src/core/tools/index.ts +21 -1
- package/src/core/tools/javaAst.ts +812 -0
- package/src/core/tools/memory.ts +655 -0
- package/src/core/tools/mybatisAnalysis.ts +322 -0
- package/src/core/tools/sqlAnalysis.ts +298 -0
- package/FINAL_SIMPLIFIED_SPEC.md +0 -456
- package/TODO.md +0 -193
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { Tool, ToolResult } from "./types.js";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
interface MybatisStatement {
|
|
6
|
+
id: string;
|
|
7
|
+
type: "select" | "insert" | "update" | "delete" | "sql";
|
|
8
|
+
parameterType?: string;
|
|
9
|
+
resultType?: string;
|
|
10
|
+
resultMap?: string;
|
|
11
|
+
sql: string;
|
|
12
|
+
line: number;
|
|
13
|
+
issues: string[];
|
|
14
|
+
dynamicElements: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MybatisMapper {
|
|
18
|
+
file: string;
|
|
19
|
+
namespace: string;
|
|
20
|
+
statements: MybatisStatement[];
|
|
21
|
+
resultMaps: string[];
|
|
22
|
+
sqlFragments: string[];
|
|
23
|
+
issues: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MybatisAnalysisResult {
|
|
27
|
+
file: string;
|
|
28
|
+
isMyBatis: boolean;
|
|
29
|
+
mapper?: MybatisMapper;
|
|
30
|
+
summary: {
|
|
31
|
+
statements: number;
|
|
32
|
+
withIssues: number;
|
|
33
|
+
injectionRisks: number;
|
|
34
|
+
dynamicSqlCount: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MyBatis XML 파싱 및 분석
|
|
39
|
+
function analyzeMyBatisXml(filePath: string): MybatisAnalysisResult {
|
|
40
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
41
|
+
const lines = content.split("\n");
|
|
42
|
+
|
|
43
|
+
// MyBatis XML 여부 확인
|
|
44
|
+
const isMyBatis =
|
|
45
|
+
content.includes("mybatis") ||
|
|
46
|
+
content.includes("ibatis") ||
|
|
47
|
+
content.includes('PUBLIC "-//mybatis.org') ||
|
|
48
|
+
content.includes('PUBLIC "-//ibatis.org') ||
|
|
49
|
+
/<mapper\s+namespace=/i.test(content);
|
|
50
|
+
|
|
51
|
+
if (!isMyBatis) {
|
|
52
|
+
return {
|
|
53
|
+
file: filePath,
|
|
54
|
+
isMyBatis: false,
|
|
55
|
+
summary: { statements: 0, withIssues: 0, injectionRisks: 0, dynamicSqlCount: 0 },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const statements: MybatisStatement[] = [];
|
|
60
|
+
const resultMaps: string[] = [];
|
|
61
|
+
const sqlFragments: string[] = [];
|
|
62
|
+
const mapperIssues: string[] = [];
|
|
63
|
+
|
|
64
|
+
// namespace 추출
|
|
65
|
+
const namespaceMatch = content.match(/<mapper\s+namespace=["']([^"']+)["']/);
|
|
66
|
+
const namespace = namespaceMatch ? namespaceMatch[1] : "unknown";
|
|
67
|
+
|
|
68
|
+
// resultMap 추출
|
|
69
|
+
const resultMapRegex = /<resultMap\s+[^>]*id=["']([^"']+)["'][^>]*>/g;
|
|
70
|
+
let match;
|
|
71
|
+
while ((match = resultMapRegex.exec(content)) !== null) {
|
|
72
|
+
resultMaps.push(match[1]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// sql fragment 추출
|
|
76
|
+
const sqlFragmentRegex = /<sql\s+[^>]*id=["']([^"']+)["'][^>]*>/g;
|
|
77
|
+
while ((match = sqlFragmentRegex.exec(content)) !== null) {
|
|
78
|
+
sqlFragments.push(match[1]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// statement 추출 함수
|
|
82
|
+
const extractStatements = (type: "select" | "insert" | "update" | "delete") => {
|
|
83
|
+
const regex = new RegExp(
|
|
84
|
+
`<${type}\\s+([^>]*)>([\\s\\S]*?)</${type}>`,
|
|
85
|
+
"gi"
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
while ((match = regex.exec(content)) !== null) {
|
|
89
|
+
const attrs = match[1];
|
|
90
|
+
const sqlContent = match[2];
|
|
91
|
+
|
|
92
|
+
// 속성 파싱
|
|
93
|
+
const idMatch = attrs.match(/id=["']([^"']+)["']/);
|
|
94
|
+
const paramMatch = attrs.match(/parameterType=["']([^"']+)["']/);
|
|
95
|
+
const resultTypeMatch = attrs.match(/resultType=["']([^"']+)["']/);
|
|
96
|
+
const resultMapMatch = attrs.match(/resultMap=["']([^"']+)["']/);
|
|
97
|
+
|
|
98
|
+
const id = idMatch ? idMatch[1] : "unknown";
|
|
99
|
+
const issues: string[] = [];
|
|
100
|
+
const dynamicElements: string[] = [];
|
|
101
|
+
|
|
102
|
+
// ${} 사용 검사 (SQL Injection 위험)
|
|
103
|
+
const dollarBraceMatches = sqlContent.match(/\$\{[^}]+\}/g) || [];
|
|
104
|
+
if (dollarBraceMatches.length > 0) {
|
|
105
|
+
issues.push(`SQL Injection 위험: ${dollarBraceMatches.join(", ")} 사용`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 동적 SQL 요소 추출
|
|
109
|
+
const dynamicTags = ["if", "choose", "when", "otherwise", "where", "set", "foreach", "trim", "bind"];
|
|
110
|
+
dynamicTags.forEach((tag) => {
|
|
111
|
+
const tagRegex = new RegExp(`<${tag}[\\s>]`, "gi");
|
|
112
|
+
const count = (sqlContent.match(tagRegex) || []).length;
|
|
113
|
+
if (count > 0) {
|
|
114
|
+
dynamicElements.push(`${tag}(${count})`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// SELECT * 검사
|
|
119
|
+
if (/SELECT\s+\*\s+FROM/i.test(sqlContent)) {
|
|
120
|
+
issues.push("SELECT * 사용");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// WHERE 없는 UPDATE/DELETE
|
|
124
|
+
if ((type === "update" || type === "delete") && !/<where>|WHERE/i.test(sqlContent)) {
|
|
125
|
+
issues.push("WHERE 절 없음 - 전체 테이블 영향 위험");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 복잡한 동적 SQL
|
|
129
|
+
if (dynamicElements.length >= 5) {
|
|
130
|
+
issues.push(`복잡한 동적 SQL (${dynamicElements.length}개 요소)`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 중첩 foreach
|
|
134
|
+
const foreachCount = (sqlContent.match(/<foreach/gi) || []).length;
|
|
135
|
+
if (foreachCount >= 2) {
|
|
136
|
+
issues.push(`중첩 foreach ${foreachCount}개 - 성능 검토 필요`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// LIKE '%${...}%' 패턴
|
|
140
|
+
if (/LIKE\s+['"]?%?\$\{/i.test(sqlContent)) {
|
|
141
|
+
issues.push("LIKE + ${} 패턴 - SQL Injection 및 인덱스 문제");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ORDER BY ${} 패턴
|
|
145
|
+
if (/ORDER\s+BY\s+\$\{/i.test(sqlContent)) {
|
|
146
|
+
issues.push("ORDER BY ${} - SQL Injection 위험");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 라인 번호 찾기
|
|
150
|
+
const statementIndex = content.indexOf(match[0]);
|
|
151
|
+
let lineNum = 1;
|
|
152
|
+
for (let i = 0; i < statementIndex; i++) {
|
|
153
|
+
if (content[i] === "\n") lineNum++;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// SQL 정리
|
|
157
|
+
const cleanSql = sqlContent
|
|
158
|
+
.replace(/<[^>]+>/g, " ") // XML 태그 제거
|
|
159
|
+
.replace(/\s+/g, " ")
|
|
160
|
+
.trim();
|
|
161
|
+
|
|
162
|
+
statements.push({
|
|
163
|
+
id,
|
|
164
|
+
type,
|
|
165
|
+
parameterType: paramMatch ? paramMatch[1] : undefined,
|
|
166
|
+
resultType: resultTypeMatch ? resultTypeMatch[1] : undefined,
|
|
167
|
+
resultMap: resultMapMatch ? resultMapMatch[1] : undefined,
|
|
168
|
+
sql: cleanSql,
|
|
169
|
+
line: lineNum,
|
|
170
|
+
issues,
|
|
171
|
+
dynamicElements,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// 각 statement 타입 처리
|
|
177
|
+
extractStatements("select");
|
|
178
|
+
extractStatements("insert");
|
|
179
|
+
extractStatements("update");
|
|
180
|
+
extractStatements("delete");
|
|
181
|
+
|
|
182
|
+
// mapper 레벨 이슈 검사
|
|
183
|
+
if (!namespaceMatch) {
|
|
184
|
+
mapperIssues.push("namespace 미정의");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 미사용 resultMap 검사 (간단한 체크)
|
|
188
|
+
resultMaps.forEach((rm) => {
|
|
189
|
+
const usageCount = (content.match(new RegExp(`resultMap=["']${rm}["']`, "g")) || []).length;
|
|
190
|
+
if (usageCount <= 1) {
|
|
191
|
+
// 정의만 있고 사용 없음
|
|
192
|
+
mapperIssues.push(`미사용 가능성: resultMap '${rm}'`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// 통계
|
|
197
|
+
const injectionRisks = statements.filter((s) =>
|
|
198
|
+
s.issues.some((i) => i.includes("Injection"))
|
|
199
|
+
).length;
|
|
200
|
+
const dynamicSqlCount = statements.filter((s) => s.dynamicElements.length > 0).length;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
file: filePath,
|
|
204
|
+
isMyBatis: true,
|
|
205
|
+
mapper: {
|
|
206
|
+
file: filePath,
|
|
207
|
+
namespace,
|
|
208
|
+
statements,
|
|
209
|
+
resultMaps,
|
|
210
|
+
sqlFragments,
|
|
211
|
+
issues: mapperIssues,
|
|
212
|
+
},
|
|
213
|
+
summary: {
|
|
214
|
+
statements: statements.length,
|
|
215
|
+
withIssues: statements.filter((s) => s.issues.length > 0).length,
|
|
216
|
+
injectionRisks,
|
|
217
|
+
dynamicSqlCount,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 도구 정의
|
|
223
|
+
export const mybatisTools: Tool[] = [
|
|
224
|
+
{
|
|
225
|
+
name: "mybatis_check",
|
|
226
|
+
description:
|
|
227
|
+
"MyBatis XML 매퍼 파일을 분석합니다. ${} SQL Injection 위험, 동적 SQL 복잡도, SELECT *, resultMap 사용 등을 검사합니다. DTD/namespace로 MyBatis 파일을 자동 감지합니다.",
|
|
228
|
+
parameters: {
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
path: {
|
|
232
|
+
type: "string",
|
|
233
|
+
description: "분석할 XML 파일 또는 디렉토리 경로",
|
|
234
|
+
},
|
|
235
|
+
recursive: {
|
|
236
|
+
type: "boolean",
|
|
237
|
+
description: "디렉토리인 경우 하위 폴더 포함 여부 (기본: true)",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
required: ["path"],
|
|
241
|
+
},
|
|
242
|
+
handler: async (args: Record<string, unknown>): Promise<ToolResult> => {
|
|
243
|
+
const targetPath = args.path as string;
|
|
244
|
+
const recursive = args.recursive !== false;
|
|
245
|
+
|
|
246
|
+
if (!fs.existsSync(targetPath)) {
|
|
247
|
+
return {
|
|
248
|
+
success: false,
|
|
249
|
+
content: "",
|
|
250
|
+
error: `경로를 찾을 수 없습니다: ${targetPath}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const results: MybatisAnalysisResult[] = [];
|
|
255
|
+
const stats = fs.statSync(targetPath);
|
|
256
|
+
|
|
257
|
+
if (stats.isFile()) {
|
|
258
|
+
if (targetPath.endsWith(".xml")) {
|
|
259
|
+
const result = analyzeMyBatisXml(targetPath);
|
|
260
|
+
if (result.isMyBatis) {
|
|
261
|
+
results.push(result);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} else if (stats.isDirectory()) {
|
|
265
|
+
const walkDir = (dir: string) => {
|
|
266
|
+
const files = fs.readdirSync(dir);
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
const filePath = path.join(dir, file);
|
|
269
|
+
const fileStat = fs.statSync(filePath);
|
|
270
|
+
if (fileStat.isDirectory() && recursive) {
|
|
271
|
+
if (!file.startsWith(".") && file !== "node_modules" && file !== "target" && file !== "build") {
|
|
272
|
+
walkDir(filePath);
|
|
273
|
+
}
|
|
274
|
+
} else if (file.endsWith(".xml")) {
|
|
275
|
+
const result = analyzeMyBatisXml(filePath);
|
|
276
|
+
if (result.isMyBatis) {
|
|
277
|
+
results.push(result);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
walkDir(targetPath);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 전체 통계
|
|
286
|
+
const totalStatements = results.reduce((sum, r) => sum + r.summary.statements, 0);
|
|
287
|
+
const totalWithIssues = results.reduce((sum, r) => sum + r.summary.withIssues, 0);
|
|
288
|
+
const totalInjectionRisks = results.reduce((sum, r) => sum + r.summary.injectionRisks, 0);
|
|
289
|
+
const totalDynamicSql = results.reduce((sum, r) => sum + r.summary.dynamicSqlCount, 0);
|
|
290
|
+
|
|
291
|
+
const output = {
|
|
292
|
+
analyzedMappers: results.length,
|
|
293
|
+
totalStatements,
|
|
294
|
+
statementsWithIssues: totalWithIssues,
|
|
295
|
+
injectionRisks: totalInjectionRisks,
|
|
296
|
+
dynamicSqlStatements: totalDynamicSql,
|
|
297
|
+
mappers: results.map((r) => ({
|
|
298
|
+
file: r.file,
|
|
299
|
+
namespace: r.mapper?.namespace,
|
|
300
|
+
resultMaps: r.mapper?.resultMaps,
|
|
301
|
+
sqlFragments: r.mapper?.sqlFragments,
|
|
302
|
+
mapperIssues: r.mapper?.issues,
|
|
303
|
+
statements: r.mapper?.statements.map((s) => ({
|
|
304
|
+
id: s.id,
|
|
305
|
+
type: s.type,
|
|
306
|
+
line: s.line,
|
|
307
|
+
parameterType: s.parameterType,
|
|
308
|
+
resultType: s.resultType || s.resultMap,
|
|
309
|
+
dynamicElements: s.dynamicElements,
|
|
310
|
+
issues: s.issues,
|
|
311
|
+
sql: s.sql.length > 150 ? s.sql.substring(0, 150) + "..." : s.sql,
|
|
312
|
+
})),
|
|
313
|
+
})),
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
success: true,
|
|
318
|
+
content: JSON.stringify(output, null, 2),
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
];
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { Tool, ToolResult } from "./types.js";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
interface SqlQuery {
|
|
6
|
+
type: "jpa" | "jdbc" | "native" | "jpql";
|
|
7
|
+
query: string;
|
|
8
|
+
location: string;
|
|
9
|
+
line: number;
|
|
10
|
+
issues: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SqlAnalysisResult {
|
|
14
|
+
file: string;
|
|
15
|
+
queries: SqlQuery[];
|
|
16
|
+
summary: {
|
|
17
|
+
total: number;
|
|
18
|
+
withIssues: number;
|
|
19
|
+
issueTypes: Record<string, number>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// SQL 쿼리 추출 및 분석
|
|
24
|
+
function analyzeJavaForSql(filePath: string): SqlAnalysisResult {
|
|
25
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
26
|
+
const lines = content.split("\n");
|
|
27
|
+
const queries: SqlQuery[] = [];
|
|
28
|
+
|
|
29
|
+
// @Query 어노테이션 찾기
|
|
30
|
+
const queryAnnotationRegex = /@Query\s*\(\s*(?:value\s*=\s*)?["'`]([^"'`]+)["'`]/g;
|
|
31
|
+
const nativeQueryRegex = /@Query\s*\([^)]*nativeQuery\s*=\s*true[^)]*value\s*=\s*["'`]([^"'`]+)["'`]/g;
|
|
32
|
+
const namedQueryRegex = /@NamedQuery\s*\([^)]*query\s*=\s*["'`]([^"'`]+)["'`]/g;
|
|
33
|
+
|
|
34
|
+
// JDBC/JPA 문자열 쿼리 찾기
|
|
35
|
+
const createQueryRegex = /(?:createQuery|createNativeQuery|prepareStatement)\s*\(\s*["'`]([^"'`]+)["'`]/g;
|
|
36
|
+
const jdbcExecuteRegex = /(?:executeQuery|executeUpdate|execute)\s*\(\s*["'`]([^"'`]+)["'`]/g;
|
|
37
|
+
|
|
38
|
+
// 문자열 변수에 할당된 SQL 찾기
|
|
39
|
+
const sqlStringRegex = /(?:String\s+)?(?:sql|query|hql|jpql)\s*=\s*["'`]([^"'`]*(?:SELECT|INSERT|UPDATE|DELETE|FROM|WHERE)[^"'`]*)["'`]/gi;
|
|
40
|
+
|
|
41
|
+
// 멀티라인 SQL 문자열 찾기
|
|
42
|
+
const multiLineSqlRegex = /["'`]\s*(SELECT|INSERT|UPDATE|DELETE)\s+[\s\S]*?["'`]\s*(?:\+\s*["'`][\s\S]*?["'`]\s*)*/gi;
|
|
43
|
+
|
|
44
|
+
const findLineNumber = (match: RegExpExecArray): number => {
|
|
45
|
+
const index = match.index;
|
|
46
|
+
let lineNum = 1;
|
|
47
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
48
|
+
if (content[i] === "\n") lineNum++;
|
|
49
|
+
}
|
|
50
|
+
return lineNum;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const analyzeQuery = (sql: string): string[] => {
|
|
54
|
+
const issues: string[] = [];
|
|
55
|
+
const upperSql = sql.toUpperCase().trim();
|
|
56
|
+
|
|
57
|
+
// SELECT * 검사
|
|
58
|
+
if (/SELECT\s+\*\s+FROM/i.test(sql)) {
|
|
59
|
+
issues.push("SELECT * 사용 - 필요한 컬럼만 명시 권장");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// WHERE 절 없는 UPDATE/DELETE
|
|
63
|
+
if (/^(UPDATE|DELETE)\s+/i.test(upperSql) && !/WHERE/i.test(sql)) {
|
|
64
|
+
issues.push("WHERE 절 없음 - 전체 테이블 영향 위험");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 서브쿼리 검사
|
|
68
|
+
const subqueryCount = (sql.match(/\(\s*SELECT/gi) || []).length;
|
|
69
|
+
if (subqueryCount >= 2) {
|
|
70
|
+
issues.push(`중첩 서브쿼리 ${subqueryCount}개 - 성능 저하 가능`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// JOIN 개수 검사
|
|
74
|
+
const joinCount = (sql.match(/\bJOIN\b/gi) || []).length;
|
|
75
|
+
if (joinCount >= 4) {
|
|
76
|
+
issues.push(`JOIN ${joinCount}개 - 쿼리 복잡도 높음`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// LIKE '%...' 패턴 (인덱스 미사용)
|
|
80
|
+
if (/LIKE\s+['"]%/i.test(sql)) {
|
|
81
|
+
issues.push("LIKE '%...' 패턴 - 인덱스 사용 불가");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// OR 조건 과다
|
|
85
|
+
const orCount = (sql.match(/\bOR\b/gi) || []).length;
|
|
86
|
+
if (orCount >= 3) {
|
|
87
|
+
issues.push(`OR 조건 ${orCount}개 - IN 절로 변환 검토`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ORDER BY RAND() 검사
|
|
91
|
+
if (/ORDER\s+BY\s+RAND\s*\(\)/i.test(sql)) {
|
|
92
|
+
issues.push("ORDER BY RAND() - 대용량 테이블에서 성능 저하");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// N+1 가능성 힌트 (단순 ID 조회)
|
|
96
|
+
if (/WHERE\s+\w+\.?id\s*=\s*[?:]/i.test(sql) && /SELECT/i.test(sql)) {
|
|
97
|
+
issues.push("단일 ID 조회 - 반복 호출 시 N+1 문제 가능");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 문자열 연결 (SQL Injection 위험)
|
|
101
|
+
if (/\+\s*["']|["']\s*\+/.test(sql)) {
|
|
102
|
+
issues.push("문자열 연결 감지 - SQL Injection 위험");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return issues;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// @Query 어노테이션 처리
|
|
109
|
+
let match;
|
|
110
|
+
while ((match = queryAnnotationRegex.exec(content)) !== null) {
|
|
111
|
+
const query = match[1].replace(/\s+/g, " ").trim();
|
|
112
|
+
queries.push({
|
|
113
|
+
type: "jpql",
|
|
114
|
+
query,
|
|
115
|
+
location: "@Query",
|
|
116
|
+
line: findLineNumber(match),
|
|
117
|
+
issues: analyzeQuery(query),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Native Query 처리
|
|
122
|
+
while ((match = nativeQueryRegex.exec(content)) !== null) {
|
|
123
|
+
const query = match[1].replace(/\s+/g, " ").trim();
|
|
124
|
+
queries.push({
|
|
125
|
+
type: "native",
|
|
126
|
+
query,
|
|
127
|
+
location: "@Query(nativeQuery)",
|
|
128
|
+
line: findLineNumber(match),
|
|
129
|
+
issues: analyzeQuery(query),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// @NamedQuery 처리
|
|
134
|
+
while ((match = namedQueryRegex.exec(content)) !== null) {
|
|
135
|
+
const query = match[1].replace(/\s+/g, " ").trim();
|
|
136
|
+
queries.push({
|
|
137
|
+
type: "jpa",
|
|
138
|
+
query,
|
|
139
|
+
location: "@NamedQuery",
|
|
140
|
+
line: findLineNumber(match),
|
|
141
|
+
issues: analyzeQuery(query),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// createQuery/prepareStatement 처리
|
|
146
|
+
while ((match = createQueryRegex.exec(content)) !== null) {
|
|
147
|
+
const query = match[1].replace(/\s+/g, " ").trim();
|
|
148
|
+
if (/SELECT|INSERT|UPDATE|DELETE/i.test(query)) {
|
|
149
|
+
queries.push({
|
|
150
|
+
type: "jdbc",
|
|
151
|
+
query,
|
|
152
|
+
location: "createQuery/prepareStatement",
|
|
153
|
+
line: findLineNumber(match),
|
|
154
|
+
issues: analyzeQuery(query),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// executeQuery 처리
|
|
160
|
+
while ((match = jdbcExecuteRegex.exec(content)) !== null) {
|
|
161
|
+
const query = match[1].replace(/\s+/g, " ").trim();
|
|
162
|
+
if (/SELECT|INSERT|UPDATE|DELETE/i.test(query)) {
|
|
163
|
+
queries.push({
|
|
164
|
+
type: "jdbc",
|
|
165
|
+
query,
|
|
166
|
+
location: "execute*",
|
|
167
|
+
line: findLineNumber(match),
|
|
168
|
+
issues: analyzeQuery(query),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// SQL 문자열 변수 처리
|
|
174
|
+
while ((match = sqlStringRegex.exec(content)) !== null) {
|
|
175
|
+
const query = match[1].replace(/\s+/g, " ").trim();
|
|
176
|
+
queries.push({
|
|
177
|
+
type: "jdbc",
|
|
178
|
+
query,
|
|
179
|
+
location: "String 변수",
|
|
180
|
+
line: findLineNumber(match),
|
|
181
|
+
issues: analyzeQuery(query),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 이슈 통계
|
|
186
|
+
const issueTypes: Record<string, number> = {};
|
|
187
|
+
queries.forEach((q) => {
|
|
188
|
+
q.issues.forEach((issue) => {
|
|
189
|
+
const key = issue.split(" - ")[0];
|
|
190
|
+
issueTypes[key] = (issueTypes[key] || 0) + 1;
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
file: filePath,
|
|
196
|
+
queries,
|
|
197
|
+
summary: {
|
|
198
|
+
total: queries.length,
|
|
199
|
+
withIssues: queries.filter((q) => q.issues.length > 0).length,
|
|
200
|
+
issueTypes,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 도구 정의
|
|
206
|
+
export const sqlTools: Tool[] = [
|
|
207
|
+
{
|
|
208
|
+
name: "sql_check",
|
|
209
|
+
description:
|
|
210
|
+
"Java 파일에서 SQL 쿼리를 추출하고 품질 검사합니다. @Query, JDBC, JPA 쿼리를 분석하여 SELECT *, 인덱스 미사용 패턴, N+1 가능성 등을 검출합니다.",
|
|
211
|
+
parameters: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: {
|
|
214
|
+
path: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "분석할 Java 파일 또는 디렉토리 경로",
|
|
217
|
+
},
|
|
218
|
+
recursive: {
|
|
219
|
+
type: "boolean",
|
|
220
|
+
description: "디렉토리인 경우 하위 폴더 포함 여부 (기본: true)",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
required: ["path"],
|
|
224
|
+
},
|
|
225
|
+
handler: async (args: Record<string, unknown>): Promise<ToolResult> => {
|
|
226
|
+
const targetPath = args.path as string;
|
|
227
|
+
const recursive = args.recursive !== false;
|
|
228
|
+
|
|
229
|
+
if (!fs.existsSync(targetPath)) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
content: "",
|
|
233
|
+
error: `경로를 찾을 수 없습니다: ${targetPath}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const results: SqlAnalysisResult[] = [];
|
|
238
|
+
const stats = fs.statSync(targetPath);
|
|
239
|
+
|
|
240
|
+
if (stats.isFile()) {
|
|
241
|
+
if (targetPath.endsWith(".java")) {
|
|
242
|
+
results.push(analyzeJavaForSql(targetPath));
|
|
243
|
+
}
|
|
244
|
+
} else if (stats.isDirectory()) {
|
|
245
|
+
const walkDir = (dir: string) => {
|
|
246
|
+
const files = fs.readdirSync(dir);
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const filePath = path.join(dir, file);
|
|
249
|
+
const fileStat = fs.statSync(filePath);
|
|
250
|
+
if (fileStat.isDirectory() && recursive) {
|
|
251
|
+
if (!file.startsWith(".") && file !== "node_modules" && file !== "target" && file !== "build") {
|
|
252
|
+
walkDir(filePath);
|
|
253
|
+
}
|
|
254
|
+
} else if (file.endsWith(".java")) {
|
|
255
|
+
const result = analyzeJavaForSql(filePath);
|
|
256
|
+
if (result.queries.length > 0) {
|
|
257
|
+
results.push(result);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
walkDir(targetPath);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 전체 통계
|
|
266
|
+
const totalQueries = results.reduce((sum, r) => sum + r.summary.total, 0);
|
|
267
|
+
const totalWithIssues = results.reduce((sum, r) => sum + r.summary.withIssues, 0);
|
|
268
|
+
const allIssueTypes: Record<string, number> = {};
|
|
269
|
+
results.forEach((r) => {
|
|
270
|
+
Object.entries(r.summary.issueTypes).forEach(([key, count]) => {
|
|
271
|
+
allIssueTypes[key] = (allIssueTypes[key] || 0) + count;
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const output = {
|
|
276
|
+
analyzed: results.length,
|
|
277
|
+
totalQueries,
|
|
278
|
+
queriesWithIssues: totalWithIssues,
|
|
279
|
+
issueTypes: allIssueTypes,
|
|
280
|
+
files: results.map((r) => ({
|
|
281
|
+
file: r.file,
|
|
282
|
+
queries: r.queries.map((q) => ({
|
|
283
|
+
line: q.line,
|
|
284
|
+
type: q.type,
|
|
285
|
+
location: q.location,
|
|
286
|
+
query: q.query.length > 100 ? q.query.substring(0, 100) + "..." : q.query,
|
|
287
|
+
issues: q.issues,
|
|
288
|
+
})),
|
|
289
|
+
})),
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
success: true,
|
|
294
|
+
content: JSON.stringify(output, null, 2),
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
];
|