diff --git a/README.md b/README.md index 86ab2a4..bb7074a 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,29 @@ The plugin discovers supported source files, skips common dependency and build d ### Run from the CLI ### 2. Create Configuration File -Create a config file (`configs/*.mjs`) mapping source files and project-specific categorization. +Prefer a data-only JSON config for shared or untrusted analysis settings. + +```json +{ + "name": "spring-demo-analysis", + "srcDir": "/path/to/spring-project", + "outDir": "/path/to/output_dir", + "sourceFiles": [ + "src/main/java/**/*.java" + ], + "perfData": {}, + "roleRules": { + "Controller": "web_endpoint", + "Repository": "data_access" + } +} +``` + +`roleRules` assigns roles when a symbol name contains a key (case-insensitive). + +Executable JavaScript configs (`configs/*.mjs`) are still supported for trusted +local workflows that need custom `guessRole` logic. They run arbitrary local +code, so only use them from sources you trust. ```javascript // configs/spring_analysis.mjs @@ -160,7 +182,13 @@ export default { Execute Quarkify with your config file as the argument: ```bash -node quarkify.mjs configs/spring_analysis.mjs +node quarkify.mjs configs/spring_analysis.json +``` + +For trusted executable configs, opt in explicitly: + +```bash +node quarkify.mjs --allow-executable-config configs/spring_analysis.mjs ``` --- diff --git a/README_KR.md b/README_KR.md index aab9c3e..492c134 100644 --- a/README_KR.md +++ b/README_KR.md @@ -97,7 +97,29 @@ Quarkify는 대규모 상용 및 오픈소스 프로젝트에 적용하여 대 * [Node.js](https://nodejs.org/) v22.12.0 이상 ### 2. 설정 파일(Config) 작성 -분석할 타겟 프로젝트 경로와 소스 파일들을 설정하는 Config 파일(`configs/*.mjs`)을 작성합니다. +공유되거나 신뢰 여부가 불분명한 설정은 실행 코드가 없는 JSON config를 권장합니다. + +```json +{ + "name": "spring-demo-analysis", + "srcDir": "/path/to/spring-project", + "outDir": "/path/to/output_dir", + "sourceFiles": [ + "src/main/java/**/*.java" + ], + "perfData": {}, + "roleRules": { + "Controller": "web_endpoint", + "Repository": "data_access" + } +} +``` + +`roleRules`는 심볼 이름에 키가 포함될 때 역할을 지정합니다(대소문자 무시). + +커스텀 `guessRole` 로직이 필요한 신뢰된 로컬 workflow에서는 실행 가능한 +JavaScript config(`configs/*.mjs`)도 사용할 수 있습니다. 이 파일은 로컬 코드를 +실행할 수 있으므로 신뢰하는 출처의 설정에만 사용하세요. ```javascript // configs/spring_analysis.mjs @@ -125,7 +147,13 @@ export default { ### 3. 실행하기 ```bash -node quarkify.mjs configs/spring_analysis.mjs +node quarkify.mjs configs/spring_analysis.json +``` + +신뢰된 실행형 config는 명시적으로 허용합니다. + +```bash +node quarkify.mjs --allow-executable-config configs/spring_analysis.mjs ``` --- diff --git a/quarkify.mjs b/quarkify.mjs index 0322be8..a39e5d0 100644 --- a/quarkify.mjs +++ b/quarkify.mjs @@ -10,9 +10,8 @@ * SOURCE_FILES / PERF_DATA / role classifier)를 외부 config 파일로 분리. (Preserving all decomposition logic from v3.1, while separating project-specific information (SRC_DIR / OUTPUT_DIR / SOURCE_FILES / PERF_DATA / role classifier) into external config files.) * * 사용법 (Usage): - * node quarkify_v7.mjs configs/sovereign_cuda.mjs - * node quarkify_v7.mjs configs/sovereign_metal.mjs - * node quarkify_v7.mjs configs/llama_cpp_cuda.mjs + * node quarkify.mjs configs/project.json + * node quarkify.mjs --allow-executable-config configs/trusted_project.mjs * * Config 인터페이스 (Config Interface) (configs/*.mjs): * export default { @@ -39,10 +38,11 @@ import { pathToFileURL } from 'url'; import { execSync } from 'child_process'; // ─── CLI / 컨피그 로드 (Load CLI / Config) ─── -const configPath = process.argv[2]; +const cli = parseCliArgs(process.argv.slice(2)); +const configPath = cli.configPath; if (!configPath) { console.error('❌ 에러: 설정 파일 경로가 제공되지 않았습니다.'); - console.error('사용법: node quarkify.mjs '); + console.error('사용법: node quarkify.mjs [--allow-executable-config] '); process.exit(1); } if (!fs.existsSync(configPath)) { @@ -57,27 +57,80 @@ if (!fs.existsSync(cfgAbs)) { let CONFIG; try { - const imported = await import(pathToFileURL(cfgAbs).href); - if (!imported || !imported.default) { - console.error(`❌ 에러: 설정 파일에 'default export'가 정의되어 있지 않습니다: "${configPath}"`); - process.exit(1); - } - CONFIG = imported.default; + CONFIG = validateConfig(await loadConfig(cfgAbs, cli.allowExecutableConfig)); } catch (err) { console.error(`❌ 에러: 설정 파일을 불러오는 중 오류가 발생했습니다:`, err.message); process.exit(1); } -// 필수 속성 검증 (Required Property Validation) -const requiredFields = ['srcDir', 'outDir', 'sourceFiles']; -for (const field of requiredFields) { - if (CONFIG[field] === undefined || CONFIG[field] === null) { - console.error(`❌ 에러: 설정 파일에 필수 속성 '${field}'이(가) 누락되었습니다.`); +// ─── 유틸 (Utils) ─── +function parseCliArgs(args) { + let allowExecutableConfig = false; + const positional = []; + for (const arg of args) { + if (arg === '--allow-executable-config') { + allowExecutableConfig = true; + } else if (arg.startsWith('-')) { + console.error(`❌ 에러: 알 수 없는 옵션입니다: ${arg}`); + process.exit(1); + } else { + positional.push(arg); + } + } + if (positional.length > 1) { + console.error('❌ 에러: 설정 파일은 하나만 지정할 수 있습니다.'); process.exit(1); } + return { configPath: positional[0], allowExecutableConfig }; +} + +async function loadConfig(absPath, allowExecutableConfig) { + const ext = path.extname(absPath).toLowerCase(); + if (ext === '.json') { + const parsed = JSON.parse(fs.readFileSync(absPath, 'utf-8')); + return parsed; + } + if (['.mjs', '.js', '.cjs'].includes(ext)) { + if (!allowExecutableConfig) { + throw new Error( + 'Executable JavaScript configs can run arbitrary local code. ' + + 'Use a JSON config, or pass --allow-executable-config for trusted configs.' + ); + } + const imported = await import(pathToFileURL(absPath).href); + if (!imported || !imported.default) { + throw new Error(`설정 파일에 'default export'가 정의되어 있지 않습니다: "${absPath}"`); + } + return imported.default; + } + throw new Error(`Unsupported config extension: ${ext || '(none)'}`); +} + +function validateConfig(config) { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('Config must be an object.'); + } + for (const field of ['srcDir', 'outDir']) { + if (typeof config[field] !== 'string' || config[field].trim() === '') { + throw new Error(`Config field '${field}' must be a non-empty string.`); + } + } + if (!Array.isArray(config.sourceFiles) || config.sourceFiles.length === 0 || + config.sourceFiles.some((file) => typeof file !== 'string' || file.trim() === '')) { + throw new Error("Config field 'sourceFiles' must be an array of non-empty strings."); + } + if (config.perfData !== undefined && (!config.perfData || typeof config.perfData !== 'object' || Array.isArray(config.perfData))) { + throw new Error("Config field 'perfData' must be an object."); + } + if (config.roleRules !== undefined) { + if (!config.roleRules || typeof config.roleRules !== 'object' || Array.isArray(config.roleRules) || + Object.entries(config.roleRules).some(([fragment, role]) => !fragment.trim() || typeof role !== 'string' || !role.trim())) { + throw new Error("Config field 'roleRules' must map non-empty name fragments to role strings."); + } + } + return config; } -// ─── 유틸 (Utils) ─── function safeName(name) { if (!name) return '_anonymous_'; return name.replace(/[^a-zA-Z0-9_$.]/g, '_').substring(0, 100); @@ -113,7 +166,10 @@ function perfBand(pct) { return '95_max'; } -const guessRole = CONFIG.guessRole || ((_) => 'general'); +const roleRules = Object.entries(CONFIG.roleRules || {}).map(([fragment, role]) => [fragment.trim().toLowerCase(), role.trim()]); +const guessRole = typeof CONFIG.guessRole === 'function' + ? CONFIG.guessRole + : (name) => roleRules.find(([fragment]) => String(name).toLowerCase().includes(fragment))?.[1] || 'general'; const OUTPUT_MARKER = '.quarkify-output'; // ─── PTX arg 의미 분류 (PTX Argument Classification) ─── diff --git a/skills/analyze/scripts/analyze.mjs b/skills/analyze/scripts/analyze.mjs index 9ec8e39..ff54abb 100644 --- a/skills/analyze/scripts/analyze.mjs +++ b/skills/analyze/scripts/analyze.mjs @@ -98,16 +98,16 @@ export async function analyze(projectRoot = process.cwd()) { } const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'quarkify-plugin-')); - const configPath = path.join(temporaryDirectory, 'config.mjs'); + const configPath = path.join(temporaryDirectory, 'config.json'); const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const cliPath = path.join(pluginRoot, 'quarkify.mjs'); - const config = `export default ${JSON.stringify({ + const config = JSON.stringify({ name: safeProjectName(root), srcDir: root, outDir: outputDir, sourceFiles, perfData: {}, - }, null, 2)};\n`; + }, null, 2); try { await writeFile(configPath, config, { encoding: 'utf8', mode: 0o600 }); diff --git a/test/quarkify-cli.test.mjs b/test/quarkify-cli.test.mjs index c640455..30b0128 100644 --- a/test/quarkify-cli.test.mjs +++ b/test/quarkify-cli.test.mjs @@ -23,8 +23,8 @@ async function writeConfig(filePath, config) { await writeFile(filePath, `export default ${config};\n`, 'utf8'); } -function runQuarkify(configPath) { - return spawnSync(process.execPath, [cliPath, configPath], { +function runQuarkify(configPath, extraArgs = []) { + return spawnSync(process.execPath, [cliPath, ...extraArgs, configPath], { cwd: repoRoot, encoding: 'utf8', }); @@ -59,7 +59,7 @@ test('CLI materializes quark output, mirrors, axons, and guide artifacts', async guessRole() { return 'general'; }, }`); - const result = runQuarkify(configPath); + const result = runQuarkify(configPath, ['--allow-executable-config']); assert.equal(result.status, 0, result.stderr || result.stdout); assert.deepEqual(await readdir(path.join(outDir, 'quark')), ['file__sample.js']); @@ -86,11 +86,10 @@ test('generated HTML viewer does not load remote scripts by default', async () = perfData: {}, guessRole() { return 'general'; }, }`); - - const result = runQuarkify(configPath); + const result = runQuarkify(configPath, ['--allow-executable-config']); assert.equal(result.status, 0, result.stderr || result.stdout); - const html = await import('node:fs/promises').then(({ readFile }) => readFile(path.join(outDir, 'index.html'), 'utf8')); + const html = await readFile(path.join(outDir, 'index.html'), 'utf8'); assert.doesNotMatch(html, new RegExp(String.raw` { + await withTempWorkspace(async (tmp) => { + const srcDir = path.join(tmp, 'src'); + const outDir = path.join(tmp, 'out'); + const configPath = path.join(tmp, 'config.json'); + + await mkdir(srcDir, { recursive: true }); + await writeFile(path.join(srcDir, 'sample.js'), 'export function sampleThing() { return 1; }\n', 'utf8'); + await writeFile(configPath, JSON.stringify({ + name: 'json-config-test', + srcDir, + outDir, + sourceFiles: ['sample.js'], + perfData: {}, + roleRules: { sample: 'json_rule' }, + }), 'utf8'); + + const result = runQuarkify(configPath); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(existsSync(path.join(outDir, 'quark', 'file__sample.js'))); + assert.ok(existsSync(path.join(outDir, '_mirror', 'by_role', 'json_rule'))); + }); +}); + +test('JSON config is validated before analysis starts', async () => { + await withTempWorkspace(async (tmp) => { + const outDir = path.join(tmp, 'out'); + const configPath = path.join(tmp, 'config.json'); + + await writeFile(configPath, JSON.stringify({ + srcDir: tmp, + outDir, + sourceFiles: 'sample.js', + }), 'utf8'); + + const result = runQuarkify(configPath); + + assert.notEqual(result.status, 0); + assert.match(result.stderr || result.stdout, /sourceFiles.*array/i); + assert.ok(!existsSync(outDir)); + }); +}); + +test('executable config requires explicit permission flag', async () => { + await withTempWorkspace(async (tmp) => { + const srcDir = path.join(tmp, 'src'); + const outDir = path.join(tmp, 'out'); + const configPath = path.join(tmp, 'config.mjs'); + const executionMarker = path.join(tmp, 'config-executed'); + + await mkdir(srcDir, { recursive: true }); + await writeFile(path.join(srcDir, 'sample.js'), 'export function sampleThing() { return 1; }\n', 'utf8'); + await writeFile(configPath, ` + import { writeFileSync } from 'node:fs'; + writeFileSync(${JSON.stringify(executionMarker)}, 'executed'); + export default { + name: 'executable-config-gate', + srcDir: ${JSON.stringify(srcDir)}, + outDir: ${JSON.stringify(outDir)}, + sourceFiles: ['sample.js'], + perfData: {}, + guessRole() { return 'general'; }, + }; + `, 'utf8'); + + const result = runQuarkify(configPath); + + assert.notEqual(result.status, 0); + assert.match(result.stderr || result.stdout, /--allow-executable-config/); + assert.ok(!existsSync(executionMarker)); + }); +}); + test('leading double-star globs match root and nested files', async () => { await withTempWorkspace(async (tmp) => { const srcDir = path.join(tmp, 'src'); @@ -116,7 +189,7 @@ test('leading double-star globs match root and nested files', async () => { guessRole() { return 'general'; }, }`); - const result = runQuarkify(configPath); + const result = runQuarkify(configPath, ['--allow-executable-config']); assert.equal(result.status, 0, result.stderr || result.stdout); const fileFolders = await readdir(path.join(outDir, 'quark')); @@ -147,7 +220,7 @@ test('segment globs support nested double-star and single-star patterns', async guessRole() { return 'general'; }, }`); - const result = runQuarkify(configPath); + const result = runQuarkify(configPath, ['--allow-executable-config']); assert.equal(result.status, 0, result.stderr || result.stdout); const fileFolders = await readdir(path.join(outDir, 'quark')); @@ -174,7 +247,7 @@ test('outDir cannot be the same directory as srcDir', async () => { guessRole() { return 'general'; }, }`); - const result = runQuarkify(configPath); + const result = runQuarkify(configPath, ['--allow-executable-config']); assert.notEqual(result.status, 0); assert.match(result.stderr || result.stdout, /unsafe output directory/i); @@ -200,7 +273,7 @@ test('outDir must be empty or marked as Quarkify output', async () => { guessRole() { return 'general'; }, }`); - const result = runQuarkify(configPath); + const result = runQuarkify(configPath, ['--allow-executable-config']); assert.notEqual(result.status, 0); assert.match(result.stderr || result.stdout, /not marked/i); @@ -237,7 +310,7 @@ test('generated output redacts literals from fields, annotations, and returns', guessRole() { return 'general'; }, }`); - const result = runQuarkify(configPath); + const result = runQuarkify(configPath, ['--allow-executable-config']); assert.equal(result.status, 0, result.stderr || result.stdout); const entries = (await listRelativeEntries(outDir)).join('\n');