@triflux/core 10.0.0-alpha.1
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/hooks/agent-route-guard.mjs +109 -0
- package/hooks/cross-review-tracker.mjs +122 -0
- package/hooks/error-context.mjs +148 -0
- package/hooks/hook-manager.mjs +352 -0
- package/hooks/hook-orchestrator.mjs +312 -0
- package/hooks/hook-registry.json +213 -0
- package/hooks/hooks.json +89 -0
- package/hooks/keyword-rules.json +581 -0
- package/hooks/lib/resolve-root.mjs +59 -0
- package/hooks/mcp-config-watcher.mjs +85 -0
- package/hooks/pipeline-stop.mjs +76 -0
- package/hooks/safety-guard.mjs +106 -0
- package/hooks/subagent-verifier.mjs +80 -0
- package/hub/assign-callbacks.mjs +133 -0
- package/hub/bridge.mjs +799 -0
- package/hub/cli-adapter-base.mjs +192 -0
- package/hub/codex-adapter.mjs +190 -0
- package/hub/codex-compat.mjs +78 -0
- package/hub/codex-preflight.mjs +147 -0
- package/hub/delegator/contracts.mjs +37 -0
- package/hub/delegator/index.mjs +14 -0
- package/hub/delegator/schema/delegator-tools.schema.json +250 -0
- package/hub/delegator/service.mjs +307 -0
- package/hub/delegator/tool-definitions.mjs +35 -0
- package/hub/fullcycle.mjs +96 -0
- package/hub/gemini-adapter.mjs +179 -0
- package/hub/hitl.mjs +143 -0
- package/hub/intent.mjs +193 -0
- package/hub/lib/process-utils.mjs +361 -0
- package/hub/middleware/request-logger.mjs +81 -0
- package/hub/paths.mjs +30 -0
- package/hub/pipeline/gates/confidence.mjs +56 -0
- package/hub/pipeline/gates/consensus.mjs +94 -0
- package/hub/pipeline/gates/index.mjs +5 -0
- package/hub/pipeline/gates/selfcheck.mjs +82 -0
- package/hub/pipeline/index.mjs +318 -0
- package/hub/pipeline/state.mjs +191 -0
- package/hub/pipeline/transitions.mjs +124 -0
- package/hub/platform.mjs +225 -0
- package/hub/quality/deslop.mjs +253 -0
- package/hub/reflexion.mjs +372 -0
- package/hub/research.mjs +146 -0
- package/hub/router.mjs +791 -0
- package/hub/routing/complexity.mjs +166 -0
- package/hub/routing/index.mjs +117 -0
- package/hub/routing/q-learning.mjs +336 -0
- package/hub/session-fingerprint.mjs +352 -0
- package/hub/state.mjs +245 -0
- package/hub/team-bridge.mjs +25 -0
- package/hub/token-mode.mjs +224 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/hud/colors.mjs +88 -0
- package/hud/constants.mjs +81 -0
- package/hud/hud-qos-status.mjs +206 -0
- package/hud/providers/claude.mjs +309 -0
- package/hud/providers/codex.mjs +151 -0
- package/hud/providers/gemini.mjs +320 -0
- package/hud/renderers.mjs +424 -0
- package/hud/terminal.mjs +140 -0
- package/hud/utils.mjs +287 -0
- package/package.json +31 -0
- package/scripts/lib/claudemd-manager.mjs +325 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +51 -0
- package/scripts/lib/env-probe.mjs +241 -0
- package/scripts/lib/gemini-profiles.mjs +85 -0
- package/scripts/lib/hook-utils.mjs +14 -0
- package/scripts/lib/keyword-rules.mjs +166 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +739 -0
- package/scripts/lib/mcp-guard-engine.mjs +940 -0
- package/scripts/lib/mcp-manifest.mjs +79 -0
- package/scripts/lib/mcp-server-catalog.mjs +118 -0
- package/scripts/lib/psmux-info.mjs +119 -0
- package/scripts/lib/remote-spawn-transfer.mjs +196 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// hub/pipeline/transitions.mjs — 파이프라인 단계 전이 규칙
|
|
2
|
+
//
|
|
3
|
+
// plan → prd → confidence → exec → deslop → verify → selfcheck → complete/fix
|
|
4
|
+
// fix → exec/verify/complete/failed
|
|
5
|
+
// complete, failed = 터미널 상태
|
|
6
|
+
|
|
7
|
+
export const PHASES = [
|
|
8
|
+
'plan', 'prd', 'confidence', 'exec', 'deslop', 'verify', 'selfcheck',
|
|
9
|
+
'fix', 'complete', 'failed',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const TERMINAL = new Set(['complete', 'failed']);
|
|
13
|
+
|
|
14
|
+
export const ALLOWED = {
|
|
15
|
+
'plan': ['prd'],
|
|
16
|
+
'prd': ['confidence'],
|
|
17
|
+
'confidence': ['exec', 'failed'],
|
|
18
|
+
'exec': ['deslop'],
|
|
19
|
+
'deslop': ['verify'],
|
|
20
|
+
'verify': ['selfcheck', 'fix', 'failed'],
|
|
21
|
+
'selfcheck': ['complete', 'fix'],
|
|
22
|
+
'fix': ['exec', 'verify', 'complete', 'failed'],
|
|
23
|
+
'complete': [],
|
|
24
|
+
'failed': [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 전이 가능 여부 확인
|
|
29
|
+
* @param {string} from - 현재 단계
|
|
30
|
+
* @param {string} to - 다음 단계
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function canTransition(from, to) {
|
|
34
|
+
const targets = ALLOWED[from];
|
|
35
|
+
if (!targets) return false;
|
|
36
|
+
return targets.includes(to);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 상태 전이 실행 — fix loop 바운딩 포함
|
|
41
|
+
* @param {object} state - 파이프라인 상태 객체
|
|
42
|
+
* @param {string} nextPhase - 다음 단계
|
|
43
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function transitionPhase(state, nextPhase) {
|
|
46
|
+
const current = state.phase;
|
|
47
|
+
|
|
48
|
+
if (!canTransition(current, nextPhase)) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: `전이 불가: ${current} → ${nextPhase}. 허용: [${(ALLOWED[current] || []).join(', ')}]`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const next = { ...state, phase: nextPhase, updated_at: Date.now() };
|
|
56
|
+
|
|
57
|
+
// fix 단계 진입 시 attempt 증가 + 바운딩
|
|
58
|
+
if (nextPhase === 'fix') {
|
|
59
|
+
next.fix_attempt = (state.fix_attempt || 0) + 1;
|
|
60
|
+
if (next.fix_attempt > (state.fix_max || 3)) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `fix loop 초과: ${state.fix_max || 3}회 도달`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// fix → exec 재진입 시 (fix 후 재실행)
|
|
69
|
+
if (current === 'fix' && nextPhase === 'exec') {
|
|
70
|
+
// fix_attempt 유지 (이미 fix 진입 시 증가됨)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// verify → fix → ... → verify 반복 후 fix_max 초과 시 ralph loop
|
|
74
|
+
if (nextPhase === 'failed' && current === 'fix') {
|
|
75
|
+
// ralph loop 반복 증가
|
|
76
|
+
next.ralph_iteration = (state.ralph_iteration || 0) + 1;
|
|
77
|
+
if (next.ralph_iteration > (state.ralph_max || 10)) {
|
|
78
|
+
// 최종 실패 — ralph loop도 초과
|
|
79
|
+
next.phase = 'failed';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// phase_history 기록
|
|
84
|
+
const history = Array.isArray(state.phase_history) ? [...state.phase_history] : [];
|
|
85
|
+
history.push({ from: current, to: nextPhase, at: Date.now() });
|
|
86
|
+
next.phase_history = history;
|
|
87
|
+
|
|
88
|
+
return { ok: true, state: next };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* ralph loop 재시작 전이
|
|
93
|
+
* fix_max 초과 시 plan으로 돌아가며 ralph_iteration 증가
|
|
94
|
+
* @param {object} state - 현재 상태
|
|
95
|
+
* @returns {{ ok: boolean, state?: object, error?: string }}
|
|
96
|
+
*/
|
|
97
|
+
export function ralphRestart(state) {
|
|
98
|
+
if (TERMINAL.has(state.phase)) {
|
|
99
|
+
return { ok: false, error: '터미널 상태에서 재시작 불가' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const iteration = (state.ralph_iteration || 0) + 1;
|
|
103
|
+
if (iteration > (state.ralph_max || 10)) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: `ralph loop 초과: ${iteration}/${state.ralph_max || 10}회. 최종 실패.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const history = Array.isArray(state.phase_history) ? [...state.phase_history] : [];
|
|
111
|
+
history.push({ from: state.phase, to: 'plan', at: Date.now(), ralph_restart: true });
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
state: {
|
|
116
|
+
...state,
|
|
117
|
+
phase: 'plan',
|
|
118
|
+
fix_attempt: 0,
|
|
119
|
+
ralph_iteration: iteration,
|
|
120
|
+
phase_history: history,
|
|
121
|
+
updated_at: Date.now(),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
package/hub/platform.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile, execFileSync, execSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export const IS_WINDOWS = process.platform === 'win32';
|
|
6
|
+
export const IS_MAC = process.platform === 'darwin';
|
|
7
|
+
export const IS_LINUX = process.platform === 'linux';
|
|
8
|
+
export const TEMP_DIR = IS_WINDOWS ? os.tmpdir() : '/tmp';
|
|
9
|
+
export const PATH_SEP = path.sep;
|
|
10
|
+
|
|
11
|
+
function getPathApi(platform) {
|
|
12
|
+
return platform === 'win32' ? path.win32 : path.posix;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function coercePathInput(value, platform) {
|
|
16
|
+
const text = String(value ?? '');
|
|
17
|
+
if (platform === 'win32') {
|
|
18
|
+
return text.replaceAll('/', '\\');
|
|
19
|
+
}
|
|
20
|
+
return text.replaceAll('\\', '/');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sanitizePipeSegment(value) {
|
|
24
|
+
return String(value ?? '')
|
|
25
|
+
.trim()
|
|
26
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]+/gu, '-')
|
|
27
|
+
.replace(/-+/g, '-')
|
|
28
|
+
.replace(/^-|-$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildWhichCommandSpec(name, options = {}) {
|
|
32
|
+
const commandName = String(name ?? '').trim();
|
|
33
|
+
if (!commandName) return null;
|
|
34
|
+
|
|
35
|
+
const platform = options.platform || process.platform;
|
|
36
|
+
return {
|
|
37
|
+
lookupCommand: platform === 'win32' ? 'where' : 'which',
|
|
38
|
+
args: [commandName],
|
|
39
|
+
execOptions: {
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
timeout: options.timeout ?? 5000,
|
|
42
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
env: options.env || process.env,
|
|
45
|
+
cwd: options.cwd,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseWhichCommandOutput(output) {
|
|
51
|
+
return String(output)
|
|
52
|
+
.split(/\r?\n/u)
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.find(Boolean) || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function execFileAsync(command, args, options, execFileFn = execFile) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
execFileFn(command, args, options, (error, stdout) => {
|
|
60
|
+
if (error) {
|
|
61
|
+
reject(error);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
resolve(stdout);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 경로를 플랫폼에 맞게 정규화합니다.
|
|
71
|
+
* Windows 환경에서는 역슬래시(\)를 슬래시(/)로 변환하여 반환합니다.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} value - 정규화할 경로 문자열
|
|
74
|
+
* @param {object} [options] - 옵션
|
|
75
|
+
* @param {string} [options.platform] - 대상 플랫폼 (기본값: process.platform)
|
|
76
|
+
* @returns {string} 정규화된 경로
|
|
77
|
+
*/
|
|
78
|
+
export function normalizePath(value, options = {}) {
|
|
79
|
+
const platform = options.platform || process.platform;
|
|
80
|
+
const pathApi = getPathApi(platform);
|
|
81
|
+
const normalized = pathApi.normalize(coercePathInput(value, platform));
|
|
82
|
+
|
|
83
|
+
if (platform === 'win32') {
|
|
84
|
+
return normalized.replaceAll('\\', '/');
|
|
85
|
+
}
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 시스템에서 실행 가능한 명령의 절대 경로를 찾습니다.
|
|
91
|
+
* Windows에서는 'where', Unix 계열에서는 'which' 명령을 사용합니다.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} name - 찾을 명령어 이름
|
|
94
|
+
* @param {object} [options] - 옵션
|
|
95
|
+
* @param {string} [options.platform] - 대상 플랫폼
|
|
96
|
+
* @param {number} [options.timeout=5000] - 검색 타임아웃 (ms)
|
|
97
|
+
* @param {object} [options.env] - 환경 변수
|
|
98
|
+
* @param {string} [options.cwd] - 작업 디렉토리
|
|
99
|
+
* @returns {string|null} 명령어의 절대 경로 또는 찾지 못한 경우 null
|
|
100
|
+
*/
|
|
101
|
+
export function whichCommand(name, options = {}) {
|
|
102
|
+
const spec = buildWhichCommandSpec(name, options);
|
|
103
|
+
if (!spec) return null;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const output = execFileSync(spec.lookupCommand, spec.args, spec.execOptions);
|
|
107
|
+
return parseWhichCommandOutput(output);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function whichCommandAsync(name, options = {}) {
|
|
114
|
+
const spec = buildWhichCommandSpec(name, options);
|
|
115
|
+
if (!spec) return null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const output = await execFileAsync(
|
|
119
|
+
spec.lookupCommand,
|
|
120
|
+
spec.args,
|
|
121
|
+
spec.execOptions,
|
|
122
|
+
options.execFileFn || execFile,
|
|
123
|
+
);
|
|
124
|
+
return parseWhichCommandOutput(output);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 프로세스를 종료합니다.
|
|
132
|
+
* Windows에서는 트리 구조 종료(/T) 및 강제 종료(/F)를 지원합니다.
|
|
133
|
+
*
|
|
134
|
+
* @param {number|string} pid - 종료할 프로세스 ID
|
|
135
|
+
* @param {object} [options] - 옵션
|
|
136
|
+
* @param {string} [options.platform] - 대상 플랫폼
|
|
137
|
+
* @param {string} [options.signal='SIGTERM'] - 전송할 신호
|
|
138
|
+
* @param {boolean} [options.tree=false] - 자식 프로세스까지 포함하여 종료할지 여부
|
|
139
|
+
* @param {boolean} [options.force=false] - 강제 종료 여부
|
|
140
|
+
* @param {number} [options.timeout=5000] - 타임아웃 (ms)
|
|
141
|
+
* @returns {boolean} 종료 성공 여부
|
|
142
|
+
*/
|
|
143
|
+
export function killProcess(pid, options = {}) {
|
|
144
|
+
const numericPid = Number.parseInt(String(pid), 10);
|
|
145
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
|
|
146
|
+
|
|
147
|
+
const platform = options.platform || process.platform;
|
|
148
|
+
const signal = options.signal || 'SIGTERM';
|
|
149
|
+
const tree = options.tree === true;
|
|
150
|
+
const force = options.force === true || signal === 'SIGKILL';
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (platform === 'win32' && (tree || force)) {
|
|
154
|
+
const command = [
|
|
155
|
+
'taskkill',
|
|
156
|
+
'/PID',
|
|
157
|
+
String(numericPid),
|
|
158
|
+
tree ? '/T' : '',
|
|
159
|
+
force ? '/F' : '',
|
|
160
|
+
]
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.join(' ');
|
|
163
|
+
execSync(command, {
|
|
164
|
+
stdio: 'ignore',
|
|
165
|
+
timeout: options.timeout ?? 5000,
|
|
166
|
+
windowsHide: true,
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
process.kill(numericPid, signal);
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 플랫폼별 IPC 파이프 또는 소켓 경로를 생성합니다.
|
|
180
|
+
* Windows에서는 네임드 파이프 경로를, Unix 계열에서는 도메인 소켓 파일 경로를 반환합니다.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} name - 파이프/소켓 기본 이름
|
|
183
|
+
* @param {number|string} [pid=process.pid] - 프로세스 ID (식별자 추가용)
|
|
184
|
+
* @param {object} [options] - 옵션
|
|
185
|
+
* @param {string} [options.platform] - 대상 플랫폼
|
|
186
|
+
* @param {string} [options.tempDir] - 임시 디렉토리 경로 (Unix 전용)
|
|
187
|
+
* @returns {string} 플랫폼별 파이프/소켓 경로
|
|
188
|
+
*/
|
|
189
|
+
export function pipePath(name, pid = process.pid, options = {}) {
|
|
190
|
+
const platform = options.platform || process.platform;
|
|
191
|
+
const safeName = sanitizePipeSegment(name) || 'triflux';
|
|
192
|
+
const suffix = pid == null || pid === '' ? safeName : `${safeName}-${pid}`;
|
|
193
|
+
|
|
194
|
+
if (platform === 'win32') {
|
|
195
|
+
return `\\\\.\\pipe\\${suffix}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const baseDir = options.tempDir || TEMP_DIR;
|
|
199
|
+
return path.posix.join(baseDir, `${suffix}.sock`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 특정 경로가 대상 디렉토리 내부에 포함되는지 확인합니다.
|
|
204
|
+
* 대소문자 구분 및 상대 경로 처리를 플랫폼 규격에 맞게 수행합니다.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} resolvedPath - 검사할 절대 경로
|
|
207
|
+
* @param {string} dir - 기준이 되는 대상 디렉토리 경로
|
|
208
|
+
* @param {object} [options] - 옵션
|
|
209
|
+
* @param {string} [options.platform] - 대상 플랫폼
|
|
210
|
+
* @returns {boolean} 포함 여부
|
|
211
|
+
*/
|
|
212
|
+
export function isPathWithin(resolvedPath, dir, options = {}) {
|
|
213
|
+
if (!resolvedPath || !dir) return false;
|
|
214
|
+
|
|
215
|
+
const platform = options.platform || process.platform;
|
|
216
|
+
const pathApi = getPathApi(platform);
|
|
217
|
+
const left = pathApi.resolve(coercePathInput(resolvedPath, platform));
|
|
218
|
+
const right = pathApi.resolve(coercePathInput(dir, platform));
|
|
219
|
+
|
|
220
|
+
const normalizedLeft = platform === 'win32' ? left.toLowerCase() : left;
|
|
221
|
+
const normalizedRight = platform === 'win32' ? right.toLowerCase() : right;
|
|
222
|
+
const relative = pathApi.relative(normalizedRight, normalizedLeft);
|
|
223
|
+
|
|
224
|
+
return relative === '' || (!relative.startsWith('..') && !pathApi.isAbsolute(relative));
|
|
225
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anti-Slop Code Pass
|
|
3
|
+
* AI 생성 코드에서 불필요한 요소를 자동 탐지/제거하는 정적 분석 모듈
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
|
|
7
|
+
import { join, relative } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** @type {ReadonlyArray<{type: string, pattern: RegExp, severity: string, autoFixable: boolean, multiline: boolean}>} */
|
|
10
|
+
export const SLOP_PATTERNS = Object.freeze([
|
|
11
|
+
{
|
|
12
|
+
type: 'trivial_comment',
|
|
13
|
+
pattern: /^\s*\/\/\s*(import|define|set|get|return|export)\s/i,
|
|
14
|
+
severity: 'low',
|
|
15
|
+
autoFixable: true,
|
|
16
|
+
multiline: false,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
type: 'empty_catch',
|
|
20
|
+
pattern: /catch\s*\([^)]*\)\s*\{\s*\}/,
|
|
21
|
+
severity: 'med',
|
|
22
|
+
autoFixable: false,
|
|
23
|
+
multiline: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: 'console_debug',
|
|
27
|
+
pattern: /^\s*console\.(log|debug|info)\(/,
|
|
28
|
+
severity: 'low',
|
|
29
|
+
autoFixable: true,
|
|
30
|
+
multiline: false,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: 'useless_jsdoc',
|
|
34
|
+
pattern: /\/\*\*\s*\n\s*\*\s*\n\s*\*\//,
|
|
35
|
+
severity: 'low',
|
|
36
|
+
autoFixable: true,
|
|
37
|
+
multiline: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: 'rethrow_only',
|
|
41
|
+
pattern: /catch\s*\((\w+)\)\s*\{\s*throw\s+\1\s*;?\s*\}/,
|
|
42
|
+
severity: 'med',
|
|
43
|
+
autoFixable: false,
|
|
44
|
+
multiline: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'redundant_type',
|
|
48
|
+
pattern: /:\s*(string|number|boolean)\s*=\s*('[^']*'|"[^"]*"|\d+|true|false)/,
|
|
49
|
+
severity: 'low',
|
|
50
|
+
autoFixable: false,
|
|
51
|
+
multiline: false,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'commented_code',
|
|
55
|
+
pattern: /^\s*\/\/\s*(const |let |var |function |class |if\s*\(|for\s*\(|while\s*\(|return |await )/,
|
|
56
|
+
severity: 'low',
|
|
57
|
+
autoFixable: true,
|
|
58
|
+
multiline: false,
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const SEVERITY_WEIGHT = { low: 2, med: 5 };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 파일 내용에서 slop 패턴 탐지
|
|
66
|
+
* @param {string} content - 파일 내용
|
|
67
|
+
* @param {string} [filePath] - 파일 경로 (보고용)
|
|
68
|
+
* @returns {{ issues: Array<{line: number, type: string, severity: string, suggestion: string, text: string, autoFixable: boolean}>, score: number }}
|
|
69
|
+
*/
|
|
70
|
+
export function detectSlop(content, filePath = '') {
|
|
71
|
+
const lines = content.split('\n');
|
|
72
|
+
const issues = [];
|
|
73
|
+
|
|
74
|
+
for (const sp of SLOP_PATTERNS) {
|
|
75
|
+
if (sp.multiline) continue;
|
|
76
|
+
for (let i = 0; i < lines.length; i++) {
|
|
77
|
+
if (sp.pattern.test(lines[i])) {
|
|
78
|
+
issues.push({
|
|
79
|
+
line: i + 1,
|
|
80
|
+
type: sp.type,
|
|
81
|
+
severity: sp.severity,
|
|
82
|
+
suggestion: `${sp.type} 패턴 감지`,
|
|
83
|
+
text: lines[i].trim(),
|
|
84
|
+
autoFixable: sp.autoFixable,
|
|
85
|
+
file: filePath,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const sp of SLOP_PATTERNS) {
|
|
92
|
+
if (!sp.multiline) continue;
|
|
93
|
+
const regex = new RegExp(sp.pattern.source, sp.pattern.flags.replace('g', '') + 'g');
|
|
94
|
+
let match;
|
|
95
|
+
while ((match = regex.exec(content)) !== null) {
|
|
96
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
97
|
+
issues.push({
|
|
98
|
+
line,
|
|
99
|
+
type: sp.type,
|
|
100
|
+
severity: sp.severity,
|
|
101
|
+
suggestion: `${sp.type} 패턴 감지`,
|
|
102
|
+
text: match[0].split('\n')[0].trim(),
|
|
103
|
+
autoFixable: sp.autoFixable,
|
|
104
|
+
file: filePath,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
issues.sort((a, b) => a.line - b.line);
|
|
110
|
+
|
|
111
|
+
const totalPenalty = issues.reduce((sum, i) => sum + (SEVERITY_WEIGHT[i.severity] || 2), 0);
|
|
112
|
+
const score = Math.max(0, 100 - totalPenalty);
|
|
113
|
+
|
|
114
|
+
return { issues, score };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 자동 수정 (safe transforms만)
|
|
119
|
+
* @param {string} content - 파일 내용
|
|
120
|
+
* @param {Array<{type: string, autoFixable: boolean}>} issues - detectSlop 결과
|
|
121
|
+
* @returns {{ fixed: string, applied: number, skipped: number }}
|
|
122
|
+
*/
|
|
123
|
+
export function autoFixSlop(content, issues) {
|
|
124
|
+
if (!issues || issues.length === 0) return { fixed: content, applied: 0, skipped: 0 };
|
|
125
|
+
|
|
126
|
+
const fixable = issues.filter(i => i.autoFixable);
|
|
127
|
+
const skipped = issues.length - fixable.length;
|
|
128
|
+
|
|
129
|
+
if (fixable.length === 0) return { fixed: content, applied: 0, skipped };
|
|
130
|
+
|
|
131
|
+
let fixed = content;
|
|
132
|
+
let applied = 0;
|
|
133
|
+
|
|
134
|
+
// Multi-line: useless_jsdoc 제거
|
|
135
|
+
if (fixable.some(i => i.type === 'useless_jsdoc')) {
|
|
136
|
+
const matches = fixed.match(/\/\*\*\s*\n\s*\*\s*\n\s*\*\/\n?/g);
|
|
137
|
+
if (matches) {
|
|
138
|
+
fixed = fixed.replace(/\/\*\*\s*\n\s*\*\s*\n\s*\*\/\n?/g, '');
|
|
139
|
+
applied += matches.length;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Line-level: trivial_comment, console_debug, commented_code 제거
|
|
144
|
+
const lineTypes = new Set(
|
|
145
|
+
fixable
|
|
146
|
+
.filter(i => ['trivial_comment', 'console_debug', 'commented_code'].includes(i.type))
|
|
147
|
+
.map(i => i.type),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (lineTypes.size > 0) {
|
|
151
|
+
const linePatterns = SLOP_PATTERNS.filter(p => lineTypes.has(p.type));
|
|
152
|
+
const lines = fixed.split('\n');
|
|
153
|
+
const result = [];
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
let remove = false;
|
|
156
|
+
for (const p of linePatterns) {
|
|
157
|
+
if (p.pattern.test(line)) {
|
|
158
|
+
remove = true;
|
|
159
|
+
applied++;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!remove) result.push(line);
|
|
164
|
+
}
|
|
165
|
+
fixed = result.join('\n');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { fixed, applied, skipped };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function matchesGlob(filePath, pattern) {
|
|
172
|
+
const normalized = '/' + filePath.replace(/\\/g, '/');
|
|
173
|
+
|
|
174
|
+
// **/*.ext → 확장자 매칭
|
|
175
|
+
if (pattern.startsWith('**/*.')) {
|
|
176
|
+
const ext = pattern.slice(4);
|
|
177
|
+
return normalized.endsWith(ext);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// *.ext → 확장자 매칭 (디렉토리 무관)
|
|
181
|
+
if (pattern.startsWith('*.') && !pattern.includes('/')) {
|
|
182
|
+
const ext = pattern.slice(1);
|
|
183
|
+
return normalized.endsWith(ext);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// **/dir/** → 디렉토리 포함 여부
|
|
187
|
+
const dirMatch = pattern.match(/^\*\*\/([^*]+)\/\*\*$/);
|
|
188
|
+
if (dirMatch) {
|
|
189
|
+
return normalized.includes('/' + dirMatch[1] + '/');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 디렉토리 전체 스캔
|
|
197
|
+
* @param {string} dirPath - 스캔 대상 디렉토리
|
|
198
|
+
* @param {object} [opts]
|
|
199
|
+
* @param {string[]} [opts.include] - 포함할 glob 패턴
|
|
200
|
+
* @param {string[]} [opts.exclude] - 제외할 glob 패턴
|
|
201
|
+
* @param {boolean} [opts.autoFix] - 자동 수정 여부
|
|
202
|
+
* @returns {Promise<{ files: Array<{path: string, issues: Array, score: number}>, summary: object }>}
|
|
203
|
+
*/
|
|
204
|
+
export async function scanDirectory(dirPath, opts = {}) {
|
|
205
|
+
const {
|
|
206
|
+
include = ['**/*.mjs', '**/*.js', '**/*.ts'],
|
|
207
|
+
exclude = ['**/node_modules/**', '**/dist/**', '**/.git/**'],
|
|
208
|
+
autoFix = false,
|
|
209
|
+
} = opts;
|
|
210
|
+
|
|
211
|
+
const entries = await readdir(dirPath, { recursive: true });
|
|
212
|
+
const files = [];
|
|
213
|
+
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
const normalized = entry.replace(/\\/g, '/');
|
|
216
|
+
const fullPath = join(dirPath, entry);
|
|
217
|
+
|
|
218
|
+
let st;
|
|
219
|
+
try { st = await stat(fullPath); } catch { continue; }
|
|
220
|
+
if (!st.isFile()) continue;
|
|
221
|
+
|
|
222
|
+
const included = include.some(p => matchesGlob(normalized, p));
|
|
223
|
+
const excluded = exclude.some(p => matchesGlob(normalized, p));
|
|
224
|
+
if (!included || excluded) continue;
|
|
225
|
+
|
|
226
|
+
const fileContent = await readFile(fullPath, 'utf-8');
|
|
227
|
+
const { issues, score } = detectSlop(fileContent, normalized);
|
|
228
|
+
|
|
229
|
+
if (autoFix && issues.length > 0) {
|
|
230
|
+
const { fixed, applied } = autoFixSlop(fileContent, issues);
|
|
231
|
+
if (applied > 0) await writeFile(fullPath, fixed, 'utf-8');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
files.push({ path: normalized, issues, score });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const totalIssues = files.reduce((sum, f) => sum + f.issues.length, 0);
|
|
238
|
+
const avgScore = files.length > 0
|
|
239
|
+
? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
|
|
240
|
+
: 100;
|
|
241
|
+
|
|
242
|
+
const byType = {};
|
|
243
|
+
for (const f of files) {
|
|
244
|
+
for (const issue of f.issues) {
|
|
245
|
+
byType[issue.type] = (byType[issue.type] || 0) + 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
files,
|
|
251
|
+
summary: { totalFiles: files.length, totalIssues, averageScore: avgScore, byType },
|
|
252
|
+
};
|
|
253
|
+
}
|