fe be 연결점 분석기
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const XML_ITEM_TAGS = ['select', 'insert', 'update', 'delete'];
const JAVA_EXTENSIONS = new Set(['.java']);
const VUE_EXTENSIONS = new Set(['.vue', '.js', '.ts', '.jsx', '.tsx']);
function printUsage() {
console.log([
'Usage:',
' node vogen/fe2be.connectinfo.js [output-path]',
'',
'Output:',
' 화결연결분석out.txt'
].join('\n'));
}
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
}
function assertDirectory(dirPath, label) {
if (!dirPath) {
throw new Error(`${label} is required.`);
}
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
throw new Error(`${label} is not a directory: ${dirPath}`);
}
}
function walkFiles(rootDir, extensions) {
const files = [];
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (!['node_modules', '.git', 'dist', 'build', 'target'].includes(entry.name)) {
stack.push(fullPath);
}
} else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
files.push(fullPath);
}
}
}
return files.sort((a, b) => a.localeCompare(b));
}
function lineNumberAt(text, index) {
return text.slice(0, index).split(/\r?\n/).length;
}
function getAttribute(tagText, name) {
const pattern = new RegExp(`\\b${escapeRegExp(name)}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i');
const match = tagText.match(pattern);
return match ? (match[2] || match[3] || '') : '';
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripComments(text) {
return text.replace(//g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
}
function extractXmlStatements(xmlFiles) {
const statements = [];
for (const filePath of xmlFiles) {
const raw = readText(filePath);
const text = stripComments(raw);
const mapperTag = text.match(/]*>/i);
const namespace = mapperTag ? getAttribute(mapperTag[0], 'namespace') : '';
for (const tagName of XML_ITEM_TAGS) {
const pattern = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}>`, 'gi');
let match;
while ((match = pattern.exec(text)) !== null) {
const openTag = match[0].match(new RegExp(`^<${tagName}\\b[^>]*>`, 'i'))?.[0] || '';
const id = getAttribute(openTag, 'id');
if (!id) {
continue;
}
statements.push({
namespace,
id,
tagName,
filePath,
line: lineNumberAt(text, match.index),
key: namespace ? `${namespace}.${id}` : id
});
}
}
}
return statements;
}
function findBackendJavaRoot(mapperDir) {
const ancestors = [];
let current = path.resolve(mapperDir);
while (true) {
ancestors.push(current);
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
for (const ancestor of ancestors) {
const javaRoot = path.join(ancestor, 'src', 'main', 'java');
if (fs.existsSync(javaRoot) && fs.statSync(javaRoot).isDirectory()) {
return javaRoot;
}
}
const markerIndex = path.resolve(mapperDir).toLowerCase().lastIndexOf(`${path.sep}src${path.sep}main${path.sep}resources`);
if (markerIndex >= 0) {
const projectRoot = path.resolve(mapperDir).slice(0, markerIndex);
const javaRoot = path.join(projectRoot, 'src', 'main', 'java');
if (fs.existsSync(javaRoot)) {
return javaRoot;
}
}
return ancestors.find((ancestor) => walkFiles(ancestor, JAVA_EXTENSIONS).length > 0) || path.resolve(mapperDir);
}
function parseJavaFile(filePath) {
const text = readText(filePath);
const packageName = text.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || '';
const classMatch = text.match(/\b(?:class|interface)\s+([A-Za-z_$][\w$]*)\b/);
const className = classMatch?.[1] || path.basename(filePath, '.java');
const classPrefix = classMatch ? text.slice(Math.max(0, classMatch.index - 800), classMatch.index) : '';
return {
filePath,
text,
packageName,
className,
fqn: packageName ? `${packageName}.${className}` : className,
classMapping: extractMappingPath(classPrefix),
methods: extractJavaMethods(text)
};
}
function extractJavaMethods(text) {
const methods = [];
const pattern =
/((?:@\w+(?:\s*\([^)]*\))?\s*)*)(?:public|protected|private)?\s*(?:static\s+)?(?:final\s+)?[\w<>\[\], ?]+\s+([A-Za-z_$][\w$]*)\s*\([^;{}]*\)\s*(?:throws[^{]+)?\{/g;
let match;
while ((match = pattern.exec(text)) !== null) {
const openBraceIndex = pattern.lastIndex - 1;
const closeBraceIndex = findMatchingBrace(text, openBraceIndex);
if (closeBraceIndex < 0) {
continue;
}
methods.push({
annotations: match[1] || '',
name: match[2],
body: text.slice(openBraceIndex + 1, closeBraceIndex),
startLine: lineNumberAt(text, match.index),
mapping: extractMappingPath(match[1] || '')
});
pattern.lastIndex = closeBraceIndex + 1;
}
return methods;
}
function findMatchingBrace(text, openBraceIndex) {
let depth = 0;
let quote = '';
for (let index = openBraceIndex; index < text.length; index += 1) {
const char = text[index];
const prev = text[index - 1];
if (quote) {
if (char === quote && prev !== '\\') {
quote = '';
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return index;
}
}
}
return -1;
}
function extractMappingPath(annotationText) {
const mappingPattern = /@(RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)\s*(?:\(([\s\S]*?)\))?/g;
const paths = [];
let match;
while ((match = mappingPattern.exec(annotationText)) !== null) {
const args = match[2] || '';
const stringValues = [...args.matchAll(/"([^"]*)"/g)].map((item) => item[1]);
paths.push(...stringValues.filter((value) => value.startsWith('/')));
if (stringValues.length === 0 && match[1] !== 'RequestMapping') {
paths.push('');
}
}
return paths[0] || '';
}
function combineUrl(basePath, methodPath) {
const joined = `/${[basePath, methodPath]
.filter((part) => part !== undefined && part !== null && part !== '')
.map((part) => String(part).replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
.join('/')}`;
return joined === '/' ? '' : joined.replace(/\/+/g, '/');
}
function bodyCallsMethod(body, methodName) {
return new RegExp(`\\.${escapeRegExp(methodName)}\\s*\\(`).test(body);
}
function bodyMentionsStatement(body, statement) {
return (
new RegExp(`\\.${escapeRegExp(statement.id)}\\s*\\(`).test(body) ||
body.includes(statement.key) ||
body.includes(`${statement.namespace}.${statement.id}`)
);
}
function findDaoFile(javaFiles, statement) {
if (!statement.namespace) {
return null;
}
const simpleName = statement.namespace.split('.').pop();
return javaFiles.find((file) => file.fqn === statement.namespace || file.className === simpleName) || null;
}
function findServiceHits(javaFiles, statement) {
return javaFiles
.flatMap((file) =>
file.methods
.filter((method) => bodyMentionsStatement(method.body, statement))
.map((method) => ({ file, method }))
)
.filter((hit) => hit.file.className !== statement.namespace.split('.').pop());
}
function findControllerHits(javaFiles, serviceHits, statement) {
const serviceMethodNames = new Set(serviceHits.map((hit) => hit.method.name));
return javaFiles.flatMap((file) => {
const looksLikeController =
/Controller\b/.test(file.className) ||
/@(RestController|Controller)\b/.test(file.text) ||
file.methods.some((method) => method.mapping);
if (!looksLikeController) {
return [];
}
return file.methods
.filter((method) => {
const callsService = [...serviceMethodNames].some((name) => bodyCallsMethod(method.body, name));
return callsService || bodyMentionsStatement(method.body, statement);
})
.map((method) => ({
file,
method,
url: combineUrl(file.classMapping, method.mapping)
}));
});
}
function findVueHits(vueFiles, url) {
if (!url) {
return [];
}
const normalizedUrl = normalizeUrlForMatch(url);
if (!normalizedUrl) {
return [];
}
const hits = [];
for (const filePath of vueFiles) {
const text = readText(filePath);
const lines = text.split(/\r?\n/);
lines.forEach((line, index) => {
if (lineMatchesUrl(line, normalizedUrl)) {
hits.push({
filePath,
line: index + 1,
text: line.trim(),
handler: findNearbyClickHandler(lines, index) || findNearestJsFunction(lines, index)
});
}
});
}
return hits;
}
function lineMatchesUrl(line, normalizedUrl) {
const stringValues = [...String(line).matchAll(/["'`]([^"'`]*\/[^"'`]*)["'`]/g)].map((match) => normalizeUrlForMatch(match[1]));
return stringValues.some((value) => {
return value.includes(normalizedUrl) || normalizedUrl.includes(value) || urlPatternMatches(normalizedUrl, value);
});
}
function normalizeUrlForMatch(value) {
return String(value)
.replace(/\$\{[^}]+\}/g, '{}')
.replace(/\{[^}]+\}/g, '{}')
.replace(/:[A-Za-z_$][\w$]*/g, '{}')
.replace(/\/+/g, '/')
.trim();
}
function urlPatternMatches(patternUrl, actualUrl) {
const patternParts = patternUrl.split('/').filter(Boolean);
const actualParts = actualUrl.split('/').filter(Boolean);
if (patternParts.length > actualParts.length) {
return false;
}
return patternParts.every((part, index) => part === '{}' || part === actualParts[index]);
}
function findNearbyClickHandler(lines, lineIndex) {
const start = Math.max(0, lineIndex - 8);
const end = Math.min(lines.length - 1, lineIndex + 8);
for (let index = start; index <= end; index += 1) {
const match = lines[index].match(/@click(?:\.[\w.]+)?\s*=\s*"([^"]+)"/) || lines[index].match(/\bv-on:click\s*=\s*"([^"]+)"/);
if (match) {
return `@click="${match[1]}" line ${index + 1}`;
}
}
return '';
}
function findNearestJsFunction(lines, lineIndex) {
for (let index = lineIndex; index >= Math.max(0, lineIndex - 80); index -= 1) {
const match = lines[index].match(/\b(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/) || lines[index].match(/\b([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?\([^)]*\)\s*=>/);
if (match) {
return `${match[1]}() line ${index + 1}`;
}
}
return '';
}
function analyze(mapperDir, frontendDir) {
const xmlFiles = walkFiles(mapperDir, new Set(['.xml']));
const statements = extractXmlStatements(xmlFiles);
const javaRoot = findBackendJavaRoot(mapperDir);
const javaFiles = walkFiles(javaRoot, JAVA_EXTENSIONS).map(parseJavaFile);
const vueFiles = walkFiles(frontendDir, VUE_EXTENSIONS);
const chains = statements.map((statement) => {
const daoFile = findDaoFile(javaFiles, statement);
const serviceHits = findServiceHits(javaFiles, statement);
const controllerHits = findControllerHits(javaFiles, serviceHits, statement);
const vueHitsByUrl = controllerHits.map((controllerHit) => ({
controllerHit,
vueHits: findVueHits(vueFiles, controllerHit.url)
}));
return {
statement,
daoFile,
serviceHits,
controllerHits,
vueHitsByUrl
};
});
return {
mapperDir,
frontendDir,
javaRoot,
xmlFileCount: xmlFiles.length,
javaFileCount: javaFiles.length,
vueFileCount: vueFiles.length,
chains
};
}
function formatAnalysis(analysis) {
const lines = [
'# 화면-백엔드 연결 분석',
'',
`XML mapper path: ${analysis.mapperDir}`,
`Backend java path: ${analysis.javaRoot}`,
`Frontend vue path: ${analysis.frontendDir}`,
`XML files: ${analysis.xmlFileCount}`,
`Java files: ${analysis.javaFileCount}`,
`Vue/JS files: ${analysis.vueFileCount}`,
`Mapper statements: ${analysis.chains.length}`,
''
];
analysis.chains.forEach((chain, index) => {
const statement = chain.statement;
lines.push(`## ${index + 1}. ${statement.key}`);
lines.push(`XML: ${statement.filePath}:${statement.line} (${statement.tagName})`);
lines.push(`DAO: ${chain.daoFile ? `${chain.daoFile.fqn} (${chain.daoFile.filePath})` : 'NOT FOUND'}`);
lines.push('');
lines.push('Service:');
if (chain.serviceHits.length === 0) {
lines.push('- NOT FOUND');
} else {
for (const hit of chain.serviceHits) {
lines.push(`- ${hit.file.className}.${hit.method.name} (${hit.file.filePath}:${hit.method.startLine})`);
}
}
lines.push('');
lines.push('Controller / URL / Vue:');
if (chain.controllerHits.length === 0) {
lines.push('- NOT FOUND');
} else {
for (const { controllerHit, vueHits } of chain.vueHitsByUrl) {
const controller = controllerHit;
lines.push(`- ${controller.file.className}.${controller.method.name} (${controller.file.filePath}:${controller.method.startLine})`);
lines.push(` URL: ${controller.url || 'NO_MAPPING_PATH_FOUND'}`);
if (vueHits.length === 0) {
lines.push(' Vue: NOT FOUND');
} else {
for (const vueHit of vueHits) {
lines.push(` Vue: ${vueHit.filePath}:${vueHit.line}`);
if (vueHit.handler) {
lines.push(` 화면/버튼: ${vueHit.handler}`);
}
lines.push(` 호출문: ${vueHit.text}`);
}
}
}
}
lines.push('');
});
return `${lines.join('\n')}\n`;
}
function main() {
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
printUsage();
return;
}
const mapperDir = path.resolve(process.cwd(), args[0] || '');
const frontendDir = path.resolve(process.cwd(), args[1] || '');
const outputPath = path.resolve(process.cwd(), args[2] || '화결연결분석out.txt');
assertDirectory(mapperDir, 'backend mybatis xml dir');
assertDirectory(frontendDir, 'frontend vue dir');
const analysis = analyze(mapperDir, frontendDir);
fs.writeFileSync(outputPath, formatAnalysis(analysis), 'utf8');
console.log(`Generated ${outputPath}`);
console.log(`Mapper statements: ${analysis.chains.length}`);
console.log(`Backend java path: ${analysis.javaRoot}`);
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
댓글
댓글 쓰기