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.
- package/README.md +203 -1
- package/data/2026-03-04_20-54.json +181 -0
- package/data/2026-03-04_20-56.json +181 -0
- package/data/apex-rulesets/egov.yaml +469 -0
- package/data/apex-rulesets/modernize.yaml +687 -0
- package/data/apex-rulesets/quality.yaml +1677 -0
- package/data/apex-rulesets/rule-schema.yaml +587 -0
- package/data/apex-rulesets/secure.yaml +1688 -0
- package/data/apex-rulesets/spring.yaml +455 -0
- package/data/apex-rulesets/sql-format.yaml +99 -0
- package/data/apex-rulesets/sql-oracle.yaml +281 -0
- package/data/apex-rulesets/sql.yaml +1660 -0
- package/dist/cli/headless.d.ts.map +1 -1
- package/dist/cli/headless.js +32 -10
- package/dist/cli/headless.js.map +1 -1
- package/dist/cli/index.js +31 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/core/agent.d.ts +3 -3
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +255 -17
- package/dist/core/agent.js.map +1 -1
- package/dist/core/commands.d.ts +2 -1
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +61 -9
- package/dist/core/commands.js.map +1 -1
- package/dist/core/config.d.ts +14 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +41 -4
- package/dist/core/config.js.map +1 -1
- package/dist/core/conversation.d.ts +2 -2
- package/dist/core/conversation.d.ts.map +1 -1
- package/dist/core/conversation.js.map +1 -1
- package/dist/core/intentRouter.d.ts +43 -0
- package/dist/core/intentRouter.d.ts.map +1 -0
- package/dist/core/intentRouter.js +804 -0
- package/dist/core/intentRouter.js.map +1 -0
- package/dist/core/llm/anthropic.d.ts +24 -0
- package/dist/core/llm/anthropic.d.ts.map +1 -0
- package/dist/core/llm/anthropic.js +226 -0
- package/dist/core/llm/anthropic.js.map +1 -0
- package/dist/core/llm/ollama.d.ts +5 -14
- package/dist/core/llm/ollama.d.ts.map +1 -1
- package/dist/core/llm/ollama.js +3 -0
- package/dist/core/llm/ollama.js.map +1 -1
- package/dist/core/llm/types.d.ts +22 -0
- package/dist/core/llm/types.d.ts.map +1 -0
- package/dist/core/llm/types.js +2 -0
- package/dist/core/llm/types.js.map +1 -0
- package/dist/core/mcp/client.d.ts +6 -0
- package/dist/core/mcp/client.d.ts.map +1 -1
- package/dist/core/mcp/client.js +16 -0
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/init.d.ts +12 -0
- package/dist/core/mcp/init.d.ts.map +1 -0
- package/dist/core/mcp/init.js +55 -0
- package/dist/core/mcp/init.js.map +1 -0
- package/dist/core/mcp/logger.d.ts +14 -0
- package/dist/core/mcp/logger.d.ts.map +1 -0
- package/dist/core/mcp/logger.js +50 -0
- package/dist/core/mcp/logger.js.map +1 -0
- package/dist/core/tools/analyzeAll.d.ts.map +1 -1
- package/dist/core/tools/analyzeAll.js +16 -28
- package/dist/core/tools/analyzeAll.js.map +1 -1
- package/dist/core/tools/analyzePatterns.d.ts +3 -0
- package/dist/core/tools/analyzePatterns.d.ts.map +1 -0
- package/dist/core/tools/analyzePatterns.js +293 -0
- package/dist/core/tools/analyzePatterns.js.map +1 -0
- package/dist/core/tools/apexPaths.d.ts +14 -0
- package/dist/core/tools/apexPaths.d.ts.map +1 -0
- package/dist/core/tools/apexPaths.js +54 -0
- package/dist/core/tools/apexPaths.js.map +1 -0
- package/dist/core/tools/apexUtils.d.ts +36 -0
- package/dist/core/tools/apexUtils.d.ts.map +1 -0
- package/dist/core/tools/apexUtils.js +83 -0
- package/dist/core/tools/apexUtils.js.map +1 -0
- package/dist/core/tools/explainIssue.d.ts +3 -0
- package/dist/core/tools/explainIssue.d.ts.map +1 -0
- package/dist/core/tools/explainIssue.js +181 -0
- package/dist/core/tools/explainIssue.js.map +1 -0
- package/dist/core/tools/fixGen.d.ts +3 -0
- package/dist/core/tools/fixGen.d.ts.map +1 -0
- package/dist/core/tools/fixGen.js +338 -0
- package/dist/core/tools/fixGen.js.map +1 -0
- package/dist/core/tools/generateImprovements.d.ts +21 -0
- package/dist/core/tools/generateImprovements.d.ts.map +1 -0
- package/dist/core/tools/generateImprovements.js +602 -0
- package/dist/core/tools/generateImprovements.js.map +1 -0
- package/dist/core/tools/generateReport.d.ts +3 -0
- package/dist/core/tools/generateReport.d.ts.map +1 -0
- package/dist/core/tools/generateReport.js +315 -0
- package/dist/core/tools/generateReport.js.map +1 -0
- package/dist/core/tools/index.d.ts +7 -0
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +62 -23
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/javaAst.d.ts.map +1 -1
- package/dist/core/tools/javaAst.js +191 -0
- package/dist/core/tools/javaAst.js.map +1 -1
- package/dist/core/tools/recommendProfile.d.ts +3 -0
- package/dist/core/tools/recommendProfile.d.ts.map +1 -0
- package/dist/core/tools/recommendProfile.js +334 -0
- package/dist/core/tools/recommendProfile.js.map +1 -0
- package/dist/core/tools/ruleGen.d.ts +3 -0
- package/dist/core/tools/ruleGen.d.ts.map +1 -0
- package/dist/core/tools/ruleGen.js +1103 -0
- package/dist/core/tools/ruleGen.js.map +1 -0
- package/dist/core/tools/standards.d.ts.map +1 -1
- package/dist/core/tools/standards.js +7 -3
- package/dist/core/tools/standards.js.map +1 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/App.js +86 -35
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/InputBox.d.ts +1 -3
- package/dist/ui/components/InputBox.d.ts.map +1 -1
- package/dist/ui/components/InputBox.js +146 -5
- package/dist/ui/components/InputBox.js.map +1 -1
- package/dist/ui/components/MessageList.d.ts +3 -1
- package/dist/ui/components/MessageList.d.ts.map +1 -1
- package/dist/ui/components/MessageList.js +13 -7
- package/dist/ui/components/MessageList.js.map +1 -1
- package/dist/ui/components/StatusBar.d.ts +1 -1
- package/dist/ui/components/StatusBar.d.ts.map +1 -1
- package/dist/ui/components/StatusBar.js +3 -2
- package/dist/ui/components/StatusBar.js.map +1 -1
- package/dist/ui/components/ToolStatus.d.ts +3 -1
- package/dist/ui/components/ToolStatus.d.ts.map +1 -1
- package/dist/ui/components/ToolStatus.js +19 -4
- package/dist/ui/components/ToolStatus.js.map +1 -1
- package/package.json +7 -1
- package/demo.gif +0 -0
- package/demo.tape +0 -53
- package/screenshot.png +0 -0
- package/src/cli/banner.ts +0 -38
- package/src/cli/headless.ts +0 -63
- package/src/cli/index.ts +0 -57
- package/src/core/agent.ts +0 -237
- package/src/core/commands.ts +0 -118
- package/src/core/config.ts +0 -98
- package/src/core/conversation.ts +0 -235
- package/src/core/llm/ollama.ts +0 -351
- package/src/core/mcp/client.ts +0 -143
- package/src/core/tools/analyzeAll.ts +0 -494
- package/src/core/tools/ast.ts +0 -826
- package/src/core/tools/builtIn.ts +0 -221
- package/src/core/tools/cache.ts +0 -570
- package/src/core/tools/cssAnalysis.ts +0 -324
- package/src/core/tools/dependencyAnalysis.ts +0 -363
- package/src/core/tools/embeddings.ts +0 -746
- package/src/core/tools/frontendAst.ts +0 -802
- package/src/core/tools/htmlAnalysis.ts +0 -466
- package/src/core/tools/index.ts +0 -160
- package/src/core/tools/javaAst.ts +0 -812
- package/src/core/tools/memory.ts +0 -655
- package/src/core/tools/mybatisAnalysis.ts +0 -322
- package/src/core/tools/openapiAnalysis.ts +0 -431
- package/src/core/tools/pythonAnalysis.ts +0 -477
- package/src/core/tools/sqlAnalysis.ts +0 -298
- package/src/core/tools/standards.test.ts +0 -186
- package/src/core/tools/standards.ts +0 -889
- package/src/core/tools/types.ts +0 -38
- package/src/ui/App.tsx +0 -334
- package/src/ui/components/InputBox.tsx +0 -37
- package/src/ui/components/MessageList.tsx +0 -80
- package/src/ui/components/StatusBar.tsx +0 -36
- package/src/ui/components/ToolStatus.tsx +0 -38
- 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
|