activo 0.4.3 → 0.5.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.
Files changed (166) hide show
  1. package/README.md +203 -1
  2. package/data/2026-03-04_20-54.json +181 -0
  3. package/data/2026-03-04_20-56.json +181 -0
  4. package/data/apex-rulesets/egov.yaml +469 -0
  5. package/data/apex-rulesets/modernize.yaml +687 -0
  6. package/data/apex-rulesets/quality.yaml +1677 -0
  7. package/data/apex-rulesets/rule-schema.yaml +587 -0
  8. package/data/apex-rulesets/secure.yaml +1688 -0
  9. package/data/apex-rulesets/spring.yaml +455 -0
  10. package/data/apex-rulesets/sql-format.yaml +99 -0
  11. package/data/apex-rulesets/sql-oracle.yaml +281 -0
  12. package/data/apex-rulesets/sql.yaml +1660 -0
  13. package/dist/cli/headless.d.ts.map +1 -1
  14. package/dist/cli/headless.js +32 -10
  15. package/dist/cli/headless.js.map +1 -1
  16. package/dist/cli/index.js +31 -3
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/core/agent.d.ts +3 -3
  19. package/dist/core/agent.d.ts.map +1 -1
  20. package/dist/core/agent.js +255 -17
  21. package/dist/core/agent.js.map +1 -1
  22. package/dist/core/commands.d.ts +2 -1
  23. package/dist/core/commands.d.ts.map +1 -1
  24. package/dist/core/commands.js +61 -9
  25. package/dist/core/commands.js.map +1 -1
  26. package/dist/core/config.d.ts +14 -0
  27. package/dist/core/config.d.ts.map +1 -1
  28. package/dist/core/config.js +41 -4
  29. package/dist/core/config.js.map +1 -1
  30. package/dist/core/conversation.d.ts +2 -2
  31. package/dist/core/conversation.d.ts.map +1 -1
  32. package/dist/core/conversation.js.map +1 -1
  33. package/dist/core/intentRouter.d.ts +43 -0
  34. package/dist/core/intentRouter.d.ts.map +1 -0
  35. package/dist/core/intentRouter.js +804 -0
  36. package/dist/core/intentRouter.js.map +1 -0
  37. package/dist/core/llm/anthropic.d.ts +24 -0
  38. package/dist/core/llm/anthropic.d.ts.map +1 -0
  39. package/dist/core/llm/anthropic.js +226 -0
  40. package/dist/core/llm/anthropic.js.map +1 -0
  41. package/dist/core/llm/ollama.d.ts +5 -14
  42. package/dist/core/llm/ollama.d.ts.map +1 -1
  43. package/dist/core/llm/ollama.js +3 -0
  44. package/dist/core/llm/ollama.js.map +1 -1
  45. package/dist/core/llm/types.d.ts +22 -0
  46. package/dist/core/llm/types.d.ts.map +1 -0
  47. package/dist/core/llm/types.js +2 -0
  48. package/dist/core/llm/types.js.map +1 -0
  49. package/dist/core/mcp/client.d.ts +6 -0
  50. package/dist/core/mcp/client.d.ts.map +1 -1
  51. package/dist/core/mcp/client.js +16 -0
  52. package/dist/core/mcp/client.js.map +1 -1
  53. package/dist/core/mcp/init.d.ts +12 -0
  54. package/dist/core/mcp/init.d.ts.map +1 -0
  55. package/dist/core/mcp/init.js +55 -0
  56. package/dist/core/mcp/init.js.map +1 -0
  57. package/dist/core/mcp/logger.d.ts +14 -0
  58. package/dist/core/mcp/logger.d.ts.map +1 -0
  59. package/dist/core/mcp/logger.js +50 -0
  60. package/dist/core/mcp/logger.js.map +1 -0
  61. package/dist/core/tools/analyzeAll.d.ts.map +1 -1
  62. package/dist/core/tools/analyzeAll.js +16 -28
  63. package/dist/core/tools/analyzeAll.js.map +1 -1
  64. package/dist/core/tools/analyzePatterns.d.ts +3 -0
  65. package/dist/core/tools/analyzePatterns.d.ts.map +1 -0
  66. package/dist/core/tools/analyzePatterns.js +293 -0
  67. package/dist/core/tools/analyzePatterns.js.map +1 -0
  68. package/dist/core/tools/apexPaths.d.ts +14 -0
  69. package/dist/core/tools/apexPaths.d.ts.map +1 -0
  70. package/dist/core/tools/apexPaths.js +54 -0
  71. package/dist/core/tools/apexPaths.js.map +1 -0
  72. package/dist/core/tools/apexUtils.d.ts +36 -0
  73. package/dist/core/tools/apexUtils.d.ts.map +1 -0
  74. package/dist/core/tools/apexUtils.js +83 -0
  75. package/dist/core/tools/apexUtils.js.map +1 -0
  76. package/dist/core/tools/explainIssue.d.ts +3 -0
  77. package/dist/core/tools/explainIssue.d.ts.map +1 -0
  78. package/dist/core/tools/explainIssue.js +181 -0
  79. package/dist/core/tools/explainIssue.js.map +1 -0
  80. package/dist/core/tools/fixGen.d.ts +3 -0
  81. package/dist/core/tools/fixGen.d.ts.map +1 -0
  82. package/dist/core/tools/fixGen.js +338 -0
  83. package/dist/core/tools/fixGen.js.map +1 -0
  84. package/dist/core/tools/generateImprovements.d.ts +21 -0
  85. package/dist/core/tools/generateImprovements.d.ts.map +1 -0
  86. package/dist/core/tools/generateImprovements.js +602 -0
  87. package/dist/core/tools/generateImprovements.js.map +1 -0
  88. package/dist/core/tools/generateReport.d.ts +3 -0
  89. package/dist/core/tools/generateReport.d.ts.map +1 -0
  90. package/dist/core/tools/generateReport.js +315 -0
  91. package/dist/core/tools/generateReport.js.map +1 -0
  92. package/dist/core/tools/index.d.ts +7 -0
  93. package/dist/core/tools/index.d.ts.map +1 -1
  94. package/dist/core/tools/index.js +62 -23
  95. package/dist/core/tools/index.js.map +1 -1
  96. package/dist/core/tools/javaAst.d.ts.map +1 -1
  97. package/dist/core/tools/javaAst.js +191 -0
  98. package/dist/core/tools/javaAst.js.map +1 -1
  99. package/dist/core/tools/recommendProfile.d.ts +3 -0
  100. package/dist/core/tools/recommendProfile.d.ts.map +1 -0
  101. package/dist/core/tools/recommendProfile.js +334 -0
  102. package/dist/core/tools/recommendProfile.js.map +1 -0
  103. package/dist/core/tools/ruleGen.d.ts +3 -0
  104. package/dist/core/tools/ruleGen.d.ts.map +1 -0
  105. package/dist/core/tools/ruleGen.js +1103 -0
  106. package/dist/core/tools/ruleGen.js.map +1 -0
  107. package/dist/core/tools/standards.d.ts.map +1 -1
  108. package/dist/core/tools/standards.js +7 -3
  109. package/dist/core/tools/standards.js.map +1 -1
  110. package/dist/ui/App.d.ts.map +1 -1
  111. package/dist/ui/App.js +86 -35
  112. package/dist/ui/App.js.map +1 -1
  113. package/dist/ui/components/InputBox.d.ts +1 -3
  114. package/dist/ui/components/InputBox.d.ts.map +1 -1
  115. package/dist/ui/components/InputBox.js +146 -5
  116. package/dist/ui/components/InputBox.js.map +1 -1
  117. package/dist/ui/components/MessageList.d.ts +3 -1
  118. package/dist/ui/components/MessageList.d.ts.map +1 -1
  119. package/dist/ui/components/MessageList.js +13 -7
  120. package/dist/ui/components/MessageList.js.map +1 -1
  121. package/dist/ui/components/StatusBar.d.ts +1 -1
  122. package/dist/ui/components/StatusBar.d.ts.map +1 -1
  123. package/dist/ui/components/StatusBar.js +3 -2
  124. package/dist/ui/components/StatusBar.js.map +1 -1
  125. package/dist/ui/components/ToolStatus.d.ts +3 -1
  126. package/dist/ui/components/ToolStatus.d.ts.map +1 -1
  127. package/dist/ui/components/ToolStatus.js +19 -4
  128. package/dist/ui/components/ToolStatus.js.map +1 -1
  129. package/package.json +7 -1
  130. package/demo.gif +0 -0
  131. package/demo.tape +0 -53
  132. package/screenshot.png +0 -0
  133. package/src/cli/banner.ts +0 -38
  134. package/src/cli/headless.ts +0 -63
  135. package/src/cli/index.ts +0 -57
  136. package/src/core/agent.ts +0 -237
  137. package/src/core/commands.ts +0 -118
  138. package/src/core/config.ts +0 -98
  139. package/src/core/conversation.ts +0 -235
  140. package/src/core/llm/ollama.ts +0 -351
  141. package/src/core/mcp/client.ts +0 -143
  142. package/src/core/tools/analyzeAll.ts +0 -494
  143. package/src/core/tools/ast.ts +0 -826
  144. package/src/core/tools/builtIn.ts +0 -221
  145. package/src/core/tools/cache.ts +0 -570
  146. package/src/core/tools/cssAnalysis.ts +0 -324
  147. package/src/core/tools/dependencyAnalysis.ts +0 -363
  148. package/src/core/tools/embeddings.ts +0 -746
  149. package/src/core/tools/frontendAst.ts +0 -802
  150. package/src/core/tools/htmlAnalysis.ts +0 -466
  151. package/src/core/tools/index.ts +0 -160
  152. package/src/core/tools/javaAst.ts +0 -812
  153. package/src/core/tools/memory.ts +0 -655
  154. package/src/core/tools/mybatisAnalysis.ts +0 -322
  155. package/src/core/tools/openapiAnalysis.ts +0 -431
  156. package/src/core/tools/pythonAnalysis.ts +0 -477
  157. package/src/core/tools/sqlAnalysis.ts +0 -298
  158. package/src/core/tools/standards.test.ts +0 -186
  159. package/src/core/tools/standards.ts +0 -889
  160. package/src/core/tools/types.ts +0 -38
  161. package/src/ui/App.tsx +0 -334
  162. package/src/ui/components/InputBox.tsx +0 -37
  163. package/src/ui/components/MessageList.tsx +0 -80
  164. package/src/ui/components/StatusBar.tsx +0 -36
  165. package/src/ui/components/ToolStatus.tsx +0 -38
  166. package/tsconfig.json +0 -21
@@ -0,0 +1,1103 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import yaml from "js-yaml";
4
+ import { createLLMClient } from "./apexUtils.js";
5
+ // ─── YAML Validation ───
6
+ const VALID_PATTERN_TYPES = [
7
+ "regex", "regex-multiline", "ast-filtered-regex", "annotation-missing-attr",
8
+ "line-length", "ast-method-call", "ast-annotation", "ast-import",
9
+ "ast-variable", "ast-try-catch", "ast-class", "ast-method",
10
+ "ast-multi-cud", "ast-dead-code",
11
+ "ast-sql-table", "ast-sql-join", "ast-sql-select", "ast-sql-where",
12
+ "ast-sql-subquery", "ast-sql-hint", "ast-sql-setop", "ast-sql-orderby",
13
+ ];
14
+ const VALID_SEVERITIES = ["low", "medium", "high", "critical"];
15
+ function validateRuleYaml(yamlText) {
16
+ let parsed;
17
+ try {
18
+ parsed = yaml.load(yamlText);
19
+ }
20
+ catch (e) {
21
+ return { valid: false, error: `YAML 파싱 실패: ${e}` };
22
+ }
23
+ if (!parsed || typeof parsed !== "object") {
24
+ return { valid: false, error: "YAML이 객체가 아닙니다" };
25
+ }
26
+ const rule = parsed;
27
+ // Required fields
28
+ for (const field of ["id", "name", "severity", "category", "description", "enabled", "pattern"]) {
29
+ if (rule[field] === undefined) {
30
+ return { valid: false, error: `필수 필드 누락: ${field}` };
31
+ }
32
+ }
33
+ // ID convention
34
+ if (typeof rule.id !== "string" || !rule.id.startsWith("custom-")) {
35
+ return { valid: false, error: `ID는 'custom-' 접두사 필요: ${rule.id}` };
36
+ }
37
+ // Severity
38
+ if (!VALID_SEVERITIES.includes(rule.severity)) {
39
+ return { valid: false, error: `잘못된 severity: ${rule.severity}` };
40
+ }
41
+ // Pattern type
42
+ const pattern = rule.pattern;
43
+ if (!pattern || typeof pattern !== "object" || !pattern.type) {
44
+ return { valid: false, error: "pattern.type 누락" };
45
+ }
46
+ if (!VALID_PATTERN_TYPES.includes(pattern.type)) {
47
+ return { valid: false, error: `잘못된 pattern.type: ${pattern.type}` };
48
+ }
49
+ // Regex validation (for regex-based types)
50
+ if (["regex", "regex-multiline", "ast-filtered-regex"].includes(pattern.type)) {
51
+ if (!pattern.regex || typeof pattern.regex !== "string") {
52
+ return { valid: false, error: "regex 타입에 regex 필드 필요" };
53
+ }
54
+ try {
55
+ new RegExp(pattern.regex);
56
+ }
57
+ catch (e) {
58
+ return { valid: false, error: `잘못된 정규식: ${e}` };
59
+ }
60
+ }
61
+ return { valid: true, parsed: rule };
62
+ }
63
+ // ─── Context Size Profiles ───
64
+ const REGEX_ONLY_TYPES = ["regex", "regex-multiline", "ast-filtered-regex"];
65
+ const CONTEXT_PROFILES = {
66
+ full: {
67
+ strategy: "one-step",
68
+ schemaFilterTypes: null,
69
+ maxExistingRules: 30,
70
+ existingRulesCompact: false,
71
+ maxItemsChars: 4000,
72
+ maxItemContentChars: 1200,
73
+ },
74
+ compact: {
75
+ strategy: "two-step",
76
+ schemaFilterTypes: REGEX_ONLY_TYPES,
77
+ maxExistingRules: 50,
78
+ existingRulesCompact: true,
79
+ maxItemsChars: 1500,
80
+ maxItemContentChars: 300,
81
+ },
82
+ };
83
+ function getContextProfile(provider) {
84
+ return provider === "ollama" ? CONTEXT_PROFILES.compact : CONTEXT_PROFILES.full;
85
+ }
86
+ // ─── Schema Loading ───
87
+ function loadRuleSchema(schemaPath, filterTypes) {
88
+ const content = fs.readFileSync(schemaPath, "utf-8");
89
+ const schema = yaml.load(content);
90
+ const patternTypes = schema.pattern_types;
91
+ if (!Array.isArray(patternTypes)) {
92
+ return [];
93
+ }
94
+ let result = patternTypes.map((pt) => ({
95
+ type: pt.type,
96
+ description: pt.description,
97
+ languages: pt.languages || [],
98
+ example: typeof pt.example === "string"
99
+ ? pt.example
100
+ : yaml.dump(pt.example, { lineWidth: 120 }),
101
+ }));
102
+ if (filterTypes) {
103
+ result = result.filter((pt) => filterTypes.includes(pt.type));
104
+ }
105
+ return result;
106
+ }
107
+ // ─── Existing Rules Loading ───
108
+ function loadExistingRules(rulesetsDir) {
109
+ const rules = [];
110
+ if (!fs.existsSync(rulesetsDir))
111
+ return rules;
112
+ const files = fs.readdirSync(rulesetsDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
113
+ for (const file of files) {
114
+ try {
115
+ const content = fs.readFileSync(path.join(rulesetsDir, file), "utf-8");
116
+ const data = yaml.load(content);
117
+ const languages = data.languages;
118
+ if (!Array.isArray(languages))
119
+ continue;
120
+ for (const lang of languages) {
121
+ const langRules = lang.rules;
122
+ if (!Array.isArray(langRules))
123
+ continue;
124
+ for (const r of langRules) {
125
+ rules.push({
126
+ id: r.id,
127
+ name: r.name,
128
+ description: r.description || "",
129
+ category: r.category || "",
130
+ });
131
+ }
132
+ }
133
+ }
134
+ catch {
135
+ // Skip invalid YAML files
136
+ }
137
+ }
138
+ return rules;
139
+ }
140
+ // ─── Standards Loading ───
141
+ function loadStandardItems(standardsDir) {
142
+ const items = [];
143
+ if (!fs.existsSync(standardsDir))
144
+ return items;
145
+ const files = fs.readdirSync(standardsDir)
146
+ .filter((f) => f.endsWith(".md") && f !== "_index.md")
147
+ .sort();
148
+ // Group files by PDF source: strip leading number prefix to get base name
149
+ // e.g., "01_FTSS-DES-D299--v01.md" ~ "08_FTSS-DES-D299--v01.md" → "FTSS-DES-D299--v01.md"
150
+ const groups = new Map();
151
+ for (const file of files) {
152
+ const baseName = file.replace(/^\d+[_-]/, "");
153
+ if (!groups.has(baseName))
154
+ groups.set(baseName, []);
155
+ groups.get(baseName).push(file);
156
+ }
157
+ for (const [baseName, groupFiles] of groups) {
158
+ if (groupFiles.length > 1) {
159
+ // Multiple parts from same PDF — merge into one string
160
+ const merged = groupFiles.map((f) => {
161
+ return fs.readFileSync(path.join(standardsDir, f), "utf-8");
162
+ }).join("\n");
163
+ const filepath = path.join(standardsDir, baseName);
164
+ console.log(`[ruleGen] merged ${groupFiles.length} parts for ${baseName}`);
165
+ const chunks = splitStandardsIntoChunks(merged, filepath);
166
+ items.push(...chunks);
167
+ }
168
+ else {
169
+ // Single file — process as-is
170
+ const filepath = path.join(standardsDir, groupFiles[0]);
171
+ const content = fs.readFileSync(filepath, "utf-8");
172
+ const chunks = splitStandardsIntoChunks(content, filepath);
173
+ items.push(...chunks);
174
+ }
175
+ }
176
+ return items;
177
+ }
178
+ function splitStandardsIntoChunks(content, filepath) {
179
+ // ── Step 1: Remove noise lines ──
180
+ const cleanedLines = content.split("\n").filter((line) => {
181
+ const trimmed = line.trim();
182
+ // Remove "# Part N" titles
183
+ if (/^#\s+.*Part\s+\d+/i.test(trimmed))
184
+ return false;
185
+ // Remove "> Source:" and "> Extracted:" metadata
186
+ if (/^>\s*(Source|Extracted|Page|Date):/.test(trimmed))
187
+ return false;
188
+ // Remove "---" separators
189
+ if (trimmed === "---")
190
+ return false;
191
+ // Remove page numbers like "8 / 23"
192
+ if (/^\d+\s*\/\s*\d+$/.test(trimmed))
193
+ return false;
194
+ // Remove "[Edit this file...]" placeholders
195
+ if (/^\[Edit\s+this\s+file/i.test(trimmed))
196
+ return false;
197
+ // Remove TOC lines with dots (2.1 주석 규칙 ····· 3)
198
+ if (/·{5,}/.test(trimmed))
199
+ return false;
200
+ return true;
201
+ });
202
+ const cleanedContent = cleanedLines.join("\n");
203
+ // ── Step 2: Split by numbered sections (2.1, 2.3.4, etc.) ──
204
+ // Pattern: line starts with digit.digit (at least one dot), then Korean/English title
205
+ const sectionPattern = /^(\d+(?:\.\d+)+)\s+([가-힣A-Za-z].*)/;
206
+ const chunks = [];
207
+ let currentSection = "";
208
+ let currentRuleId;
209
+ let currentContent = [];
210
+ for (const line of cleanedContent.split("\n")) {
211
+ // Pattern 1: Structured rule ID (## NR-001: Title)
212
+ const ruleMatch = line.match(/^##\s+([A-Z]+-\d+):\s*(.+)/i);
213
+ // Pattern 2: Numbered section heading (2.1 주석 규칙, 2.3.4 각종 선언)
214
+ const numberedMatch = line.match(sectionPattern);
215
+ if (ruleMatch) {
216
+ flushChunk();
217
+ currentRuleId = ruleMatch[1];
218
+ currentSection = ruleMatch[2];
219
+ currentContent = [line];
220
+ }
221
+ else if (numberedMatch) {
222
+ const sectionNumber = numberedMatch[1];
223
+ const title = numberedMatch[2].trim();
224
+ // Skip section 1.x (overview, not coding rules)
225
+ if (!sectionNumber.startsWith("1.")) {
226
+ flushChunk();
227
+ currentRuleId = undefined;
228
+ currentSection = `${sectionNumber} ${title}`;
229
+ currentContent = [line];
230
+ }
231
+ else {
232
+ currentContent.push(line);
233
+ }
234
+ }
235
+ else {
236
+ currentContent.push(line);
237
+ }
238
+ }
239
+ flushChunk();
240
+ // ── Step 3: Filter out empty/useless chunks ──
241
+ return chunks.filter((c) => {
242
+ // Actual text content (strip markdown formatting and whitespace)
243
+ const textOnly = c.content.replace(/[>\-#*`\s\n]/g, "");
244
+ // Must have at least 20 chars of real content
245
+ if (textOnly.length < 20)
246
+ return false;
247
+ // Skip section 1.x (overview)
248
+ if (/^1\.\d/.test(c.section))
249
+ return false;
250
+ // Skip chunks with no section name (metadata/cover page before first numbered section)
251
+ if (!c.section && !c.ruleId)
252
+ return false;
253
+ // Skip metadata content (Document ID, Document Version, 승인내역, etc.)
254
+ if (/Document\s+(ID|Version)\s*:/.test(c.content))
255
+ return false;
256
+ // Skip TOC-only content (all meaningful lines contain ·····)
257
+ const nonEmptyLines = c.content.split("\n").filter((l) => l.trim().length > 0);
258
+ const tocLines = nonEmptyLines.filter((l) => /·{3,}/.test(l));
259
+ if (tocLines.length > nonEmptyLines.length * 0.5)
260
+ return false;
261
+ return true;
262
+ });
263
+ function flushChunk() {
264
+ if (currentContent.length > 0) {
265
+ const text = currentContent.join("\n").trim();
266
+ if (text) {
267
+ chunks.push({ filepath, section: currentSection, ruleId: currentRuleId, content: text });
268
+ }
269
+ }
270
+ }
271
+ }
272
+ // ─── Category Grouping ───
273
+ function groupByCategory(items) {
274
+ const groups = new Map();
275
+ const categoryPatterns = [
276
+ { pattern: /naming|명명|네이밍|이름/i, category: "naming" },
277
+ { pattern: /security|보안|시큐어|취약/i, category: "security" },
278
+ { pattern: /exception|예외|에러|error/i, category: "exception" },
279
+ { pattern: /performance|성능|퍼포먼스/i, category: "performance" },
280
+ { pattern: /transaction|트랜잭션/i, category: "transaction" },
281
+ { pattern: /logging|로그|로깅/i, category: "logging" },
282
+ { pattern: /sql|쿼리|query|mybatis/i, category: "sql" },
283
+ { pattern: /design|설계|구조|아키텍처|architecture/i, category: "design" },
284
+ { pattern: /format|포맷|들여쓰기|indent/i, category: "format" },
285
+ { pattern: /document|주석|comment|javadoc/i, category: "documentation" },
286
+ { pattern: /deprecated|비추천|폐기/i, category: "deprecated" },
287
+ { pattern: /import|패키지|package/i, category: "import" },
288
+ { pattern: /resource|리소스|자원/i, category: "resource" },
289
+ { pattern: /api|REST|엔드포인트/i, category: "api" },
290
+ ];
291
+ for (const item of items) {
292
+ const text = `${item.section} ${item.content}`.toLowerCase();
293
+ let matched = false;
294
+ for (const { pattern, category } of categoryPatterns) {
295
+ if (pattern.test(text)) {
296
+ if (!groups.has(category))
297
+ groups.set(category, []);
298
+ groups.get(category).push(item);
299
+ matched = true;
300
+ break;
301
+ }
302
+ }
303
+ if (!matched) {
304
+ if (!groups.has("general"))
305
+ groups.set("general", []);
306
+ groups.get("general").push(item);
307
+ }
308
+ }
309
+ return groups;
310
+ }
311
+ // ─── Pattern Type Selection ───
312
+ function selectPatternTypes(category, allTypes) {
313
+ const categoryTypeMap = {
314
+ naming: ["regex", "ast-variable", "ast-class"],
315
+ security: ["ast-filtered-regex", "ast-method-call", "regex"],
316
+ exception: ["ast-try-catch", "ast-method-call", "regex"],
317
+ performance: ["ast-method-call", "ast-sql-select", "regex"],
318
+ transaction: ["ast-multi-cud", "ast-annotation", "regex-multiline"],
319
+ logging: ["regex", "ast-filtered-regex", "ast-method-call"],
320
+ sql: ["ast-sql-table", "ast-sql-join", "ast-sql-where", "regex-multiline"],
321
+ design: ["ast-class", "ast-method", "ast-annotation"],
322
+ format: ["line-length", "regex", "regex-multiline"],
323
+ documentation: ["regex", "ast-class", "ast-method"],
324
+ deprecated: ["ast-import", "ast-method-call", "regex"],
325
+ import: ["ast-import", "regex"],
326
+ resource: ["ast-try-catch", "ast-method-call", "regex"],
327
+ api: ["ast-annotation", "annotation-missing-attr", "regex"],
328
+ general: ["regex", "ast-filtered-regex", "ast-method-call"],
329
+ };
330
+ const typeNames = categoryTypeMap[category] || categoryTypeMap.general;
331
+ return typeNames
332
+ .map((name) => allTypes.find((t) => t.type === name))
333
+ .filter((t) => t !== undefined)
334
+ .slice(0, 3);
335
+ }
336
+ // ─── LLM Prompt & Parsing ───
337
+ function buildBatchPrompt(items, patternTypes, existingRules, profile) {
338
+ // Combine items text (profile-aware max chars)
339
+ let itemsText = items.map((item, i) => {
340
+ const id = item.ruleId || `ITEM-${i + 1}`;
341
+ return `[${id}] ${item.section}\n${item.content.slice(0, profile.maxItemContentChars)}`;
342
+ }).join("\n---\n");
343
+ if (itemsText.length > profile.maxItemsChars) {
344
+ itemsText = itemsText.slice(0, profile.maxItemsChars) + "\n...(truncated)";
345
+ }
346
+ // Pattern types info
347
+ const typesText = patternTypes.map((pt) => {
348
+ return `### ${pt.type}\n${pt.description}\nExample:\n${pt.example}`;
349
+ }).join("\n\n");
350
+ // Existing rules — compact (ID only) or full
351
+ let existingText;
352
+ if (profile.existingRulesCompact) {
353
+ // Compact: group IDs by category, no name/description
354
+ const byCategory = new Map();
355
+ for (const r of existingRules.slice(0, profile.maxExistingRules)) {
356
+ const cat = r.category || "other";
357
+ if (!byCategory.has(cat))
358
+ byCategory.set(cat, []);
359
+ byCategory.get(cat).push(r.id);
360
+ }
361
+ existingText = Array.from(byCategory.entries())
362
+ .map(([cat, ids]) => `${cat}: ${ids.join(", ")}`)
363
+ .join("\n");
364
+ }
365
+ else {
366
+ existingText = existingRules.slice(0, profile.maxExistingRules).map((r) => {
367
+ return `- ${r.id}: ${r.name} (${r.category})`;
368
+ }).join("\n");
369
+ }
370
+ const prompt = `You are an APEX rule generator. For each standard item below, classify it and optionally generate a YAML rule.
371
+
372
+ ## Standard Items
373
+ ${itemsText}
374
+
375
+ ## Available Pattern Types (use ONLY these)
376
+ ${typesText}
377
+
378
+ ## Existing Rules (check for duplicates)
379
+ ${existingText}
380
+
381
+ ## Instructions
382
+ For EACH standard item (identified by [RULE-XXX] or [ITEM-N]), output ONE classification:
383
+
384
+ A) If the item can be detected with a YAML pattern:
385
+ ITEM: [id]
386
+ CLASSIFICATION: yaml
387
+ RULE_YAML:
388
+ \`\`\`yaml
389
+ - id: "custom-{category}-{number}"
390
+ name: "규칙 이름"
391
+ severity: "low|medium|high|critical"
392
+ category: "카테고리"
393
+ description: "설명"
394
+ enabled: true
395
+ pattern:
396
+ type: "{pattern_type}"
397
+ ...pattern params...
398
+ \`\`\`
399
+
400
+ B) If the item requires Go code or complex logic:
401
+ ITEM: [id]
402
+ CLASSIFICATION: manual
403
+ REASON: (why it can't be YAML)
404
+
405
+ C) If an existing rule already covers this:
406
+ ITEM: [id]
407
+ CLASSIFICATION: matched
408
+ MATCHED_RULE: (existing rule ID)
409
+
410
+ ## IMPORTANT
411
+ - Skip items that are document metadata, table of contents, or overview/purpose sections — classify them as "matched" with MATCHED_RULE: "N/A (metadata)"
412
+ - Focus ONLY on items with concrete coding rules that show OK/NOT OK examples
413
+ - Generate a UNIQUE sequential ID for each rule: custom-{category}-001, custom-{category}-002, ...
414
+ - DO NOT reuse IDs from existing rules or from other items in this batch
415
+ - Include the full code example from the standard when building regex patterns
416
+ - id MUST start with "custom-"
417
+ - severity MUST be one of: low, medium, high, critical
418
+ - pattern.type MUST be one of the available types above
419
+ - regex must be valid (escape special chars with double backslash)
420
+ - Output ALL items, one classification per item
421
+
422
+ ## Example for Korean dev standard "한 줄에 하나의 문장":
423
+ ITEM: [ITEM-1]
424
+ CLASSIFICATION: yaml
425
+ RULE_YAML:
426
+ \`\`\`yaml
427
+ - id: "custom-format-001"
428
+ name: "한 줄에 하나의 문장만 작성"
429
+ severity: "low"
430
+ category: "format"
431
+ description: "세미콜론 뒤에 같은 줄에서 새 문장이 시작되면 안 됩니다"
432
+ enabled: true
433
+ pattern:
434
+ type: "regex"
435
+ regex: ";\\\\s*\\\\w+\\\\s*[=+\\\\-]"
436
+ \`\`\``;
437
+ console.log(`[ruleGen] prompt size: ${prompt.length} chars (items: ${itemsText.length}, types: ${typesText.length}, rules: ${existingText.length})`);
438
+ return prompt;
439
+ }
440
+ /**
441
+ * Step 1: MD 표준 항목 중 regex로 검출 가능한 후보를 선별
442
+ */
443
+ function buildStep1Prompt(items, existingRuleIds) {
444
+ const itemsText = items.map((item, i) => {
445
+ const id = item.ruleId || `ITEM-${i + 1}`;
446
+ return `[${id}] ${item.section}\n${item.content.slice(0, 300)}`;
447
+ }).join("\n---\n");
448
+ // 기존 규칙 ID만 간결하게
449
+ const existingText = existingRuleIds.slice(0, 30).join(", ");
450
+ const prompt = `You select coding standards that can be detected by regex.
451
+
452
+ ## Standards
453
+ ${itemsText}
454
+
455
+ ## Already covered rule IDs (skip these)
456
+ ${existingText}
457
+
458
+ ## Task
459
+ For each standard [ID], answer YES or NO.
460
+ - YES = can detect with a single-line regex or multi-line regex on source code
461
+ - NO = too complex, needs AST analysis, or already covered
462
+
463
+ Output format (one per line):
464
+ [ID] YES regex "reason"
465
+ [ID] YES regex-multiline "reason"
466
+ [ID] NO "reason"
467
+
468
+ Rules:
469
+ - Only answer YES if a simple regex pattern can catch violations
470
+ - Use "regex" for single-line patterns, "regex-multiline" for multi-line
471
+ - Keep reasons short (under 20 words)`;
472
+ console.log(`[ruleGen] step1 prompt: ${prompt.length} chars`);
473
+ return prompt;
474
+ }
475
+ function findItemById(itemId, items) {
476
+ // Direct match by ruleId
477
+ const byRuleId = items.find((i) => i.ruleId === itemId);
478
+ if (byRuleId)
479
+ return byRuleId;
480
+ // Match by section containing the ID
481
+ const bySection = items.find((i) => i.section.includes(itemId));
482
+ if (bySection)
483
+ return bySection;
484
+ // ITEM-N index-based match (fallback numbering from prompt)
485
+ const indexMatch = itemId.match(/^ITEM-(\d+)$/);
486
+ if (indexMatch) {
487
+ const idx = parseInt(indexMatch[1]) - 1;
488
+ if (idx >= 0 && idx < items.length)
489
+ return items[idx];
490
+ }
491
+ return undefined;
492
+ }
493
+ function parseStep1Results(response, items) {
494
+ const candidates = [];
495
+ const rejected = [];
496
+ for (const line of response.split("\n")) {
497
+ // Match: [ID] YES regex "reason" or [ID] YES regex-multiline "reason"
498
+ const yesMatch = line.match(/\[([^\]]+)\]\s*YES\s+(regex(?:-multiline)?|ast-filtered-regex)\s+"?([^"]*)"?/i);
499
+ if (yesMatch) {
500
+ const itemId = yesMatch[1];
501
+ const patternType = yesMatch[2].toLowerCase();
502
+ const reason = yesMatch[3].trim();
503
+ const item = findItemById(itemId, items);
504
+ if (item) {
505
+ candidates.push({ item, patternType, reason });
506
+ }
507
+ continue;
508
+ }
509
+ // Match: [ID] NO "reason"
510
+ const noMatch = line.match(/\[([^\]]+)\]\s*NO\s+"?([^"]*)"?/i);
511
+ if (noMatch) {
512
+ const itemId = noMatch[1];
513
+ const reason = noMatch[2].trim();
514
+ const item = findItemById(itemId, items);
515
+ if (item) {
516
+ rejected.push({ item, reason: reason || "regex로 검출 불가" });
517
+ }
518
+ }
519
+ }
520
+ return { candidates, rejected };
521
+ }
522
+ /**
523
+ * Step 2: 선별된 항목 1개에 대해 regex 규칙 YAML 생성
524
+ */
525
+ function buildStep2Prompt(candidate, category, ruleNumber) {
526
+ const patternExample = candidate.patternType === "regex-multiline"
527
+ ? ` type: "regex-multiline"\n regex: "\\\\bSELECT\\\\s+DISTINCT\\\\b[\\\\s\\\\S]*?\\\\bGROUP\\\\s+BY\\\\b"\n flags: "i"`
528
+ : candidate.patternType === "ast-filtered-regex"
529
+ ? ` type: "ast-filtered-regex"\n regex: "System\\\\.(out|err)\\\\.(print|println)"`
530
+ : ` type: "regex"\n regex: "System\\\\.(out|err)\\\\.(print|println)"`;
531
+ const prompt = `Generate one APEX YAML rule.
532
+
533
+ ## Standard
534
+ ${candidate.item.section}
535
+ ${candidate.item.content.slice(0, 400)}
536
+
537
+ ## Why regex works
538
+ ${candidate.reason}
539
+
540
+ ## Output exactly this YAML format (nothing else):
541
+ \`\`\`yaml
542
+ id: "custom-${category}-${String(ruleNumber).padStart(3, "0")}"
543
+ name: "규칙 이름"
544
+ severity: "low|medium|high|critical"
545
+ category: "${category}"
546
+ description: "설명"
547
+ enabled: true
548
+ pattern:
549
+ ${patternExample}
550
+ \`\`\`
551
+
552
+ Rules:
553
+ - id MUST start with "custom-"
554
+ - severity: low, medium, high, or critical
555
+ - regex must be valid (double-escape backslashes)
556
+ - Output ONLY the yaml block, nothing else`;
557
+ console.log(`[ruleGen] step2 prompt: ${prompt.length} chars (${candidate.item.ruleId || candidate.item.section})`);
558
+ return prompt;
559
+ }
560
+ function parseStep2Result(response) {
561
+ const yamlMatch = response.match(/```yaml\s*\n([\s\S]*?)```/);
562
+ if (yamlMatch)
563
+ return yamlMatch[1].trim();
564
+ // Fallback: try parsing the whole response as YAML if it starts with id:
565
+ const trimmed = response.trim();
566
+ if (trimmed.startsWith("id:") || trimmed.startsWith("- id:")) {
567
+ return trimmed.startsWith("- ") ? trimmed.slice(2) : trimmed;
568
+ }
569
+ return null;
570
+ }
571
+ /**
572
+ * Ollama 2단계 전략 실행
573
+ */
574
+ async function processTwoStep(client, standardItems, existingRules, groups) {
575
+ const allYamlRules = [];
576
+ const allManual = [];
577
+ const allMatched = [];
578
+ let errorCount = 0;
579
+ const existingRuleIds = existingRules.map((r) => r.id);
580
+ let globalRuleNumber = 1;
581
+ for (const [category, items] of groups) {
582
+ console.log(`[ruleGen] step1: category="${category}", ${items.length} items`);
583
+ // ── Step 1: 후보 선별 ──
584
+ // 배치 분할 (한 번에 최대 1500자)
585
+ const step1Batches = [];
586
+ let batch = [];
587
+ let batchLen = 0;
588
+ for (const item of items) {
589
+ const len = item.content.length + (item.section?.length || 0) + 50;
590
+ if (batchLen + len > 1500 && batch.length > 0) {
591
+ step1Batches.push(batch);
592
+ batch = [];
593
+ batchLen = 0;
594
+ }
595
+ batch.push(item);
596
+ batchLen += len;
597
+ }
598
+ if (batch.length > 0)
599
+ step1Batches.push(batch);
600
+ const allCandidates = [];
601
+ for (const batchItems of step1Batches) {
602
+ try {
603
+ const prompt = buildStep1Prompt(batchItems, existingRuleIds);
604
+ const response = await client.chat([{ role: "user", content: prompt }]);
605
+ const { candidates, rejected } = parseStep1Results(response.content, batchItems);
606
+ allCandidates.push(...candidates);
607
+ // rejected → manual
608
+ for (const r of rejected) {
609
+ allManual.push({
610
+ classification: "manual",
611
+ reason: r.reason,
612
+ standardItem: r.item,
613
+ });
614
+ }
615
+ // 응답에 포함되지 않은 항목도 manual 처리
616
+ const handledIds = new Set([
617
+ ...candidates.map((c) => c.item.ruleId || c.item.section),
618
+ ...rejected.map((r) => r.item.ruleId || r.item.section),
619
+ ]);
620
+ for (const item of batchItems) {
621
+ const itemId = item.ruleId || item.section;
622
+ if (!handledIds.has(itemId)) {
623
+ allManual.push({
624
+ classification: "manual",
625
+ reason: "Step 1 분류 누락",
626
+ standardItem: item,
627
+ });
628
+ }
629
+ }
630
+ }
631
+ catch (e) {
632
+ errorCount++;
633
+ for (const item of batchItems) {
634
+ allManual.push({
635
+ classification: "manual",
636
+ reason: `Step 1 오류: ${e}`,
637
+ standardItem: item,
638
+ });
639
+ }
640
+ }
641
+ }
642
+ console.log(`[ruleGen] step1 result: ${allCandidates.length} candidates selected`);
643
+ // ── Step 2: 후보별 regex 생성 ──
644
+ for (const candidate of allCandidates) {
645
+ try {
646
+ const prompt = buildStep2Prompt(candidate, category, globalRuleNumber);
647
+ const response = await client.chat([{ role: "user", content: prompt }]);
648
+ const ruleYaml = parseStep2Result(response.content);
649
+ if (ruleYaml) {
650
+ const validation = validateRuleYaml(ruleYaml);
651
+ if (validation.valid && validation.parsed) {
652
+ allYamlRules.push({ yaml: ruleYaml, parsed: validation.parsed });
653
+ globalRuleNumber++;
654
+ console.log(`[ruleGen] step2: ✓ ${validation.parsed.id}`);
655
+ }
656
+ else {
657
+ console.log(`[ruleGen] step2: ✗ validation failed: ${validation.error}`);
658
+ errorCount++;
659
+ // 검증 실패 → manual로 fallback
660
+ allManual.push({
661
+ classification: "manual",
662
+ reason: `YAML 검증 실패: ${validation.error}`,
663
+ standardItem: candidate.item,
664
+ });
665
+ }
666
+ }
667
+ else {
668
+ errorCount++;
669
+ allManual.push({
670
+ classification: "manual",
671
+ reason: "Step 2 YAML 파싱 실패",
672
+ standardItem: candidate.item,
673
+ });
674
+ }
675
+ }
676
+ catch (e) {
677
+ errorCount++;
678
+ allManual.push({
679
+ classification: "manual",
680
+ reason: `Step 2 오류: ${e}`,
681
+ standardItem: candidate.item,
682
+ });
683
+ }
684
+ }
685
+ }
686
+ return { yamlRules: allYamlRules, manual: allManual, matched: allMatched, errors: errorCount };
687
+ }
688
+ // ─── One-Step Parsing (Anthropic) ───
689
+ function parseClassificationResults(response, items) {
690
+ const results = [];
691
+ // Split by ITEM: markers
692
+ const itemBlocks = response.split(/\nITEM:\s*/);
693
+ for (const block of itemBlocks) {
694
+ if (!block.trim())
695
+ continue;
696
+ // Extract item ID
697
+ const idMatch = block.match(/^\[([^\]]+)\]/);
698
+ if (!idMatch)
699
+ continue;
700
+ const itemId = idMatch[1];
701
+ // Find corresponding StandardItem (use findItemById to avoid partial matching)
702
+ const item = findItemById(itemId, items);
703
+ if (!item)
704
+ continue;
705
+ // Extract classification
706
+ const classMatch = block.match(/CLASSIFICATION:\s*(yaml|manual|matched)/i);
707
+ if (!classMatch)
708
+ continue;
709
+ const classification = classMatch[1].toLowerCase();
710
+ if (classification === "yaml") {
711
+ const yamlMatch = block.match(/```yaml\s*\n([\s\S]*?)```/);
712
+ if (yamlMatch) {
713
+ results.push({ classification, ruleYaml: yamlMatch[1].trim(), standardItem: item });
714
+ }
715
+ }
716
+ else if (classification === "manual") {
717
+ const reasonMatch = block.match(/REASON:\s*(.+)/i);
718
+ results.push({
719
+ classification,
720
+ reason: reasonMatch ? reasonMatch[1].trim() : "복잡한 로직 필요",
721
+ standardItem: item,
722
+ });
723
+ }
724
+ else if (classification === "matched") {
725
+ const matchedMatch = block.match(/MATCHED_RULE:\s*(.+)/i);
726
+ results.push({
727
+ classification,
728
+ matchedRule: matchedMatch ? matchedMatch[1].trim() : "unknown",
729
+ standardItem: item,
730
+ });
731
+ }
732
+ }
733
+ return results;
734
+ }
735
+ /**
736
+ * Fallback parser for single-item batches where LLM omits ITEM: prefix.
737
+ * Tries to extract classification directly from the response.
738
+ */
739
+ function parseSingleItemFallback(response, item) {
740
+ const classMatch = response.match(/CLASSIFICATION:\s*(yaml|manual|matched)/i);
741
+ if (!classMatch) {
742
+ // Even without CLASSIFICATION: marker, check if there's a YAML block
743
+ const yamlMatch = response.match(/```yaml\s*\n([\s\S]*?)```/);
744
+ if (yamlMatch) {
745
+ return [{ classification: "yaml", ruleYaml: yamlMatch[1].trim(), standardItem: item }];
746
+ }
747
+ // Check for REASON: (manual classification)
748
+ const reasonMatch = response.match(/REASON:\s*(.+)/i);
749
+ if (reasonMatch) {
750
+ return [{ classification: "manual", reason: reasonMatch[1].trim(), standardItem: item }];
751
+ }
752
+ return [];
753
+ }
754
+ const classification = classMatch[1].toLowerCase();
755
+ if (classification === "yaml") {
756
+ const yamlMatch = response.match(/```yaml\s*\n([\s\S]*?)```/);
757
+ if (yamlMatch) {
758
+ return [{ classification, ruleYaml: yamlMatch[1].trim(), standardItem: item }];
759
+ }
760
+ }
761
+ else if (classification === "manual") {
762
+ const reasonMatch = response.match(/REASON:\s*(.+)/i);
763
+ return [{
764
+ classification,
765
+ reason: reasonMatch ? reasonMatch[1].trim() : "복잡한 로직 필요",
766
+ standardItem: item,
767
+ }];
768
+ }
769
+ else if (classification === "matched") {
770
+ const matchedMatch = response.match(/MATCHED_RULE:\s*(.+)/i);
771
+ return [{
772
+ classification,
773
+ matchedRule: matchedMatch ? matchedMatch[1].trim() : "unknown",
774
+ standardItem: item,
775
+ }];
776
+ }
777
+ return [];
778
+ }
779
+ // ─── Output Generation ───
780
+ function generateCustomYaml(rules) {
781
+ const javaRules = [];
782
+ const sqlRules = [];
783
+ const otherRules = [];
784
+ for (const r of rules) {
785
+ const patternType = r.parsed.pattern?.type;
786
+ if (patternType?.startsWith("ast-sql-")) {
787
+ sqlRules.push(r.parsed);
788
+ }
789
+ else if (["regex", "regex-multiline"].includes(patternType) && !r.parsed.id?.includes("sql")) {
790
+ javaRules.push(r.parsed);
791
+ }
792
+ else {
793
+ javaRules.push(r.parsed);
794
+ }
795
+ }
796
+ const ruleset = {
797
+ version: "1.0",
798
+ profile: "custom",
799
+ languages: [],
800
+ };
801
+ if (javaRules.length > 0 || otherRules.length > 0) {
802
+ ruleset.languages.push({
803
+ language: "java",
804
+ rules: [...javaRules, ...otherRules],
805
+ });
806
+ }
807
+ if (sqlRules.length > 0) {
808
+ ruleset.languages.push({
809
+ language: "sql",
810
+ rules: sqlRules,
811
+ });
812
+ }
813
+ return yaml.dump(ruleset, { lineWidth: 120, noRefs: true, sortKeys: false });
814
+ }
815
+ function generateManualRulesMd(manualItems) {
816
+ // Filter out noise items before generating output
817
+ const filtered = manualItems.filter((item) => {
818
+ const content = item.standardItem.content;
819
+ // TOC lines only → exclude
820
+ if (/^[\d.]+\s+.*·{5,}/.test(content))
821
+ return false;
822
+ // Placeholder → exclude
823
+ if (content.includes("[Edit this file"))
824
+ return false;
825
+ // Too short (less than 50 chars of actual text) → exclude
826
+ if (content.replace(/[\s\n]/g, "").length < 50)
827
+ return false;
828
+ // Metadata-only reason → exclude
829
+ if (item.reason?.includes('N/A (metadata)'))
830
+ return false;
831
+ return true;
832
+ });
833
+ let md = "# 수동 규칙 구현 필요 목록\n\n";
834
+ md += "이 항목들은 YAML 패턴으로 표현할 수 없어 Go 코드 수정이 필요합니다.\n\n";
835
+ for (const item of filtered) {
836
+ const id = item.standardItem.ruleId || item.standardItem.section;
837
+ md += `## ${id}\n`;
838
+ md += `- **섹션**: ${item.standardItem.section}\n`;
839
+ md += `- **사유**: ${item.reason || "복잡한 로직 필요"}\n`;
840
+ md += `- **내용**: ${item.standardItem.content.slice(0, 200)}\n\n`;
841
+ }
842
+ return md;
843
+ }
844
+ function generateMatchedMd(matchedItems) {
845
+ let md = "# 기존 규칙 매핑 목록\n\n";
846
+ md += "이 항목들은 기존 apex 규칙으로 이미 검출됩니다.\n\n";
847
+ md += "| 표준 항목 | 매핑된 규칙 ID |\n";
848
+ md += "|-----------|---------------|\n";
849
+ for (const item of matchedItems) {
850
+ const id = item.standardItem.ruleId || item.standardItem.section;
851
+ md += `| ${id} | ${item.matchedRule || "?"} |\n`;
852
+ }
853
+ return md;
854
+ }
855
+ // ─── Main Tool ───
856
+ const generateApexRulesTool = {
857
+ name: "generate_apex_rules",
858
+ description: "개발표준 마크다운에서 APEX 커스텀 규칙 YAML을 자동 생성합니다. Use when user asks: '규칙 생성', 'generate rules', '커스텀 규칙', 'custom rule', '표준 → 규칙'.",
859
+ parameters: {
860
+ type: "object",
861
+ required: ["standards_dir", "schema_path", "existing_rulesets_dir"],
862
+ properties: {
863
+ standards_dir: {
864
+ type: "string",
865
+ description: "표준 문서 마크다운 디렉토리 (예: .activo/standards)",
866
+ },
867
+ schema_path: {
868
+ type: "string",
869
+ description: "apex rule-schema.yaml 경로",
870
+ },
871
+ existing_rulesets_dir: {
872
+ type: "string",
873
+ description: "apex configs/rulesets/ 디렉토리 경로",
874
+ },
875
+ output_dir: {
876
+ type: "string",
877
+ description: "출력 디렉토리 (기본: .activo/generated-rules)",
878
+ },
879
+ },
880
+ },
881
+ handler: async (args) => {
882
+ try {
883
+ const standardsDir = args.standards_dir;
884
+ const schemaPath = args.schema_path;
885
+ const existingRulesetsDir = args.existing_rulesets_dir;
886
+ const outputDir = args.output_dir || ".activo/generated-rules";
887
+ // Validate inputs
888
+ if (!fs.existsSync(standardsDir)) {
889
+ return { success: false, content: "", error: `표준 디렉토리 없음: ${standardsDir}` };
890
+ }
891
+ if (!fs.existsSync(schemaPath)) {
892
+ return { success: false, content: "", error: `스키마 파일 없음: ${schemaPath}` };
893
+ }
894
+ if (!fs.existsSync(existingRulesetsDir)) {
895
+ return { success: false, content: "", error: `규칙셋 디렉토리 없음: ${existingRulesetsDir}` };
896
+ }
897
+ // 0. Create LLM client and determine context profile
898
+ const client = createLLMClient();
899
+ const profile = getContextProfile(client.getProvider());
900
+ const profileName = profile === CONTEXT_PROFILES.compact ? "compact" : "full";
901
+ console.log(`[ruleGen] provider: ${client.getProvider()}, context profile: ${profileName}, schema filter: ${profile.schemaFilterTypes?.join(",") || "all"}`);
902
+ // 1. Load schema (filtered by profile)
903
+ const patternTypes = loadRuleSchema(schemaPath, profile.schemaFilterTypes ?? undefined);
904
+ console.log(`[ruleGen] loaded ${patternTypes.length} pattern types`);
905
+ // 2. Load existing rules
906
+ const existingRules = loadExistingRules(existingRulesetsDir);
907
+ console.log(`[ruleGen] loaded ${existingRules.length} existing rules`);
908
+ // 3. Load standards
909
+ const standardItems = loadStandardItems(standardsDir);
910
+ if (standardItems.length === 0) {
911
+ return { success: false, content: "", error: `표준 항목이 없습니다: ${standardsDir}` };
912
+ }
913
+ // 4. Group by category
914
+ const groups = groupByCategory(standardItems);
915
+ // 5. Process — strategy branching
916
+ let allYamlRules;
917
+ let allManual;
918
+ let allMatched;
919
+ let errorCount;
920
+ if (profile.strategy === "two-step") {
921
+ // ── Ollama: 2단계 (선별 → 생성) ──
922
+ console.log(`[ruleGen] using two-step strategy`);
923
+ const result = await processTwoStep(client, standardItems, existingRules, groups);
924
+ allYamlRules = result.yamlRules;
925
+ allManual = result.manual;
926
+ allMatched = result.matched;
927
+ errorCount = result.errors;
928
+ }
929
+ else {
930
+ // ── Anthropic: 1단계 (분류+생성 동시) ──
931
+ console.log(`[ruleGen] using one-step strategy`);
932
+ allYamlRules = [];
933
+ allManual = [];
934
+ allMatched = [];
935
+ errorCount = 0;
936
+ const usedIds = new Set();
937
+ for (const [category, items] of groups) {
938
+ const selectedTypes = selectPatternTypes(category, patternTypes);
939
+ // Split into batches (profile-aware max chars)
940
+ const batches = [];
941
+ let currentBatch = [];
942
+ let currentLength = 0;
943
+ for (const item of items) {
944
+ // Use truncated length (what actually appears in the prompt) for budget
945
+ const itemLength = Math.min(item.content.length, profile.maxItemContentChars) + (item.section?.length || 0) + 50;
946
+ if (currentLength + itemLength > profile.maxItemsChars && currentBatch.length > 0) {
947
+ batches.push(currentBatch);
948
+ currentBatch = [];
949
+ currentLength = 0;
950
+ }
951
+ currentBatch.push(item);
952
+ currentLength += itemLength;
953
+ }
954
+ if (currentBatch.length > 0)
955
+ batches.push(currentBatch);
956
+ console.log(`[ruleGen] category="${category}": ${items.length} items → ${batches.length} batches`);
957
+ for (const batch of batches) {
958
+ try {
959
+ const prompt = buildBatchPrompt(batch, selectedTypes, existingRules, profile);
960
+ const response = await client.chat([{ role: "user", content: prompt }]);
961
+ let results = parseClassificationResults(response.content, batch);
962
+ // Fallback: when batch has 1 item and parser found nothing,
963
+ // the LLM may have responded without ITEM: prefix
964
+ if (results.length === 0 && batch.length === 1) {
965
+ results = parseSingleItemFallback(response.content, batch[0]);
966
+ }
967
+ for (const result of results) {
968
+ if (result.classification === "yaml" && result.ruleYaml) {
969
+ const yamlRules = extractIndividualRules(result.ruleYaml);
970
+ for (let ruleYaml of yamlRules) {
971
+ const validation = validateRuleYaml(ruleYaml);
972
+ if (validation.valid && validation.parsed) {
973
+ // Dedup IDs
974
+ const ruleId = validation.parsed.id;
975
+ if (usedIds.has(ruleId)) {
976
+ const newId = generateUniqueId(ruleId, usedIds);
977
+ validation.parsed.id = newId;
978
+ ruleYaml = ruleYaml.replace(`id: "${ruleId}"`, `id: "${newId}"`);
979
+ console.log(`[ruleGen] dedup: ${ruleId} → ${newId}`);
980
+ }
981
+ usedIds.add(validation.parsed.id);
982
+ allYamlRules.push({ yaml: ruleYaml, parsed: validation.parsed });
983
+ }
984
+ else {
985
+ errorCount++;
986
+ }
987
+ }
988
+ }
989
+ else if (result.classification === "manual") {
990
+ allManual.push(result);
991
+ }
992
+ else if (result.classification === "matched") {
993
+ allMatched.push(result);
994
+ }
995
+ }
996
+ const classifiedIds = new Set(results.map((r) => r.standardItem.ruleId || r.standardItem.section));
997
+ for (const item of batch) {
998
+ const itemId = item.ruleId || item.section;
999
+ if (!classifiedIds.has(itemId)) {
1000
+ allManual.push({
1001
+ classification: "manual",
1002
+ reason: "LLM 분류 결과 누락",
1003
+ standardItem: item,
1004
+ });
1005
+ }
1006
+ }
1007
+ }
1008
+ catch (e) {
1009
+ errorCount++;
1010
+ for (const item of batch) {
1011
+ allManual.push({
1012
+ classification: "manual",
1013
+ reason: `LLM 호출 오류: ${e}`,
1014
+ standardItem: item,
1015
+ });
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+ // 6. Generate output files
1022
+ fs.mkdirSync(outputDir, { recursive: true });
1023
+ // custom.yaml
1024
+ if (allYamlRules.length > 0) {
1025
+ const customYaml = generateCustomYaml(allYamlRules);
1026
+ fs.writeFileSync(path.join(outputDir, "custom.yaml"), customYaml, "utf-8");
1027
+ }
1028
+ // manual_rules.md
1029
+ if (allManual.length > 0) {
1030
+ const manualMd = generateManualRulesMd(allManual);
1031
+ fs.writeFileSync(path.join(outputDir, "manual_rules.md"), manualMd, "utf-8");
1032
+ }
1033
+ // matched.md
1034
+ if (allMatched.length > 0) {
1035
+ const matchedMd = generateMatchedMd(allMatched);
1036
+ fs.writeFileSync(path.join(outputDir, "matched.md"), matchedMd, "utf-8");
1037
+ }
1038
+ // Summary
1039
+ const summary = {
1040
+ totalItems: standardItems.length,
1041
+ yamlRules: allYamlRules.length,
1042
+ manualRules: allManual.length,
1043
+ matchedRules: allMatched.length,
1044
+ errors: errorCount,
1045
+ outputDir,
1046
+ };
1047
+ return {
1048
+ success: true,
1049
+ content: JSON.stringify(summary, null, 2),
1050
+ };
1051
+ }
1052
+ catch (error) {
1053
+ return { success: false, content: "", error: String(error) };
1054
+ }
1055
+ },
1056
+ };
1057
+ // Helper: extract individual rules from YAML that may be in array format
1058
+ function extractIndividualRules(yamlText) {
1059
+ const trimmed = yamlText.trim();
1060
+ // If starts with "- id:", it's an array format — split into individual rules
1061
+ if (trimmed.startsWith("- id:")) {
1062
+ const rules = [];
1063
+ const parts = trimmed.split(/\n(?=- id:)/);
1064
+ for (const part of parts) {
1065
+ // Remove leading "- " and adjust indentation
1066
+ const lines = part.split("\n");
1067
+ const adjusted = lines.map((line, i) => {
1068
+ if (i === 0)
1069
+ return line.replace(/^- /, "");
1070
+ return line.replace(/^ /, "");
1071
+ });
1072
+ rules.push(adjusted.join("\n"));
1073
+ }
1074
+ return rules;
1075
+ }
1076
+ // Single rule format
1077
+ return [trimmed];
1078
+ }
1079
+ // Helper: generate unique ID by incrementing suffix when collision detected
1080
+ function generateUniqueId(baseId, usedIds) {
1081
+ // Extract base and number: "custom-format-002" → ["custom-format-", "002"]
1082
+ const match = baseId.match(/^(.+-)(\d+)$/);
1083
+ if (!match) {
1084
+ // No numeric suffix — append -001
1085
+ let candidate = `${baseId}-001`;
1086
+ let n = 1;
1087
+ while (usedIds.has(candidate)) {
1088
+ n++;
1089
+ candidate = `${baseId}-${String(n).padStart(3, "0")}`;
1090
+ }
1091
+ return candidate;
1092
+ }
1093
+ const prefix = match[1];
1094
+ let num = parseInt(match[2]);
1095
+ let candidate = baseId;
1096
+ while (usedIds.has(candidate)) {
1097
+ num++;
1098
+ candidate = `${prefix}${String(num).padStart(3, "0")}`;
1099
+ }
1100
+ return candidate;
1101
+ }
1102
+ export const ruleGenTools = [generateApexRulesTool];
1103
+ //# sourceMappingURL=ruleGen.js.map