아이템파인더
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const MAPPER_ITEM_TAGS = ['select', 'insert', 'update', 'delete'];
const TARGET_COLUMN_KEYS = ['plainColumn', 'encColumn', 'hashColumn'];
function printUsage() {
console.log([
'Usage:',
' node index.js [output-md-path]',
'',
'Example:',
' node index.js "D:\\prj\\mybatis" "D:\\prj\\vsix2\\crypto-targets.json"',
'',
'Output:',
' xml파일별카운트.md'
].join('\n'));
}
function assertReadableDirectory(dirPath, label) {
if (!dirPath) {
throw new Error(`${label} is required.`);
}
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
throw new Error(`${label} is not a directory: ${dirPath}`);
}
}
function assertReadableFile(filePath, label) {
if (!filePath) {
throw new Error(`${label} is required.`);
}
const stat = fs.statSync(filePath);
if (!stat.isFile()) {
throw new Error(`${label} is not a file: ${filePath}`);
}
}
function walkXmlFiles(rootDir) {
const xmlFiles = [];
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()) {
stack.push(fullPath);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xml')) {
xmlFiles.push(fullPath);
}
}
}
return xmlFiles.sort((a, b) => a.localeCompare(b));
}
function stripXmlComments(xml) {
return xml.replace(//g, '');
}
function readTextFile(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
}
function getAttribute(tagText, name) {
const attrPattern = new RegExp(`\\b${escapeRegExp(name)}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i');
const match = tagText.match(attrPattern);
return match ? (match[2] || match[3] || '') : '';
}
function getNamespace(xml) {
const mapperMatch = xml.match(/]*>/i);
return mapperMatch ? getAttribute(mapperMatch[0], 'namespace') : '';
}
function extractTaggedBlocks(xml, tagName) {
const blocks = [];
const pattern = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}>`, 'gi');
let match;
while ((match = pattern.exec(xml)) !== null) {
const block = match[0];
const openTagMatch = block.match(new RegExp(`^<${tagName}\\b[^>]*>`, 'i'));
const closeTagPattern = new RegExp(`<\\/${tagName}>\\s*$`, 'i');
blocks.push({
raw: block,
openTag: openTagMatch ? openTagMatch[0] : '',
body: block
.replace(new RegExp(`^<${tagName}\\b[^>]*>`, 'i'), '')
.replace(closeTagPattern, '')
});
}
return blocks;
}
function loadMapperFile(filePath) {
const xml = stripXmlComments(readTextFile(filePath));
const namespace = getNamespace(xml);
const sqlFragments = [];
const items = [];
for (const block of extractTaggedBlocks(xml, 'sql')) {
const id = getAttribute(block.openTag, 'id');
if (!id) {
continue;
}
sqlFragments.push({
filePath,
namespace,
id,
body: block.body
});
}
for (const tagName of MAPPER_ITEM_TAGS) {
for (const block of extractTaggedBlocks(xml, tagName)) {
const id = getAttribute(block.openTag, 'id') || `${tagName}@${items.length + 1}`;
items.push({
filePath,
namespace,
tagName,
id,
body: block.body
});
}
}
return {
filePath,
namespace,
sqlFragments,
items
};
}
function buildSqlFragmentIndex(mapperFiles) {
const index = new Map();
for (const mapperFile of mapperFiles) {
for (const fragment of mapperFile.sqlFragments) {
const indexedFragment = {
namespace: fragment.namespace,
body: fragment.body
};
if (fragment.namespace) {
index.set(`${fragment.namespace}.${fragment.id}`, indexedFragment);
}
index.set(fragment.id, indexedFragment);
}
}
return index;
}
function resolveIncludeRefId(refId, currentNamespace, fragmentIndex) {
if (!refId) {
return null;
}
const trimmed = refId.trim();
if (currentNamespace && !trimmed.includes('.')) {
const namespaced = `${currentNamespace}.${trimmed}`;
if (fragmentIndex.has(namespaced)) {
return namespaced;
}
}
if (fragmentIndex.has(trimmed)) {
return trimmed;
}
return null;
}
function extractIncludeProperties(includeTag) {
const properties = {};
const pattern = /]*\/?>/gi;
let match;
while ((match = pattern.exec(includeTag)) !== null) {
const name = getAttribute(match[0], 'name');
const value = getAttribute(match[0], 'value');
if (name) {
properties[name] = value;
}
}
return properties;
}
function applyIncludeProperties(fragmentBody, properties) {
return Object.keys(properties).reduce((body, name) => {
const placeholder = new RegExp(`\\$\\{\\s*${escapeRegExp(name)}\\s*\\}`, 'g');
return body.replace(placeholder, properties[name]);
}, fragmentBody);
}
function expandIncludes(text, currentNamespace, fragmentIndex, includeStack) {
return text.replace(/]*\/>|]*>[\s\S]*?<\/include>/gi, (includeTag) => {
const refId = getAttribute(includeTag, 'refid');
const resolvedKey = resolveIncludeRefId(refId, currentNamespace, fragmentIndex);
if (!resolvedKey) {
return includeTag;
}
if (includeStack.includes(resolvedKey)) {
return '';
}
const fragment = fragmentIndex.get(resolvedKey);
const fragmentBody = applyIncludeProperties(fragment.body, extractIncludeProperties(includeTag));
return expandIncludes(
fragmentBody,
fragment.namespace || currentNamespace,
fragmentIndex,
includeStack.concat(resolvedKey)
);
});
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function makeBoundaryRegex(value) {
return new RegExp(`(^|[^A-Za-z0-9_])${escapeRegExp(value)}([^A-Za-z0-9_]|$)`, 'i');
}
function loadTargets(jsonPath) {
const parsed = JSON.parse(readTextFile(jsonPath));
const rows = Array.isArray(parsed) ? parsed : (parsed.targets || parsed.items || []);
if (!Array.isArray(rows)) {
throw new Error('Target JSON must be an array or contain a targets/items array.');
}
const targets = [];
for (const row of rows) {
if (!row || typeof row !== 'object') {
continue;
}
const tableName = typeof row.tableName === 'string' ? row.tableName.trim() : '';
const columns = TARGET_COLUMN_KEYS
.map((key) => typeof row[key] === 'string' ? row[key].trim() : '')
.filter(Boolean);
if (!tableName || columns.length === 0) {
continue;
}
targets.push({
tableName,
columns: Array.from(new Set(columns)),
tableRegex: makeBoundaryRegex(tableName),
columnRegexes: Array.from(new Set(columns)).map(makeBoundaryRegex)
});
}
if (targets.length === 0) {
throw new Error('No valid targets found. Each target needs tableName and at least one column value.');
}
return targets;
}
function itemMatchesAnyTarget(queryText, targets) {
return targets.some((target) => {
if (!target.tableRegex.test(queryText)) {
return false;
}
return target.columnRegexes.some((columnRegex) => columnRegex.test(queryText));
});
}
function countMatchesByXml(mapperFiles, fragmentIndex, targets) {
const counts = new Map();
for (const mapperFile of mapperFiles) {
const matchedItemKeys = new Set();
for (const item of mapperFile.items) {
const expandedBody = expandIncludes(item.body, item.namespace, fragmentIndex, []);
if (itemMatchesAnyTarget(expandedBody, targets)) {
matchedItemKeys.add(`${item.tagName}:${item.id}`);
}
}
if (matchedItemKeys.size > 0) {
counts.set(mapperFile.filePath, matchedItemKeys.size);
}
}
return counts;
}
function toMarkdown(counts, rootDir) {
const lines = [
'# xml파일별카운트',
'',
'| xml파일명 | 카운트 |',
'| --- | ---: |'
];
const rows = Array.from(counts.entries())
.sort(([fileA], [fileB]) => fileA.localeCompare(fileB))
.map(([filePath, count]) => {
const relativePath = path.relative(rootDir, filePath) || path.basename(filePath);
return `| ${relativePath.replace(/\\/g, '/')} | ${count} |`;
});
return lines.concat(rows).concat('').join('\n');
}
function main() {
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
printUsage();
return;
}
const rootDir = args[0] ? path.resolve(args[0]) : '';
const jsonPath = args[1] ? path.resolve(args[1]) : '';
const outputPath = args[2] ? path.resolve(args[2]) : path.resolve(process.cwd(), 'xml파일별카운트.md');
assertReadableDirectory(rootDir, 'xml-root-dir');
assertReadableFile(jsonPath, 'targets-json-path');
const targets = loadTargets(jsonPath);
const xmlFiles = walkXmlFiles(rootDir);
const mapperFiles = xmlFiles.map(loadMapperFile);
const fragmentIndex = buildSqlFragmentIndex(mapperFiles);
const counts = countMatchesByXml(mapperFiles, fragmentIndex, targets);
const markdown = toMarkdown(counts, rootDir);
fs.writeFileSync(outputPath, markdown, 'utf8');
console.log(`Scanned XML files: ${xmlFiles.length}`);
console.log(`Valid targets: ${targets.length}`);
console.log(`Matched XML files: ${counts.size}`);
console.log(`Output: ${outputPath}`);
}
try {
main();
} catch (error) {
console.error(`Error: ${error.message}`);
printUsage();
process.exit(1);
}
패키지json
{
"name": "itemfinder",
"version": "1.0.0",
"description": "Count MyBatis mapper items that reference target tables and columns.",
"main": "index.js",
"bin": {
"itemfinder": "./index.js"
},
"scripts": {
"start": "node index.js"
},
"engines": {
"node": ">=16 <17"
},
"license": "UNLICENSED"
}
댓글
댓글 쓰기